Initial commit - ERP System

This commit is contained in:
root
2026-05-20 18:58:23 +00:00
commit e174936997
2697 changed files with 1628427 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
// /opt/erp-system/app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import prisma from "../../../../lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Passwort", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const teamUser = await prisma.user.findUnique({
where: { email: credentials.email },
include: { role: true }
});
if (teamUser) {
const match = await bcrypt.compare(credentials.password, teamUser.passwordHash);
if (match) {
return {
id: `TEAM_${teamUser.id}`, dbId: teamUser.id, email: teamUser.email,
firstName: teamUser.firstName, lastName: teamUser.lastName,
roleName: teamUser.role?.name || 'Keine Rolle', permissions: teamUser.role?.permissions || [],
userType: 'TEAM', forcePasswordChange: false
};
}
}
const customer = await prisma.customer.findUnique({ where: { email: credentials.email } });
if (customer && customer.passwordHash) {
const match = await bcrypt.compare(credentials.password, customer.passwordHash);
if (match) {
return {
id: `CUST_${customer.id}`, dbId: customer.id, email: customer.email,
firstName: customer.firstName, lastName: customer.lastName,
roleName: 'Kunde', permissions: [], userType: 'CUSTOMER',
companyName: customer.companyName,
forcePasswordChange: customer.forcePasswordChange // WICHTIG: Flag übergeben
};
}
}
return null;
}
})
],
callbacks: {
async jwt({ token, user }: any) {
if (user) {
token.dbId = user.dbId; token.firstName = user.firstName; token.lastName = user.lastName;
token.roleName = user.roleName; token.permissions = user.permissions;
token.userType = user.userType; token.companyName = user.companyName;
token.forcePasswordChange = user.forcePasswordChange;
}
return token;
},
async session({ session, token }: any) {
if (token) {
session.user.id = token.dbId; session.user.firstName = token.firstName;
session.user.lastName = token.lastName; session.user.roleName = token.roleName;
session.user.permissions = token.permissions || []; session.user.userType = token.userType;
session.user.companyName = token.companyName;
session.user.forcePasswordChange = token.forcePasswordChange;
}
return session;
}
},
pages: { signIn: "/login" },
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+57
View File
@@ -0,0 +1,57 @@
// /opt/erp-system/app/api/billing/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
async function checkAccess() {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
return perms.includes('TEAM_MANAGE'); // Wir nutzen vorerst dieses Recht für die Abrechnung
}
export async function GET() {
if (!await checkAccess()) return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
try {
const unbilledEntries = await prisma.timeEntry.findMany({
where: { isBilled: false },
include: {
user: { select: { firstName: true, lastName: true } },
ticket: {
include: { customer: true }
}
},
orderBy: { createdAt: 'asc' }
});
return NextResponse.json(unbilledEntries);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function PUT(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
try {
const body = await request.json();
const { entryIds } = body;
if (!Array.isArray(entryIds) || entryIds.length === 0) {
return NextResponse.json({ error: 'Keine Einträge übergeben' }, { status: 400 });
}
await prisma.timeEntry.updateMany({
where: { id: { in: entryIds } },
data: {
isBilled: true,
billedAt: new Date()
}
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Speichern' }, { status: 500 });
}
}
+140
View File
@@ -0,0 +1,140 @@
// /opt/erp-system/app/api/cron/imap/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export const dynamic = 'force-dynamic';
const UPLOAD_DIR = join(process.cwd(), 'uploads');
export async function GET() {
try {
const settings = await prisma.systemSettings.findFirst({ where: { id: 1 } });
if (!settings || !settings.imapHost || !settings.imapUser || !settings.imapPass) {
return NextResponse.json({ error: 'IMAP Zugangsdaten fehlen.' }, { status: 400 });
}
const client = new ImapFlow({
host: settings.imapHost,
port: settings.imapPort,
secure: settings.imapPort === 993,
auth: { user: settings.imapUser, pass: settings.imapPass },
logger: false
});
await client.connect();
let lock = await client.getMailboxLock('INBOX');
let processedCounter = 0;
try {
for await (let message of client.fetch({ seen: false }, { source: true, uid: true })) {
const parsed = await simpleParser(message.source);
const fromEmail = parsed.from?.value[0]?.address;
const fromName = parsed.from?.value[0]?.name || 'Unbekannt';
const subject = parsed.subject || 'Kein Betreff';
const textContent = parsed.text || parsed.textAsHtml || '(Kein Inhalt)';
if (!fromEmail) continue;
// 1. Kunden-Matching (Haupt-Email, alternative E-Mails oder Mitarbeiter)
let targetCustomerId = null;
const directCustomer = await prisma.customer.findFirst({
where: {
OR: [
{ email: fromEmail },
{ additionalEmails: { has: fromEmail } }
]
}
});
if (directCustomer) {
targetCustomerId = directCustomer.id;
} else {
const contact = await prisma.customerContact.findFirst({
where: { email: fromEmail }
});
if (contact) {
targetCustomerId = contact.customerId;
}
}
// Falls komplett unbekannt, neuen Kunden anlegen
if (!targetCustomerId) {
const newCustomer = await prisma.customer.create({
data: {
email: fromEmail,
firstName: fromName,
lastName: '(Auto-Erfasst)',
companyName: 'Aus E-Mail Anfrage'
}
});
targetCustomerId = newCustomer.id;
}
// 2. Ticket Zuordnung
const ticketMatch = subject.match(/Ticket #(\d+)/i);
let ticketId = ticketMatch ? parseInt(ticketMatch[1]) : null;
let activeTicketId = null;
if (ticketId) {
const existingTicket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (existingTicket) {
activeTicketId = existingTicket.id;
await prisma.ticketMessage.create({
data: { content: textContent, isFromCustomer: true, ticketId: existingTicket.id }
});
if (existingTicket.status !== 'OPEN' && existingTicket.status !== 'IN_PROGRESS') {
await prisma.ticket.update({ where: { id: existingTicket.id }, data: { status: 'OPEN' } });
}
}
}
if (!activeTicketId) {
const newTicket = await prisma.ticket.create({
data: { title: subject, description: textContent, customerId: targetCustomerId, status: 'OPEN' }
});
activeTicketId = newTicket.id;
}
// 3. Dateianhänge verarbeiten
if (parsed.attachments && parsed.attachments.length > 0) {
for (const att of parsed.attachments) {
// Buffer in Datei schreiben
const safeOriginalName = (att.filename || 'unbekannt.dat').replace(/[^a-zA-Z0-9.-]/g, '_');
const savedName = `${Date.now()}-${safeOriginalName}`;
const filepath = join(UPLOAD_DIR, savedName);
await writeFile(filepath, att.content);
// DB Eintrag
await prisma.attachment.create({
data: {
fileName: att.filename || 'unbekannt.dat',
savedName: savedName,
fileSize: att.size || 0,
fileType: att.contentType || 'application/octet-stream',
ticketId: activeTicketId
}
});
}
}
await client.messageFlagsAdd(message.uid, ['\\Seen'], { uid: true });
processedCounter++;
}
} finally {
lock.release();
}
await client.logout();
return NextResponse.json({ success: true, processedTickets: processedCounter });
} catch (error: any) {
console.error('IMAP Error:', error);
return NextResponse.json({ error: 'Verarbeitungsfehler', details: error.message }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
// /opt/erp-system/app/api/customers/[id]/contacts/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const body = await request.json();
const contact = await prisma.customerContact.create({
data: {
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone || null,
customerId: parseInt(params.id)
}
});
return NextResponse.json(contact, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Erstellen' }, { status: 500 });
}
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { searchParams } = new URL(request.url);
const contactId = searchParams.get('contactId');
if (!contactId) return NextResponse.json({ error: 'Kontakt-ID fehlt' }, { status: 400 });
await prisma.customerContact.delete({
where: { id: parseInt(contactId) }
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const contracts = await prisma.contract.findMany({
where: { customerId: parseInt(params.id) },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(contracts);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const data = await request.json();
const contract = await prisma.contract.create({
data: {
customerId: parseInt(params.id),
title: data.title,
description: data.description || '',
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
monthlyPrice: parseFloat(data.monthlyPrice) || 0,
status: data.status || 'ACTIVE'
}
});
return NextResponse.json(contract);
} catch (error) {
return NextResponse.json({ error: 'Erstellen fehlgeschlagen' }, { status: 500 });
}
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { searchParams } = new URL(request.url);
const contractId = searchParams.get('contractId');
if (!contractId) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
await prisma.contract.delete({
where: { id: parseInt(contractId) }
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const data = await request.json();
const credential = await prisma.customerCredential.create({
data: {
customerId: parseInt(params.id),
title: data.title,
username: data.username,
password: data.password
}
});
return NextResponse.json(credential);
} catch (error) {
return NextResponse.json({ error: 'Erstellen fehlgeschlagen' }, { status: 500 });
}
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { searchParams } = new URL(request.url);
const credId = searchParams.get('credId');
if (!credId) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
await prisma.customerCredential.delete({
where: { id: parseInt(credId) }
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import fs from 'fs/promises';
import path from 'path';
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const { searchParams } = new URL(request.url);
const downloadId = searchParams.get('download');
if (downloadId) {
const doc = await prisma.customerDocument.findUnique({
where: { id: parseInt(downloadId) }
});
if (!doc) return new NextResponse('Not found', { status: 404 });
const filePath = path.join(process.cwd(), 'uploads', 'customers', params.id, doc.savedName);
const fileBuffer = await fs.readFile(filePath);
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': doc.fileType,
'Content-Disposition': `attachment; filename="${doc.fileName}"`,
},
});
}
const documents = await prisma.customerDocument.findMany({
where: { customerId: parseInt(params.id) },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(documents);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
const buffer = Buffer.from(await file.arrayBuffer());
const savedName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const uploadDir = path.join(process.cwd(), 'uploads', 'customers', params.id);
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, savedName), buffer);
const doc = await prisma.customerDocument.create({
data: {
customerId: parseInt(params.id),
fileName: file.name,
savedName: savedName,
fileSize: file.size,
fileType: file.type
}
});
return NextResponse.json(doc);
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 });
}
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const params = await context.params;
const { searchParams } = new URL(request.url);
const docId = searchParams.get('docId');
if (!docId) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
const doc = await prisma.customerDocument.findUnique({
where: { id: parseInt(docId) }
});
if (doc) {
const filePath = path.join(process.cwd(), 'uploads', 'customers', params.id, doc.savedName);
try {
await fs.unlink(filePath);
} catch(e) {
// ignore if file doesn't exist
}
await prisma.customerDocument.delete({ where: { id: parseInt(docId) } });
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
@@ -0,0 +1,40 @@
// /opt/erp-system/app/api/customers/[id]/reset-password/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import bcrypt from 'bcryptjs';
import { sendEmail } from '../../../../../lib/email';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../../auth/[...nextauth]/route";
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).userType !== 'TEAM') {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const params = await context.params;
const customerId = parseInt(params.id);
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) return NextResponse.json({ error: 'Kunde nicht gefunden' }, { status: 404 });
const tempPassword = Math.random().toString(36).slice(-8);
const hash = await bcrypt.hash(tempPassword, 10);
await prisma.customer.update({
where: { id: customerId },
data: { passwordHash: hash, forcePasswordChange: true }
});
await sendEmail({
to: customer.email,
subject: "Ihr Passwort wurde zurückgesetzt",
text: `Hallo ${customer.firstName},\n\nIhr Passwort für das Kundenportal wurde durch unseren Support zurückgesetzt.\n\nIhr neues Start-Passwort lautet: ${tempPassword}\n\nBitte loggen Sie sich ein. Sie werden aufgefordert, sofort ein neues, eigenes Passwort zu vergeben.\n\nViele Grüße\nIhr Support-Team`
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Reset' }, { status: 500 });
}
}
+78
View File
@@ -0,0 +1,78 @@
// /opt/erp-system/app/api/customers/[id]/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
async function getPerms() {
const session = await getServerSession(authOptions);
if (!session) return null;
return (session.user as any)?.permissions || [];
}
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
const perms = await getPerms();
if (!perms) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
try {
const params = await context.params;
const customer = await prisma.customer.findUnique({
where: { id: parseInt(params.id) },
include: {
contacts: { orderBy: { createdAt: 'desc' } },
tickets: { orderBy: { createdAt: 'desc' } },
contracts: { orderBy: { createdAt: 'desc' } },
documents: { orderBy: { createdAt: 'desc' } },
credentials: { orderBy: { createdAt: 'desc' } }
}
});
if (!customer) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 });
return NextResponse.json(customer);
} catch (error) {
console.error('Customer GET Error:', error);
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
const perms = await getPerms();
if (!perms || (!perms.includes('CUSTOMERS_MANAGE') && !perms.includes('CUSTOMERS_EDIT'))) {
return NextResponse.json({ error: 'Keine Berechtigung zum Bearbeiten von Kundendaten' }, { status: 403 });
}
try {
const params = await context.params;
const body = await request.json();
const updateData: any = {
companyName: body.companyName,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone,
address: body.address,
zipCode: body.zipCode,
city: body.city,
additionalEmails: body.additionalEmails || [],
};
// Nur ein neues Passwort hashen und speichern, wenn das Feld ausgefüllt wurde
if (body.password && body.password.trim() !== '') {
updateData.passwordHash = await bcrypt.hash(body.password, 10);
}
const customer = await prisma.customer.update({
where: { id: parseInt(params.id) },
data: updateData,
include: { contacts: true }
});
return NextResponse.json(customer);
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Update fehlgeschlagen' }, { status: 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
// /opt/erp-system/app/api/customers/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import bcrypt from 'bcryptjs';
import { sendEmail } from '../../../lib/email';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
const customers = await prisma.customer.findMany({ orderBy: { companyName: 'asc' } });
return NextResponse.json(customers);
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
try {
const body = await request.json();
// 1. Zufälliges Start-Passwort generieren (8 Zeichen)
const tempPassword = Math.random().toString(36).slice(-8);
const hash = await bcrypt.hash(tempPassword, 10);
// 2. Kunde anlegen
const customer = await prisma.customer.create({
data: {
companyName: body.companyName,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone,
passwordHash: hash,
forcePasswordChange: true // Muss beim ersten Login geändert werden
}
});
// 3. Willkommens-E-Mail senden
await sendEmail({
to: customer.email,
subject: "Willkommen im Kundenportal",
text: `Hallo ${customer.firstName} ${customer.lastName},\n\nIhr Kundenkonto wurde erfolgreich eingerichtet.\n\nIhre Zugangsdaten lauten:\nLogin: ${customer.email}\nPasswort: ${tempPassword}\n\nBitte loggen Sie sich in unser Portal ein. Sie werden nach dem ersten Login aufgefordert, ein eigenes Passwort zu vergeben.\n\nViele Grüße\nIhr Support-Team`
});
return NextResponse.json(customer, { status: 201 });
} catch (error: any) {
console.error(error);
return NextResponse.json({ error: 'Fehler beim Erstellen des Kunden' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
if (!perms.includes('DATA_DELETE')) return NextResponse.json({ error: 'Keine Löschberechtigung' }, { status: 403 });
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
const customerId = parseInt(id);
// Delete all related data first (non-cascading relations)
await prisma.ticketMessage.deleteMany({ where: { ticket: { customerId } } });
await prisma.ticketNote.deleteMany({ where: { ticket: { customerId } } });
await prisma.timeEntry.deleteMany({ where: { ticket: { customerId } } });
await prisma.ticketSurvey.deleteMany({ where: { ticket: { customerId } } });
await prisma.attachment.deleteMany({ where: { ticket: { customerId } } });
await prisma.ticket.deleteMany({ where: { customerId } });
// Cascading relations (contacts, contracts, documents, credentials) are handled by onDelete: Cascade
await prisma.customer.delete({ where: { id: customerId } });
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json([], { status: 401 });
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
if (!q || q.length < 2) return NextResponse.json([]);
const customers = await prisma.customer.findMany({
where: {
OR: [
{ companyName: { contains: q, mode: 'insensitive' } },
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
]
},
select: {
id: true,
companyName: true,
firstName: true,
lastName: true,
email: true,
},
take: 8,
orderBy: { companyName: 'asc' }
});
return NextResponse.json(customers);
}
+77
View File
@@ -0,0 +1,77 @@
// /opt/erp-system/app/api/dashboard/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
const userId = parseInt((session.user as any).id);
const userType = (session.user as any).userType;
// ----------------------------------------------------
// METRIKEN FÜR KUNDEN
// ----------------------------------------------------
if (userType === 'CUSTOMER') {
const [openTicketsCount, closedTicketsCount, recentTickets] = await Promise.all([
prisma.ticket.count({
where: { customerId: userId, status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_FOR_CUSTOMER'] } }
}),
prisma.ticket.count({
where: { customerId: userId, status: { in: ['RESOLVED', 'CLOSED'] } }
}),
prisma.ticket.findMany({
where: { customerId: userId },
take: 5,
orderBy: { updatedAt: 'desc' },
include: { customer: { select: { companyName: true, firstName: true, lastName: true } } }
})
]);
return NextResponse.json({
userType: 'CUSTOMER',
openTickets: openTicketsCount,
closedTickets: closedTicketsCount,
recentTickets
});
}
// ----------------------------------------------------
// METRIKEN FÜR TEAM-MITARBEITER
// ----------------------------------------------------
const [openTicketsCount, myTicketsCount, recentTickets, timeEntries] = await Promise.all([
prisma.ticket.count({
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_FOR_CUSTOMER'] } }
}),
prisma.ticket.count({
where: { assignedToId: userId, status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_FOR_CUSTOMER'] } }
}),
prisma.ticket.findMany({
take: 5,
orderBy: { updatedAt: 'desc' },
include: { customer: { select: { companyName: true, firstName: true, lastName: true } } }
}),
prisma.timeEntry.aggregate({
_sum: { durationMins: true }
})
]);
const totalMinutes = timeEntries._sum.durationMins || 0;
const totalHours = totalMinutes / 60;
return NextResponse.json({
userType: 'TEAM',
openTickets: openTicketsCount,
myTickets: myTicketsCount,
recentTickets,
totalHours
});
} catch (error) {
console.error("Dashboard Fehler:", error);
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
// /opt/erp-system/app/api/portal/password/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import bcrypt from 'bcryptjs';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
export async function PUT(request: Request) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).userType !== 'CUSTOMER') {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const body = await request.json();
if (!body.password || body.password.length < 6) {
return NextResponse.json({ error: 'Passwort zu kurz' }, { status: 400 });
}
const hash = await bcrypt.hash(body.password, 10);
const customerId = parseInt((session.user as any).id);
await prisma.customer.update({
where: { id: customerId },
data: { passwordHash: hash, forcePasswordChange: false }
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Speichern' }, { status: 500 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import path from 'path';
export async function GET(request: Request, context: { params: Promise<{ name: string }> }) {
try {
const { name } = await context.params;
const filePath = path.join(process.cwd(), 'uploads', 'products', name);
const buffer = await readFile(filePath);
const ext = name.split('.').pop()?.toLowerCase();
const contentType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
return new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' } });
} catch {
return NextResponse.json({ error: 'Bild nicht gefunden' }, { status: 404 });
}
}
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
async function checkPerm(perm: string) {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
return perms.includes(perm);
}
export async function GET() {
try {
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return NextResponse.json(products);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request) {
if (!await checkPerm('PURCHASING_MANAGE')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const formData = await request.formData();
const name = formData.get('name') as string;
const description = formData.get('description') as string || '';
const sku = formData.get('sku') as string || null;
const purchasePrice = parseFloat(formData.get('purchasePrice') as string || '0');
const salePrice = parseFloat(formData.get('salePrice') as string || '0');
const stock = parseInt(formData.get('stock') as string || '0');
const unit = formData.get('unit') as string || 'Stk';
const category = formData.get('category') as string || null;
const trackStock = formData.get('trackStock') !== 'false';
const image = formData.get('image') as File | null;
let imagePath = null;
if (image && image.size > 0) {
const uploadDir = path.join(process.cwd(), 'uploads', 'products');
await mkdir(uploadDir, { recursive: true });
const ext = image.name.split('.').pop();
const savedName = `prod_${Date.now()}.${ext}`;
const buffer = Buffer.from(await image.arrayBuffer());
await writeFile(path.join(uploadDir, savedName), buffer);
imagePath = `/api/products/image/${savedName}`;
}
const product = await prisma.product.create({
data: { name, description, sku, purchasePrice, salePrice, stock, unit, category, trackStock, imagePath }
});
return NextResponse.json(product, { status: 201 });
} catch (error: any) {
if (error.code === 'P2002') return NextResponse.json({ error: 'Artikelnummer existiert bereits' }, { status: 400 });
console.error(error);
return NextResponse.json({ error: 'Fehler beim Erstellen' }, { status: 500 });
}
}
export async function PUT(request: Request) {
if (!await checkPerm('PURCHASING_MANAGE')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const formData = await request.formData();
const id = parseInt(formData.get('id') as string);
const data: any = {
name: formData.get('name') as string,
description: formData.get('description') as string || '',
sku: formData.get('sku') as string || null,
purchasePrice: parseFloat(formData.get('purchasePrice') as string || '0'),
salePrice: parseFloat(formData.get('salePrice') as string || '0'),
stock: parseInt(formData.get('stock') as string || '0'),
unit: formData.get('unit') as string || 'Stk',
category: formData.get('category') as string || null,
active: formData.get('active') === 'true',
trackStock: formData.get('trackStock') !== 'false',
};
const image = formData.get('image') as File | null;
if (image && image.size > 0) {
const uploadDir = path.join(process.cwd(), 'uploads', 'products');
await mkdir(uploadDir, { recursive: true });
const ext = image.name.split('.').pop();
const savedName = `prod_${Date.now()}.${ext}`;
const buffer = Buffer.from(await image.arrayBuffer());
await writeFile(path.join(uploadDir, savedName), buffer);
data.imagePath = `/api/products/image/${savedName}`;
}
const product = await prisma.product.update({ where: { id }, data });
return NextResponse.json(product);
} catch (error: any) {
if (error.code === 'P2002') return NextResponse.json({ error: 'Artikelnummer existiert bereits' }, { status: 400 });
console.error(error);
return NextResponse.json({ error: 'Fehler beim Aktualisieren' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
if (!await checkPerm('PURCHASING_MANAGE')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { searchParams } = new URL(request.url);
const id = parseInt(searchParams.get('id') || '0');
await prisma.product.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
// /opt/erp-system/app/api/roles/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
async function checkAccess() {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
return perms.includes('TEAM_MANAGE');
}
export async function GET() {
if (!await checkAccess()) return NextResponse.json({ error: 'Access denied' }, { status: 403 });
try {
const roles = await prisma.role.findMany({
include: { _count: { select: { users: true } } },
orderBy: { id: 'asc' }
});
return NextResponse.json(roles);
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Laden' }, { status: 500 });
}
}
export async function POST(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Access denied' }, { status: 403 });
try {
const body = await request.json();
const newRole = await prisma.role.create({
data: { name: body.name, permissions: body.permissions }
});
return NextResponse.json(newRole, { status: 201 });
} catch (error: any) {
if (error.code === 'P2002') return NextResponse.json({ error: 'Rollenname existiert bereits.' }, { status: 400 });
return NextResponse.json({ error: 'Fehler beim Speichern' }, { status: 500 });
}
}
export async function PUT(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Access denied' }, { status: 403 });
try {
const body = await request.json();
const updatedRole = await prisma.role.update({
where: { id: body.id },
data: { name: body.name, permissions: body.permissions }
});
return NextResponse.json(updatedRole, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Speichern' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
if (!perms.includes('DATA_DELETE')) return NextResponse.json({ error: 'Keine Löschberechtigung' }, { status: 403 });
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
const roleId = parseInt(id);
// Check if users are still assigned
const usersWithRole = await prisma.user.count({ where: { roleId } });
if (usersWithRole > 0) {
return NextResponse.json({ error: `Diese Gruppe ist noch ${usersWithRole} Nutzer(n) zugeordnet. Bitte weise sie zuerst einer anderen Gruppe zu.` }, { status: 400 });
}
await prisma.role.delete({ where: { id: roleId } });
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+231
View File
@@ -0,0 +1,231 @@
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
const PREFIXES: Record<string, string> = {
QUOTE: 'ANG', ORDER_CONFIRMATION: 'AB', DELIVERY_NOTE: 'LS', INVOICE: 'RE', CREDIT_NOTE: 'RK'
};
const NUMBER_FIELDS: Record<string, string> = {
QUOTE: 'nextQuoteNumber', ORDER_CONFIRMATION: 'nextOrderNumber',
DELIVERY_NOTE: 'nextDeliveryNumber', INVOICE: 'nextInvoiceNumber', CREDIT_NOTE: 'nextCreditNoteNumber'
};
async function generateNumber(type: string) {
const settings = await prisma.systemSettings.findFirst();
if (!settings) throw new Error('SystemSettings not found');
const year = new Date().getFullYear();
const prefix = PREFIXES[type] || 'DOC';
const field = NUMBER_FIELDS[type];
const num = (settings as any)[field] || 1;
await prisma.systemSettings.update({ where: { id: settings.id }, data: { [field]: num + 1 } });
return `${prefix}-${year}-${num.toString().padStart(4, '0')}`;
}
// Set status to ARCHIVED while preserving original status in previousStatus
async function archiveDoc(docId: number) {
const doc = await prisma.salesDocument.findUnique({ where: { id: docId }, select: { status: true } });
if (!doc || doc.status === 'ARCHIVED' || doc.status === 'CANCELLED') return;
await prisma.salesDocument.update({
where: { id: docId },
data: { previousStatus: doc.status, status: 'ARCHIVED' }
});
}
// Walk the chain back via sourceDocumentId and collect all related doc IDs
async function getChainIds(docId: number): Promise<number[]> {
const ids: number[] = [];
let currentId: number | null = docId;
while (currentId) {
const doc = await prisma.salesDocument.findUnique({ where: { id: currentId }, select: { sourceDocumentId: true } });
if (doc?.sourceDocumentId) {
ids.push(doc.sourceDocumentId);
currentId = doc.sourceDocumentId;
} else {
currentId = null;
}
}
return ids;
}
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
const doc = await prisma.salesDocument.findUnique({
where: { id: parseInt(id) },
include: {
customer: true,
items: { include: { product: true } },
createdBy: { select: { firstName: true, lastName: true } }
}
});
if (!doc) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 });
return NextResponse.json(doc);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
if (!perms.includes('SALES_MANAGE')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await context.params;
const body = await request.json();
const docId = parseInt(id);
// Handle signature (not for invoices/credit notes)
if (body.signatureData) {
const doc = await prisma.salesDocument.findUnique({ where: { id: docId } });
if (doc?.type === 'INVOICE' || doc?.type === 'CREDIT_NOTE') {
return NextResponse.json({ error: 'Kann nicht unterschrieben werden' }, { status: 400 });
}
const updated = await prisma.salesDocument.update({
where: { id: docId },
data: { signatureData: body.signatureData, signedAt: new Date(), status: 'ACCEPTED' }
});
return NextResponse.json(updated);
}
// Handle "create follow-up document" action
if (body.action === 'CREATE_FOLLOWUP') {
const doc = await prisma.salesDocument.findUnique({ where: { id: docId }, include: { items: true } });
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 });
let newType = '';
if (doc.type === 'ORDER_CONFIRMATION') newType = 'DELIVERY_NOTE';
else if (doc.type === 'DELIVERY_NOTE') newType = 'INVOICE';
else if (doc.type === 'INVOICE') newType = 'CREDIT_NOTE';
else return NextResponse.json({ error: 'Kein Folgebeleg möglich' }, { status: 400 });
const number = await generateNumber(newType);
const followUp = await prisma.salesDocument.create({
data: {
type: newType as any,
number,
customerId: doc.customerId,
createdById: (session?.user as any)?.id || null,
sourceDocumentId: doc.id,
subtotal: doc.subtotal, taxAmount: doc.taxAmount, total: doc.total,
notes: newType === 'CREDIT_NOTE' ? `Rechnungskorrektur zu ${doc.number}` : doc.notes,
items: {
create: doc.items.map(i => ({
description: i.description, quantity: i.quantity, unitPrice: i.unitPrice,
taxRate: i.taxRate, total: i.total, productId: i.productId
}))
}
},
include: { items: true }
});
// CREDIT_NOTE → cancel the original invoice
if (newType === 'CREDIT_NOTE') {
await prisma.salesDocument.update({
where: { id: doc.id },
data: { previousStatus: doc.status, status: 'CANCELLED' }
});
} else {
// Archive the source document
await archiveDoc(doc.id);
}
// If creating an INVOICE → also archive all earlier docs in the chain
if (newType === 'INVOICE') {
const chainIds = await getChainIds(followUp.id);
for (const cid of chainIds) { await archiveDoc(cid); }
}
return NextResponse.json(followUp, { status: 201 });
}
// Handle status changes
if (body.status) {
const doc = await prisma.salesDocument.findUnique({
where: { id: docId },
include: { items: { include: { product: true } } }
});
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// QUOTE accepted → reserve stock + auto-create AB (SENT) + archive quote
if (doc.type === 'QUOTE' && body.status === 'ACCEPTED') {
for (const item of doc.items) {
if (item.productId && item.product?.trackStock) {
await prisma.product.update({
where: { id: item.productId },
data: { reservedStock: { increment: Math.ceil(item.quantity) } }
});
}
}
// Auto-create AB with status SENT
const abNumber = await generateNumber('ORDER_CONFIRMATION');
const ab = await prisma.salesDocument.create({
data: {
type: 'ORDER_CONFIRMATION',
number: abNumber,
status: 'SENT',
customerId: doc.customerId,
createdById: (session?.user as any)?.id || null,
sourceDocumentId: doc.id,
subtotal: doc.subtotal, taxAmount: doc.taxAmount, total: doc.total,
notes: doc.notes,
items: {
create: doc.items.map(i => ({
description: i.description, quantity: i.quantity, unitPrice: i.unitPrice,
taxRate: i.taxRate, total: i.total, productId: i.productId
}))
}
}
});
// Archive the quote (preserving ACCEPTED as previousStatus)
await prisma.salesDocument.update({
where: { id: docId },
data: { previousStatus: 'ACCEPTED', status: 'ARCHIVED' }
});
return NextResponse.json({ ...doc, status: 'ARCHIVED', followUpId: ab.id, followUpNumber: ab.number });
}
// DELIVERY_NOTE delivered → reduce stock
if (doc.type === 'DELIVERY_NOTE' && body.status === 'DELIVERED') {
for (const item of doc.items) {
if (item.productId && item.product?.trackStock) {
await prisma.product.update({
where: { id: item.productId },
data: {
stock: { decrement: Math.ceil(item.quantity) },
reservedStock: { decrement: Math.ceil(item.quantity) }
}
});
}
}
}
// INVOICE paid → mark as paid then archive
if (doc.type === 'INVOICE' && body.status === 'PAID') {
await prisma.salesDocument.update({
where: { id: docId },
data: { previousStatus: 'PAID', status: 'ARCHIVED' }
});
return NextResponse.json({ ...doc, status: 'ARCHIVED', previousStatus: 'PAID' });
}
}
const updated = await prisma.salesDocument.update({
where: { id: docId },
data: {
status: body.status || undefined,
notes: body.notes !== undefined ? body.notes : undefined,
},
include: { items: true, customer: true }
});
return NextResponse.json(updated);
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Fehler' }, { status: 500 });
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
async function getSession() {
const session = await getServerSession(authOptions);
if (!session) return null;
return session;
}
const PREFIXES: Record<string, string> = {
QUOTE: 'ANG', ORDER_CONFIRMATION: 'AB', DELIVERY_NOTE: 'LS', INVOICE: 'RE', CREDIT_NOTE: 'RK'
};
const NUMBER_FIELDS: Record<string, string> = {
QUOTE: 'nextQuoteNumber', ORDER_CONFIRMATION: 'nextOrderNumber',
DELIVERY_NOTE: 'nextDeliveryNumber', INVOICE: 'nextInvoiceNumber', CREDIT_NOTE: 'nextCreditNoteNumber'
};
async function generateNumber(type: string) {
const settings = await prisma.systemSettings.findFirst();
if (!settings) throw new Error('SystemSettings not found');
const year = new Date().getFullYear();
const prefix = PREFIXES[type] || 'DOC';
const field = NUMBER_FIELDS[type];
const num = (settings as any)[field] || 1;
await prisma.systemSettings.update({ where: { id: settings.id }, data: { [field]: num + 1 } });
return `${prefix}-${year}-${num.toString().padStart(4, '0')}`;
}
export async function GET(request: Request) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { searchParams } = new URL(request.url);
const customerId = searchParams.get('customerId');
const where = customerId ? { customerId: parseInt(customerId) } : {};
const docs = await prisma.salesDocument.findMany({
where,
include: {
customer: { select: { companyName: true, firstName: true, lastName: true } },
items: { include: { product: { select: { name: true } } } },
createdBy: { select: { firstName: true, lastName: true } }
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(docs);
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request) {
const session = await getSession();
const perms = (session?.user as any)?.permissions || [];
if (!perms.includes('SALES_MANAGE')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const body = await request.json();
const number = await generateNumber(body.type);
// Auto-set validity for quotes
let validUntil = body.validUntil ? new Date(body.validUntil) : null;
if (body.type === 'QUOTE' && !validUntil) {
const settings = await prisma.systemSettings.findFirst();
if (settings?.defaultQuoteValidityDays) {
validUntil = new Date();
validUntil.setDate(validUntil.getDate() + settings.defaultQuoteValidityDays);
}
}
// Calculate totals
let subtotal = 0;
const items = (body.items || []).map((item: any) => {
const total = item.quantity * item.unitPrice;
subtotal += total;
return { ...item, total, taxRate: 19 };
});
const taxAmount = subtotal * 0.19;
const total = subtotal + taxAmount;
const doc = await prisma.salesDocument.create({
data: {
type: body.type,
number,
customerId: body.customerId,
createdById: (session?.user as any)?.id || null,
notes: body.notes || null,
validUntil,
subtotal, taxAmount, total,
items: { create: items.map((i: any) => ({ description: i.description, quantity: i.quantity, unitPrice: i.unitPrice, taxRate: i.taxRate, total: i.total, productId: i.productId || null })) }
},
include: { items: true, customer: true }
});
return NextResponse.json(doc, { status: 201 });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Fehler beim Erstellen' }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
// /opt/erp-system/app/api/search/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
// Nur Team-Mitglieder dürfen die globale Suche nutzen
if (!session || (session.user as any).userType !== 'TEAM') {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get('q');
if (!q || q.length < 2) {
return NextResponse.json({ tickets: [], customers: [] });
}
// Prüfen, ob nach einer exakten Ticket-ID gesucht wird (z.B. "12")
const isNumber = !isNaN(Number(q));
try {
const [tickets, customers] = await Promise.all([
prisma.ticket.findMany({
where: {
OR: [
isNumber ? { id: Number(q) } : {},
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } }
].filter(condition => Object.keys(condition).length > 0)
},
take: 20, // Begrenzung für Performance
include: { customer: { select: { firstName: true, lastName: true, companyName: true } } }
}),
prisma.customer.findMany({
where: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ companyName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } }
]
},
take: 20
})
]);
return NextResponse.json({ tickets, customers });
} catch (error) {
return NextResponse.json({ error: 'Fehler bei der Suche' }, { status: 500 });
}
}
+67
View File
@@ -0,0 +1,67 @@
// /opt/erp-system/app/api/settings/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
async function checkAccess() {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
return perms.includes('SYSTEM_SETTINGS');
}
export async function GET() {
try {
let settings = await prisma.systemSettings.findFirst({ where: { id: 1 } });
if (!settings) {
settings = await prisma.systemSettings.create({ data: { id: 1 } });
}
// Passwörter nicht ans Frontend senden
const { smtpPass, imapPass, ...safeSettings } = settings;
return NextResponse.json({
...safeSettings,
hasSmtpPass: !!smtpPass,
hasImapPass: !!imapPass
});
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function PUT(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Verweigert' }, { status: 403 });
try {
const body = await request.json();
const updateData: any = {
hourlyRate: parseFloat(body.hourlyRate),
taxRate: parseFloat(body.taxRate),
companyName: body.companyName,
companyInfo: body.companyInfo,
smtpHost: body.smtpHost,
smtpPort: parseInt(body.smtpPort) || 587,
smtpUser: body.smtpUser,
smtpFrom: body.smtpFrom,
imapHost: body.imapHost,
imapPort: parseInt(body.imapPort) || 993,
imapUser: body.imapUser,
};
if (body.smtpPass && body.smtpPass.trim() !== '') updateData.smtpPass = body.smtpPass;
if (body.imapPass && body.imapPass.trim() !== '') updateData.imapPass = body.imapPass;
const updated = await prisma.systemSettings.upsert({
where: { id: 1 },
update: updateData,
create: { id: 1, ...updateData }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Update-Fehler' }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
// /opt/erp-system/app/api/setup-customer/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import bcrypt from 'bcryptjs';
export async function GET() {
try {
const customer = await prisma.customer.findFirst({ orderBy: { id: 'asc' } });
if (!customer) {
return NextResponse.json({ error: 'Kein Kunde im System gefunden.' }, { status: 404 });
}
const hash = await bcrypt.hash('portal123', 10);
await prisma.customer.update({
where: { id: customer.id },
data: { passwordHash: hash }
});
return NextResponse.json({
success: true,
message: `Passwort für Kunde ${customer.email} (ID: ${customer.id}) erfolgreich auf 'portal123' gesetzt.`
});
} catch (error) {
return NextResponse.json({ error: 'Fehler' }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
// /opt/erp-system/app/api/setup/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
export async function GET() {
try {
// 1. Sicherestellen, dass die Admin-Rolle existiert und alle Rechte hat
const adminRole = await prisma.role.upsert({
where: { name: 'Administrator' },
update: {
permissions: ['TICKETS_VIEW', 'TICKETS_EDIT', 'CUSTOMERS_MANAGE', 'TEAM_MANAGE', 'SYSTEM_SETTINGS']
},
create: {
name: 'Administrator',
permissions: ['TICKETS_VIEW', 'TICKETS_EDIT', 'CUSTOMERS_MANAGE', 'TEAM_MANAGE', 'SYSTEM_SETTINGS']
}
});
// 2. Den allerersten Nutzer im System finden (dein initialer Account)
const firstUser = await prisma.user.findFirst({
orderBy: { id: 'asc' }
});
if (!firstUser) {
return NextResponse.json({ error: 'Kein Nutzer im System gefunden. Bitte erstelle zuerst einen Nutzer.' }, { status: 404 });
}
// 3. Diesem Nutzer die Admin-Rolle aufzwingen
await prisma.user.update({
where: { id: firstUser.id },
data: { roleId: adminRole.id }
});
return NextResponse.json({
success: true,
message: `Setup erfolgreich! Der Nutzer ${firstUser.email} (ID: ${firstUser.id}) ist jetzt Administrator. Bitte logge dich jetzt einmal aus und wieder ein.`
});
} catch (error) {
console.error("Setup Fehler:", error);
return NextResponse.json({ error: 'Interner Fehler beim Setup. Siehe Terminal.' }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
// /opt/erp-system/app/api/tickets/[id]/attachments/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../../auth/[...nextauth]/route";
import { writeFile, readFile } from 'fs/promises';
import { join } from 'path';
const UPLOAD_DIR = join(process.cwd(), 'uploads');
// POST: Datei hochladen
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
try {
const params = await context.params;
const ticketId = parseInt(params.id);
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) return NextResponse.json({ error: 'Keine Datei gefunden' }, { status: 400 });
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Dateinamen bereinigen und eindeutig machen
const safeOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
const savedName = `${Date.now()}-${safeOriginalName}`;
const filepath = join(UPLOAD_DIR, savedName);
await writeFile(filepath, buffer);
const attachment = await prisma.attachment.create({
data: {
fileName: file.name,
savedName: savedName,
fileSize: file.size,
fileType: file.type,
ticketId: ticketId
}
});
return NextResponse.json(attachment, { status: 201 });
} catch (error) {
console.error("Upload Fehler:", error);
return NextResponse.json({ error: 'Fehler beim Upload' }, { status: 500 });
}
}
// GET: Datei-Liste abrufen oder einzelne Datei herunterladen
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return new NextResponse('Nicht autorisiert', { status: 401 });
const { searchParams } = new URL(request.url);
const downloadId = searchParams.get('download');
try {
const params = await context.params;
const ticketId = parseInt(params.id);
// Modus 1: Einzelne Datei herunterladen
if (downloadId) {
const attachment = await prisma.attachment.findUnique({
where: { id: parseInt(downloadId) }
});
if (!attachment || attachment.ticketId !== ticketId) {
return new NextResponse('Datei nicht gefunden', { status: 404 });
}
const filepath = join(UPLOAD_DIR, attachment.savedName);
const fileBuffer = await readFile(filepath);
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': attachment.fileType,
'Content-Disposition': `attachment; filename="${attachment.fileName}"`
}
});
}
// Modus 2: Liste aller Anhänge des Tickets zurückgeben
const attachments = await prisma.attachment.findMany({
where: { ticketId: ticketId },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(attachments);
} catch (error) {
return new NextResponse('Fehler beim Abrufen der Datei', { status: 500 });
}
}
+133
View File
@@ -0,0 +1,133 @@
// /opt/erp-system/app/api/tickets/[id]/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
import { sendEmail } from '../../../../lib/email';
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
const params = await context.params;
const ticketId = parseInt(params.id);
const userId = parseInt((session.user as any).id);
const userType = (session.user as any).userType;
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
customer: true,
assignedTo: { select: { id: true, firstName: true, lastName: true } },
messages: { orderBy: { createdAt: 'asc' } },
timeEntries: { orderBy: { createdAt: 'desc' }, include: { user: { select: { firstName: true, lastName: true } } } },
notes: { orderBy: { createdAt: 'desc' }, include: { user: { select: { firstName: true, lastName: true } } } },
}
});
if (!ticket) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 });
// Sicherheits-Prüfung: Kunden dürfen nur ihre eigenen Akten laden
if (userType === 'CUSTOMER' && ticket.customerId !== userId) {
return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
}
return NextResponse.json(ticket);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
const params = await context.params;
const ticketId = parseInt(params.id);
const body = await request.json();
const userId = parseInt((session.user as any).id);
const userType = (session.user as any).userType;
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: { customer: true }
});
if (!ticket) return NextResponse.json({ error: 'Ticket nicht gefunden' }, { status: 404 });
// Sicherheits-Prüfung für Schreibzugriffe
if (userType === 'CUSTOMER' && ticket.customerId !== userId) {
return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
}
if (body.action === 'assign') {
if (userType === 'CUSTOMER') return NextResponse.json({ error: 'Aktion unzulässig' }, { status: 403 });
await prisma.ticket.update({
where: { id: ticketId },
data: { assignedToId: body.userId ? parseInt(body.userId) : null }
});
return NextResponse.json({ success: true });
}
if (body.action === 'changePriority') {
if (userType === 'CUSTOMER') return NextResponse.json({ error: 'Aktion unzulässig' }, { status: 403 });
await prisma.ticket.update({
where: { id: ticketId },
data: { priority: body.priority }
});
return NextResponse.json({ success: true });
}
if (body.action === 'addMessage') {
const isFromCustomer = userType === 'CUSTOMER';
await prisma.ticketMessage.create({
data: { content: body.content, ticketId, isFromCustomer }
});
// Nur E-Mail senden, wenn der Mitarbeiter antwortet
if (!isFromCustomer && ticket.customer.email) {
await sendEmail({
to: ticket.customer.email,
subject: `Update zu Ticket #${ticket.id}: ${ticket.title}`,
text: `Hallo ${ticket.customer.firstName},\n\nes gibt eine neue Nachricht zu deinem Ticket:\n\n${body.content}\n\nViele Grüße\nDein Support-Team`
});
}
return NextResponse.json({ success: true });
}
if (body.action === 'addTimeEntry') {
if (userType === 'CUSTOMER') return NextResponse.json({ error: 'Aktion unzulässig' }, { status: 403 });
await prisma.timeEntry.create({ data: { durationMins: parseInt(body.durationMins), description: body.description, ticketId, userId } });
return NextResponse.json({ success: true });
}
if (body.action === 'addNote') {
if (userType === 'CUSTOMER') return NextResponse.json({ error: 'Aktion unzulässig' }, { status: 403 });
await prisma.ticketNote.create({ data: { content: body.content, ticketId, userId } });
return NextResponse.json({ success: true });
}
if (body.action === 'closeTicket') {
if (userType === 'CUSTOMER') return NextResponse.json({ error: 'Aktion unzulässig' }, { status: 403 });
if (body.durationMins > 0) {
await prisma.timeEntry.create({ data: { durationMins: parseInt(body.durationMins), description: body.description || 'Abschluss', ticketId, userId } });
}
await prisma.ticket.update({ where: { id: ticketId }, data: { status: 'RESOLVED' } });
if (ticket.customer.email) {
await sendEmail({
to: ticket.customer.email,
subject: `Ticket #${ticket.id} gelöst: ${ticket.title}`,
text: `Hallo ${ticket.customer.firstName},\n\ndein Ticket wurde als gelöst markiert.\n\nLösung/Hinweis:\n${body.description || 'Das Problem wurde behoben.'}\n\nViele Grüße\nDein Support-Team`
});
}
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Ungültig' }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: 'Fehler' }, { status: 500 });
}
}
+48
View File
@@ -0,0 +1,48 @@
// /opt/erp-system/app/api/tickets/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import { sendEmail } from '../../../lib/email';
export async function GET() {
try {
const tickets = await prisma.ticket.findMany({
include: {
customer: { select: { companyName: true, firstName: true, lastName: true } },
assignedTo: { select: { id: true, firstName: true, lastName: true } }
},
orderBy: { updatedAt: 'desc' }
});
return NextResponse.json(tickets);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const ticket = await prisma.ticket.create({
data: {
title: body.title,
description: body.description,
customerId: parseInt(body.customerId),
priority: body.priority || 'MEDIUM'
},
include: { customer: true }
});
// Automatischer E-Mail-Versand an den Kunden
if (ticket.customer.email) {
await sendEmail({
to: ticket.customer.email,
subject: `Ticket #${ticket.id} eröffnet: ${ticket.title}`,
text: `Hallo ${ticket.customer.firstName},\n\ndein Ticket wurde erfolgreich in unserem System erfasst.\n\nBetreff: ${ticket.title}\nBeschreibung:\n${ticket.description}\n\nWir kümmern uns schnellstmöglich darum.\n\nViele Grüße\nDein Support-Team`
});
}
return NextResponse.json(ticket, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Erstellen' }, { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
// /opt/erp-system/app/api/time-entries/[id]/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../../lib/prisma';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
try {
const params = await context.params;
const id = parseInt(params.id);
const body = await request.json();
const updated = await prisma.timeEntry.update({
where: { id },
data: {
description: body.description,
durationMins: parseInt(body.durationMins)
}
});
return NextResponse.json(updated);
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Update' }, { status: 500 });
}
}
+116
View File
@@ -0,0 +1,116 @@
// /opt/erp-system/app/api/users/route.ts
import { NextResponse } from 'next/server';
import prisma from '../../../lib/prisma';
import bcrypt from 'bcryptjs';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]/route";
async function checkAccess() {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
return perms.includes('TEAM_MANAGE');
}
export async function GET() {
try {
const users = await prisma.user.findMany({
include: { role: true },
orderBy: { createdAt: 'asc' },
});
const safeUsers = users.map(u => ({
id: u.id, firstName: u.firstName, lastName: u.lastName, email: u.email, role: u.role, createdAt: u.createdAt, roleId: u.roleId
}));
return NextResponse.json(safeUsers);
} catch (error) {
return NextResponse.json({ error: 'Ladefehler' }, { status: 500 });
}
}
export async function POST(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
try {
const body = await request.json();
const passwordHash = await bcrypt.hash(body.password, 10);
const newUser = await prisma.user.create({
data: {
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
passwordHash: passwordHash,
roleId: parseInt(body.roleId),
},
include: { role: true }
});
return NextResponse.json({
id: newUser.id, firstName: newUser.firstName, email: newUser.email, role: newUser.role
}, { status: 201 });
} catch (error: any) {
if (error.code === 'P2002') return NextResponse.json({ error: 'E-Mail wird bereits verwendet.' }, { status: 400 });
return NextResponse.json({ error: 'Fehler beim Erstellen' }, { status: 500 });
}
}
export async function PUT(request: Request) {
if (!await checkAccess()) return NextResponse.json({ error: 'Zugriff verweigert' }, { status: 403 });
try {
const body = await request.json();
// Basis-Daten für das Update
const updateData: any = {
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
roleId: parseInt(body.roleId)
};
// Passwort nur aktualisieren, wenn ein neues eingegeben wurde
if (body.password && body.password.trim() !== '') {
updateData.passwordHash = await bcrypt.hash(body.password, 10);
}
const updatedUser = await prisma.user.update({
where: { id: body.id },
data: updateData,
include: { role: true }
});
return NextResponse.json({
id: updatedUser.id, firstName: updatedUser.firstName, email: updatedUser.email, role: updatedUser.role
}, { status: 200 });
} catch (error: any) {
if (error.code === 'P2002') return NextResponse.json({ error: 'E-Mail wird bereits verwendet.' }, { status: 400 });
return NextResponse.json({ error: 'Fehler beim Aktualisieren' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const session = await getServerSession(authOptions);
const perms = (session?.user as any)?.permissions || [];
if (!perms.includes('DATA_DELETE')) return NextResponse.json({ error: 'Keine Löschberechtigung' }, { status: 403 });
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID fehlt' }, { status: 400 });
const userId = parseInt(id);
// Unassign tickets instead of deleting them
await prisma.ticket.updateMany({ where: { assignedToId: userId }, data: { assignedToId: null } });
// Delete user's time entries and notes
await prisma.timeEntry.deleteMany({ where: { userId } });
await prisma.ticketNote.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
}
}
+143
View File
@@ -0,0 +1,143 @@
// /opt/erp-system/app/billing/[customerId]/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Printer } from 'lucide-react';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
export default function InvoiceDraftPage() {
const params = useParams();
const router = useRouter();
const customerId = params.customerId;
const [entries, setEntries] = useState<any[]>([]);
const [customer, setCustomer] = useState<any>(null);
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (customerId) fetchData();
}, [customerId]);
const fetchData = async () => {
const [billingRes, settingsRes] = await Promise.all([
fetch('/api/billing'),
fetch('/api/settings')
]);
if (billingRes.ok && settingsRes.ok) {
const allEntries = await billingRes.json();
const filtered = allEntries.filter((e: any) => e.ticket.customerId === parseInt(customerId as string));
setEntries(filtered);
if (filtered.length > 0) setCustomer(filtered[0].ticket.customer);
setSettings(await settingsRes.json());
}
setLoading(false);
};
const handleUpdateEntry = async (id: number, field: string, value: any) => {
const updatedEntries = entries.map(e => e.id === id ? { ...e, [field]: value } : e);
setEntries(updatedEntries);
const entry = updatedEntries.find(e => e.id === id);
await fetch(`/api/time-entries/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: entry.description, durationMins: entry.durationMins })
});
};
const generatePDF = () => {
const doc = new jsPDF();
const dateStr = new Date().toLocaleDateString('de-DE');
const invNo = `RE-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
doc.setFontSize(20);
doc.text(settings.companyName || "ERP SYSTEM", 14, 20);
doc.setFontSize(9);
doc.text(settings.companyInfo || "", 14, 30);
doc.setFontSize(11);
doc.text("Rechnung an:", 14, 55);
doc.setFont("helvetica", "bold");
doc.text(customer.companyName || `${customer.firstName} ${customer.lastName}`, 14, 62);
doc.setFont("helvetica", "normal");
doc.text(`${customer.address || ''}\n${customer.zipCode || ''} ${customer.city || ''}`, 14, 68);
doc.text(`Datum: ${dateStr}`, 140, 62);
doc.text(`Rechnungs-Nr: ${invNo}`, 140, 68);
const tableData = entries.map(e => [
new Date(e.createdAt).toLocaleDateString('de-DE'),
`${e.ticket.title}\n${e.description}`,
(e.durationMins / 60).toFixed(2) + " h",
settings.hourlyRate.toFixed(2) + " €",
((e.durationMins / 60) * settings.hourlyRate).toFixed(2) + " €"
]);
autoTable(doc, {
startY: 90,
head: [['Datum', 'Leistung', 'Menge', 'Einzelpreis', 'Gesamt']],
body: tableData,
theme: 'striped',
headStyles: { fillColor: [79, 70, 229] }
});
const netTotal = entries.reduce((sum, e) => sum + (e.durationMins / 60 * settings.hourlyRate), 0);
const tax = netTotal * (settings.taxRate / 100);
const grossTotal = netTotal + tax;
const finalY = (doc as any).lastAutoTable.finalY + 10;
doc.text(`Netto Gesamt:`, 140, finalY);
doc.text(`${netTotal.toFixed(2)}`, 180, finalY, { align: 'right' });
doc.text(`USt. ${settings.taxRate}%:`, 140, finalY + 6);
doc.text(`${tax.toFixed(2)}`, 180, finalY + 6, { align: 'right' });
doc.setFont("helvetica", "bold");
doc.text(`Rechnungsbetrag:`, 140, finalY + 14);
doc.text(`${grossTotal.toFixed(2)}`, 180, finalY + 14, { align: 'right' });
doc.save(`Rechnung_${invNo}.pdf`);
};
if (loading) return <div className="p-8">Lade Entwurf...</div>;
return (
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<button onClick={() => router.back()} className="text-slate-500 hover:text-slate-800 flex items-center gap-2 transition">
<ArrowLeft className="w-4 h-4" /> Abbrechen
</button>
<button onClick={generatePDF} className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg font-bold shadow-lg flex items-center gap-2 hover:bg-indigo-700 transition">
<Printer className="w-5 h-5" /> PDF erstellen
</button>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200">
<h1 className="text-2xl font-bold text-slate-900 mb-6">Rechnung korrigieren</h1>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 border-y border-slate-100">
<tr>
<th className="py-3 px-4 text-left">Beschreibung</th>
<th className="py-3 px-4 text-right">Minuten</th>
<th className="py-3 px-4 text-right">Betrag</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{entries.map((entry) => (
<tr key={entry.id}>
<td className="py-4 px-4">
<textarea className="w-full border-none p-0 focus:ring-0 text-slate-700 bg-transparent resize-none" rows={2} value={entry.description} onChange={(e) => handleUpdateEntry(entry.id, 'description', e.target.value)} />
</td>
<td className="py-4 px-4 text-right">
<input type="number" step="15" className="w-20 text-right border border-slate-200 rounded p-1" value={entry.durationMins} onChange={(e) => handleUpdateEntry(entry.id, 'durationMins', parseInt(e.target.value))} />
</td>
<td className="py-4 px-4 text-right font-mono">{(entry.durationMins / 60 * settings.hourlyRate).toFixed(2)} </td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
// /opt/erp-system/app/billing/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { FileText, CheckCircle, Clock, User as UserIcon, Printer, Search } from 'lucide-react';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { useToast } from '../components/ToastProvider';
export default function T2medBillingPage() {
const [groups, setGroups] = useState<any[]>([]);
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const { toast } = useToast();
// T2med Split-Screen States
const [activeCustomer, setActiveCustomer] = useState<any>(null);
const [draftEntries, setDraftEntries] = useState<any[]>([]);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
const [billingRes, settingsRes] = await Promise.all([
fetch('/api/billing'),
fetch('/api/settings')
]);
if (billingRes.ok && settingsRes.ok) {
const entries = await billingRes.json();
const loadedSettings = await settingsRes.json();
setSettings(loadedSettings);
// Gruppieren nach Kunde
const groupedData = entries.reduce((acc: any, entry: any) => {
const cid = entry.ticket.customerId;
if (!acc[cid]) {
acc[cid] = { customer: entry.ticket.customer, entries: [], totalMins: 0 };
}
acc[cid].entries.push(entry);
acc[cid].totalMins += entry.durationMins;
return acc;
}, {});
setGroups(Object.values(groupedData));
}
setLoading(false);
};
// Aktiviert den Kunden für die rechte Bildschirmhälfte
const selectCustomer = (group: any) => {
setActiveCustomer(group.customer);
// Erstelle eine tiefe Kopie der Einträge für das Inline-Editing
setDraftEntries(JSON.parse(JSON.stringify(group.entries)));
};
// Inline-Editing im Rechnungsblatt
const handleUpdateDraft = async (id: number, field: string, value: any) => {
const updated = draftEntries.map(e => e.id === id ? { ...e, [field]: value } : e);
setDraftEntries(updated);
// Speichere die Änderung sofort im Hintergrund in der DB ab (T2med-Feeling)
const entry = updated.find(e => e.id === id);
await fetch(`/api/time-entries/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: entry.description, durationMins: entry.durationMins })
});
// Aktualisiere die linke Liste leise mit den neuen Summen
fetchData();
};
const markAsBilled = async () => {
const entryIds = draftEntries.map(e => e.id);
const res = await fetch('/api/billing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entryIds })
});
if (res.ok) {
setActiveCustomer(null);
setDraftEntries([]);
fetchData();
} else {
toast('Fehler beim Abschließen.', 'error');
}
};
const generatePDF = () => {
if (!settings) return;
const doc = new jsPDF();
const dateStr = new Date().toLocaleDateString('de-DE');
const yyyy = new Date().getFullYear();
const mm = String(new Date().getMonth() + 1).padStart(2, '0');
const seq = String(Date.now()).slice(-5);
const invNo = `RE-${yyyy}${mm}-${seq}`;
doc.setFontSize(20);
doc.text(settings.companyName || "ERP SYSTEM", 14, 20);
doc.setFontSize(9);
doc.text(settings.companyInfo || "", 14, 30);
doc.setFontSize(11);
doc.text("Rechnung an:", 14, 55);
doc.setFont("helvetica", "bold");
doc.text(activeCustomer.companyName || `${activeCustomer.firstName} ${activeCustomer.lastName}`, 14, 62);
doc.setFont("helvetica", "normal");
doc.text(`${activeCustomer.address || ''}\n${activeCustomer.zipCode || ''} ${activeCustomer.city || ''}`, 14, 68);
doc.text(`Datum: ${dateStr}`, 140, 62);
doc.text(`Rechnungs-Nr: ${invNo}`, 140, 68);
const tableData = draftEntries.map(e => [
new Date(e.createdAt).toLocaleDateString('de-DE'),
`${e.ticket.title}\n${e.description}`,
(e.durationMins / 60).toFixed(2) + " h",
settings.hourlyRate.toFixed(2) + " €",
((e.durationMins / 60) * settings.hourlyRate).toFixed(2) + " €"
]);
autoTable(doc, {
startY: 90,
head: [['Datum', 'Leistung', 'Menge', 'Einzelpreis', 'Gesamt']],
body: tableData,
theme: 'striped',
headStyles: { fillColor: [79, 70, 229] },
styles: { cellPadding: 4, fontSize: 9 }
});
const netTotal = draftEntries.reduce((sum, e) => sum + (e.durationMins / 60 * settings.hourlyRate), 0);
const tax = netTotal * (settings.taxRate / 100);
const grossTotal = netTotal + tax;
const finalY = (doc as any).lastAutoTable.finalY + 10;
doc.text(`Netto Gesamt:`, 140, finalY);
doc.text(`${netTotal.toFixed(2)}`, 180, finalY, { align: 'right' });
doc.text(`USt. ${settings.taxRate}%:`, 140, finalY + 6);
doc.text(`${tax.toFixed(2)}`, 180, finalY + 6, { align: 'right' });
doc.setFont("helvetica", "bold");
doc.text(`Rechnungsbetrag:`, 140, finalY + 14);
doc.text(`${grossTotal.toFixed(2)}`, 180, finalY + 14, { align: 'right' });
doc.save(`Rechnung_${invNo}.pdf`);
};
const filteredGroups = groups.filter(g => {
const name = g.customer.companyName || `${g.customer.firstName} ${g.customer.lastName}`;
return name.toLowerCase().includes(searchTerm.toLowerCase());
});
if (loading && groups.length === 0) return <div className="p-8 font-medium text-slate-500 animate-pulse">Lade Abrechnungs-Modul...</div>;
return (
<div className="flex h-[calc(100vh-8rem)] -m-8 bg-white border-t border-slate-200 animate-fade-in-up">
{/* LINKE SPALTE: Kunden-Liste (T2med-Style) */}
<div className="w-1/3 min-w-[320px] max-w-sm border-r border-slate-200 bg-slate-50/50 flex flex-col h-full shadow-[4px_0_24px_rgba(0,0,0,0.02)] z-10">
<div className="p-4 border-b border-slate-200 bg-white shadow-sm z-10">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2 mb-3">
<FileText className="w-5 h-5 text-indigo-600" /> Abrechnungslauf
</h2>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Kunde suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-100/80 border border-slate-200 rounded-xl text-sm focus:bg-white focus:border-indigo-400 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-slate-100">
{filteredGroups.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">Keine offenen Positionen.</div>
) : (
filteredGroups.map((group, idx) => {
const name = group.customer.companyName || `${group.customer.firstName} ${group.customer.lastName}`;
const isActive = activeCustomer?.id === group.customer.id;
return (
<button
key={idx}
onClick={() => selectCustomer(group)}
className={`w-full text-left p-4 transition-all border-l-4 ${isActive ? 'bg-indigo-50 border-indigo-600' : 'bg-transparent border-transparent hover:bg-slate-100'}`}
>
<div className="flex justify-between items-start">
<span className={`font-semibold ${isActive ? 'text-indigo-900' : 'text-slate-800'}`}>{name}</span>
<span className="text-xs font-bold text-slate-500 bg-white px-2 py-1 rounded-md shadow-sm border border-slate-200">
{group.entries.length} Pos.
</span>
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-slate-500">
<span className="flex items-center gap-1"><Clock className="w-3.5 h-3.5" /> {(group.totalMins / 60).toFixed(1)} h</span>
{settings && (
<span className="font-mono text-indigo-600 font-medium">{((group.totalMins / 60) * settings.hourlyRate).toFixed(2)} </span>
)}
</div>
</button>
);
})
)}
</div>
</div>
{/* RECHTE SPALTE: Das aktive Rechnungsblatt */}
<div className="flex-1 bg-slate-100/50 flex flex-col h-full overflow-hidden relative">
{!activeCustomer ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 animate-fade-in-up">
<UserIcon className="w-16 h-16 mb-4 opacity-20" />
<p className="font-medium text-lg text-slate-500">Bitte wähle links einen Kunden aus</p>
</div>
) : (
<div className="flex-1 overflow-y-auto p-8 relative">
{/* Action Bar (Oben rechts, sticky) */}
<div className="absolute top-8 right-8 flex gap-3 z-10">
<button onClick={generatePDF} className="bg-white border border-slate-200 text-indigo-600 px-4 py-2 rounded-xl font-bold shadow-sm hover:shadow-md hover:border-indigo-200 transition-all flex items-center gap-2 text-sm hover-lift">
<Printer className="w-4 h-4" /> PDF erstellen
</button>
<button onClick={markAsBilled} className="bg-emerald-600 text-white px-5 py-2 rounded-xl font-bold shadow-lg shadow-emerald-600/20 hover:bg-emerald-700 transition-all flex items-center gap-2 text-sm hover-lift">
<CheckCircle className="w-4 h-4" /> Als erledigt markieren
</button>
</div>
{/* Das "Papier"-Dokument */}
<div className="bg-white max-w-4xl mx-auto min-h-[800px] shadow-2xl shadow-slate-300/40 border border-slate-100 rounded-sm p-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="border-b border-slate-200 pb-8 mb-8 flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-slate-900 mb-2">Rechnungsentwurf</h1>
<p className="text-lg text-slate-600 font-medium">{activeCustomer.companyName || `${activeCustomer.firstName} ${activeCustomer.lastName}`}</p>
<p className="text-sm text-slate-500">{activeCustomer.address || 'Keine Adresse hinterlegt'}</p>
</div>
<div className="text-right text-slate-500 text-sm">
<p>Stundensatz: <strong className="text-slate-800">{settings?.hourlyRate} </strong></p>
<p>MwSt: <strong className="text-slate-800">{settings?.taxRate} %</strong></p>
</div>
</div>
{/* T2med-Style Inline-Editing Tabelle */}
<div className="w-full">
<div className="grid grid-cols-12 gap-4 pb-3 border-b-2 border-slate-800 font-bold text-slate-700 text-sm">
<div className="col-span-2">Datum</div>
<div className="col-span-6">Leistung</div>
<div className="col-span-2 text-right">Minuten</div>
<div className="col-span-2 text-right">Betrag</div>
</div>
<div className="divide-y divide-slate-100">
{draftEntries.map((entry) => (
<div key={entry.id} className="grid grid-cols-12 gap-4 py-3 group hover:bg-indigo-50/50 transition-colors -mx-4 px-4 rounded-lg">
<div className="col-span-2 text-sm text-slate-500 pt-1.5">
{new Date(entry.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="col-span-6">
<p className="text-xs text-indigo-500 font-bold mb-1">Ticket #{entry.ticket.id}</p>
<textarea
className="w-full bg-transparent border border-transparent group-hover:border-slate-200 focus:bg-white focus:border-indigo-300 focus:ring-2 focus:ring-indigo-100 rounded p-1.5 text-sm text-slate-800 outline-none resize-none transition"
rows={2}
value={entry.description}
onChange={(e) => handleUpdateDraft(entry.id, 'description', e.target.value)}
/>
</div>
<div className="col-span-2 text-right pt-1.5">
<input
type="number"
step="15"
className="w-16 text-right bg-transparent border border-transparent group-hover:border-slate-200 focus:bg-white focus:border-indigo-300 focus:ring-2 focus:ring-indigo-100 rounded p-1 text-sm text-slate-800 font-mono outline-none transition inline-block"
value={entry.durationMins}
onChange={(e) => handleUpdateDraft(entry.id, 'durationMins', parseInt(e.target.value))}
/>
</div>
<div className="col-span-2 text-right pt-2 font-mono text-slate-700 font-medium">
{settings ? ((entry.durationMins / 60) * settings.hourlyRate).toFixed(2) : '0.00'}
</div>
</div>
))}
</div>
<div className="mt-8 pt-4 border-t-2 border-slate-800 grid grid-cols-12 gap-4">
<div className="col-span-10 text-right font-bold text-slate-700">Netto Gesamt:</div>
<div className="col-span-2 text-right font-bold text-slate-900 font-mono text-lg">
{settings ? draftEntries.reduce((sum, e) => sum + (e.durationMins / 60 * settings.hourlyRate), 0).toFixed(2) : '0.00'}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+443
View File
@@ -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>
);
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { CheckCircle2, AlertTriangle, Info, X, AlertCircle } from 'lucide-react';
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface ToastContextType {
toast: (message: string, type?: ToastType, duration?: number) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ToastContext = createContext<ToastContextType | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle2 className="w-5 h-5 text-emerald-500" />,
error: <AlertCircle className="w-5 h-5 text-red-500" />,
warning: <AlertTriangle className="w-5 h-5 text-amber-500" />,
info: <Info className="w-5 h-5 text-blue-500" />,
};
const bgColors: Record<ToastType, string> = {
success: 'bg-emerald-50 border-emerald-200',
error: 'bg-red-50 border-red-200',
warning: 'bg-amber-50 border-amber-200',
info: 'bg-blue-50 border-blue-200',
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const [confirmState, setConfirmState] = useState<{
options: ConfirmOptions;
resolve: (value: boolean) => void;
} | null>(null);
const toast = useCallback((message: string, type: ToastType = 'info', duration = 4000) => {
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
setToasts(prev => [...prev, { id, message, type, duration }]);
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, duration);
}
}, []);
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmState({ options, resolve });
});
}, []);
const handleConfirm = (result: boolean) => {
confirmState?.resolve(result);
setConfirmState(null);
};
const removeToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
return (
<ToastContext.Provider value={{ toast, confirm }}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] space-y-3 pointer-events-none" style={{ maxWidth: '400px' }}>
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-lg backdrop-blur-sm ${bgColors[t.type]} animate-slide-in`}
>
<div className="flex-shrink-0 mt-0.5">{icons[t.type]}</div>
<p className="text-sm font-medium text-slate-800 flex-1">{t.message}</p>
<button onClick={() => removeToast(t.id)} className="flex-shrink-0 text-slate-400 hover:text-slate-600 transition">
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Confirm Modal */}
{confirmState && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[110] p-4 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8 animate-scale-in">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${confirmState.options.danger ? 'bg-red-100' : 'bg-blue-100'}`}>
{confirmState.options.danger
? <AlertTriangle className="w-6 h-6 text-red-600" />
: <Info className="w-6 h-6 text-blue-600" />
}
</div>
<h3 className="text-lg font-bold text-slate-900 mb-2">{confirmState.options.title}</h3>
<p className="text-sm text-slate-600 mb-8">{confirmState.options.message}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => handleConfirm(false)}
className="px-5 py-2.5 rounded-lg text-slate-600 font-medium hover:bg-slate-100 transition"
>
{confirmState.options.cancelLabel || 'Abbrechen'}
</button>
<button
onClick={() => handleConfirm(true)}
className={`px-5 py-2.5 rounded-lg font-medium text-white shadow-sm transition ${confirmState.options.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-indigo-600 hover:bg-indigo-700'}`}
>
{confirmState.options.confirmLabel || 'Bestätigen'}
</button>
</div>
</div>
</div>
)}
</ToastContext.Provider>
);
}
+781
View File
@@ -0,0 +1,781 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft, Save, Building2, Mail, Key, Users, Plus, Trash2,
Activity, FileText, FileBadge, Download, CheckCircle, Ticket,
Phone, MapPin, Eye, EyeOff, RefreshCw, Copy
} from 'lucide-react';
import { useToast } from '../../components/ToastProvider';
import { getStatusBadge } from '../../components/AppShell';
import { useSession } from 'next-auth/react';
type Tab = 'OVERVIEW' | 'TICKETS' | 'CONTACTS' | 'CONTRACTS' | 'MASTER_DATA' | 'CREDENTIALS' | 'DOCUMENTS' | 'SALES_DOCS';
export default function CustomerDashboard() {
const params = useParams();
const router = useRouter();
const customerId = params.id;
const { toast, confirm } = useToast();
const { data: session } = useSession();
const perms = (session?.user as any)?.permissions || [];
const canEdit = perms.includes('CUSTOMERS_MANAGE') || perms.includes('CUSTOMERS_EDIT');
const [customer, setCustomer] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('OVERVIEW');
// Credentials State
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<any>({});
const [additionalEmails, setAdditionalEmails] = useState<string[]>([]);
const [visiblePasswords, setVisiblePasswords] = useState<Record<number, boolean>>({});
const [newEmailInput, setNewEmailInput] = useState('');
// Modals
const [showContactModal, setShowContactModal] = useState(false);
const [contactForm, setContactForm] = useState({ firstName: '', lastName: '', email: '', phone: '' });
const [showCredentialModal, setShowCredentialModal] = useState(false);
const [credentialForm, setCredentialForm] = useState({ title: '', username: '', password: '', description: '' });
const [showTicketModal, setShowTicketModal] = useState(false);
const [ticketForm, setTicketForm] = useState({ title: '', description: '', priority: 'MEDIUM' });
const [salesDocs, setSalesDocs] = useState<any[]>([]);
const generatePassword = (len = 16) => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*';
return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => {
fetchCustomer();
}, [customerId]);
const fetchCustomer = async () => {
const res = await fetch(`/api/customers/${customerId}`);
if (res.ok) {
const data = await res.json();
setCustomer(data);
setFormData({
companyName: data.companyName || '',
firstName: data.firstName || '',
lastName: data.lastName || '',
email: data.email || '',
phone: data.phone || '',
address: data.address || '',
zipCode: data.zipCode || '',
city: data.city || ''
});
setAdditionalEmails(data.additionalEmails || []);
}
setLoading(false);
};
useEffect(() => {
if (customerId) fetch(`/api/sales?customerId=${customerId}`).then(r => r.json()).then(setSalesDocs).catch(() => {});
}, [customerId]);
const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...ticketForm, customerId })
});
if (res.ok) {
setShowTicketModal(false);
setTicketForm({ title: '', description: '', priority: 'MEDIUM' });
fetchCustomer();
toast('Ticket erstellt', 'success');
}
};
// ── STAMMDATEN ──
const handleSaveCustomer = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!canEdit) { toast('Keine Berechtigung', 'error'); return; }
setSaving(true);
const res = await fetch(`/api/customers/${customerId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...formData, additionalEmails })
});
if (res.ok) {
toast('Kundendaten erfolgreich gespeichert.', 'success');
fetchCustomer();
} else {
const data = await res.json();
toast(data.error || 'Fehler beim Speichern', 'error');
}
setSaving(false);
};
// ── CONTACTS ──
const handleAddContact = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch(`/api/customers/${customerId}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contactForm)
});
if (res.ok) {
setShowContactModal(false);
setContactForm({ firstName: '', lastName: '', email: '', phone: '' });
fetchCustomer();
}
};
const handleDeleteContact = async (contactId: number) => {
const isConfirmed = await confirm({ title: 'Mitarbeiter löschen', message: 'Mitarbeiter wirklich löschen?', danger: true });
if (!isConfirmed) return;
await fetch(`/api/customers/${customerId}/contacts?contactId=${contactId}`, { method: 'DELETE' });
fetchCustomer();
};
// ── CREDENTIALS ──
const handleAddCredential = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch(`/api/customers/${customerId}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentialForm)
});
if (res.ok) {
setShowCredentialModal(false);
setCredentialForm({ title: '', username: '', password: '', description: '' });
fetchCustomer();
toast('Zugangsdaten gespeichert', 'success');
}
};
const handleDeleteCredential = async (credId: number) => {
const isConfirmed = await confirm({ title: 'Löschen', message: 'Zugangsdaten wirklich löschen?', danger: true });
if (!isConfirmed) return;
await fetch(`/api/customers/${customerId}/credentials?credId=${credId}`, { method: 'DELETE' });
fetchCustomer();
};
// ── DOCUMENTS ──
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch(`/api/customers/${customerId}/documents`, {
method: 'POST',
body: uploadData
});
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
if (res.ok) {
toast('Dokument hochgeladen', 'success');
fetchCustomer();
} else {
toast('Fehler beim Hochladen', 'error');
}
};
const handleDeleteDocument = async (docId: number) => {
const isConfirmed = await confirm({ title: 'Dokument löschen', message: 'Dokument wirklich löschen?', danger: true });
if (!isConfirmed) return;
await fetch(`/api/customers/${customerId}/documents?docId=${docId}`, { method: 'DELETE' });
fetchCustomer();
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (loading) return <div className="p-8 text-slate-500 font-medium">Lade Kundendaten...</div>;
if (!customer) return <div className="p-8 text-red-600 font-medium">Kunde nicht gefunden.</div>;
const openTickets = customer.tickets?.filter((t:any) => t.status === 'OPEN' || t.status === 'IN_PROGRESS').length || 0;
const totalTickets = customer.tickets?.length || 0;
const activeContracts = customer.contracts?.filter((c:any) => c.status === 'ACTIVE').length || 0;
const tabs: { id: Tab, label: string, icon: any }[] = [
{ id: 'OVERVIEW', label: 'Alles', icon: Activity },
{ id: 'TICKETS', label: `Tickets (${totalTickets})`, icon: Ticket },
{ id: 'CONTACTS', label: 'Mitarbeiter', icon: Users },
{ id: 'CONTRACTS', label: 'Abos & Verträge', icon: FileBadge },
{ id: 'SALES_DOCS', label: `Belege (${salesDocs.length})`, icon: FileText },
{ id: 'MASTER_DATA', label: 'Stammdaten', icon: Building2 },
{ id: 'CREDENTIALS', label: 'Zugangsdaten', icon: Key },
{ id: 'DOCUMENTS', label: 'Dokumente', icon: FileText },
];
return (
<div className="max-w-7xl mx-auto space-y-4 pb-12 animate-fade-in-up">
{/* Header */}
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 p-6">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex items-start gap-4">
<button onClick={() => router.push('/customers')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition mt-1">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900">
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</h1>
{customer.companyName && <p className="text-sm text-slate-500 mt-0.5">{customer.firstName} {customer.lastName}</p>}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-2 text-sm text-slate-500">
<span className="flex items-center gap-1.5"><Mail className="w-3.5 h-3.5" /> {customer.email}</span>
{customer.phone && <span className="flex items-center gap-1.5"><Phone className="w-3.5 h-3.5" /> {customer.phone}</span>}
{(customer.address || customer.city) && (
<span className="flex items-center gap-1.5"><MapPin className="w-3.5 h-3.5" /> {[customer.address, customer.zipCode, customer.city].filter(Boolean).join(', ')}</span>
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{activeTab === 'MASTER_DATA' && canEdit && (
<button onClick={handleSaveCustomer} disabled={saving} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition disabled:opacity-50 flex items-center gap-2 text-sm">
<Save className="w-4 h-4" /> {saving ? 'Speichert...' : 'Speichern'}
</button>
)}
{activeTab === 'TICKETS' && (
<button onClick={() => setShowTicketModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
<Plus className="w-4 h-4" /> Neues Ticket
</button>
)}
{activeTab === 'CONTACTS' && canEdit && (
<button onClick={() => setShowContactModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
<Plus className="w-4 h-4" /> Mitarbeiter
</button>
)}
{activeTab === 'CREDENTIALS' && canEdit && (
<button onClick={() => setShowCredentialModal(true)} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
<Plus className="w-4 h-4" /> Zugangsdaten
</button>
)}
{activeTab === 'DOCUMENTS' && (
<div>
<input type="file" className="hidden" ref={fileInputRef} onChange={handleFileUpload} />
<button onClick={() => fileInputRef.current?.click()} disabled={uploading} className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-bold shadow-md hover:bg-indigo-700 transition flex items-center gap-2 text-sm">
<Plus className="w-4 h-4" /> {uploading ? 'Lädt...' : 'Dokument'}
</button>
</div>
)}
</div>
</div>
{/* Status Bar */}
<div className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t border-slate-100">
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${openTickets > 0 ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}`}>
{openTickets > 0 ? `${openTickets} offene Tickets` : 'Keine offenen Tickets'}
</span>
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
{totalTickets} Tickets gesamt
</span>
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-indigo-50 text-indigo-600">
{activeContracts} aktive Verträge
</span>
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
{customer.contacts?.length || 0} Mitarbeiter
</span>
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">
{customer.credentials?.length || 0} Zugangsdaten
</span>
</div>
</div>
{/* Tabs */}
<div className="flex overflow-x-auto border-b border-slate-200 gap-6 no-scrollbar">
{tabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors relative whitespace-nowrap
${isActive ? 'text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
<Icon className="w-4 h-4" />
{tab.label}
{isActive && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600 animate-in fade-in duration-300"></div>}
</button>
);
})}
</div>
{/* Tab Content */}
<div className="mt-6">
{/* ── OVERVIEW ── */}
{activeTab === 'OVERVIEW' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in-up">
<div className="md:col-span-2 space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5 text-indigo-600" /> Letzte Aktivitäten
</h2>
<div className="space-y-4">
{customer.tickets?.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-4">Bisher keine Tickets vorhanden.</p>
) : (
customer.tickets?.slice(0, 5).map((t: any) => (
<div key={t.id} onClick={() => router.push(`/tickets/${t.id}`)} className="flex items-start justify-between p-4 bg-slate-50 hover:bg-indigo-50/50 rounded-xl border border-slate-100 cursor-pointer transition-colors group">
<div className="flex gap-4">
<div className="p-2 bg-white rounded-lg shadow-sm border border-slate-200 group-hover:border-indigo-200">
<Ticket className="w-5 h-5 text-indigo-500" />
</div>
<div>
<p className="font-semibold text-slate-800 text-sm">{t.title}</p>
<p className="text-xs text-slate-500 mt-1 line-clamp-1">{t.description}</p>
<p className="text-[10px] text-slate-400 mt-1 font-mono">{new Date(t.createdAt).toLocaleDateString('de-DE')}</p>
</div>
</div>
<div>
{getStatusBadge(t.status)}
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Quick Stats Sidebar */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-center gap-4 hover-lift">
<div className="p-3 bg-amber-50 text-amber-600 rounded-xl">
<Ticket className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Tickets gesamt</p>
<p className="text-2xl font-bold text-slate-900">{customer.tickets?.length || 0}</p>
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-center gap-4 hover-lift">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
<FileBadge className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Aktive Verträge</p>
<p className="text-2xl font-bold text-slate-900">{customer.contracts?.filter((c:any) => c.status === 'ACTIVE').length || 0}</p>
</div>
</div>
</div>
</div>
)}
{/* ── TICKETS ── */}
{activeTab === 'TICKETS' && (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
{customer.tickets?.length === 0 ? (
<div className="p-8 text-slate-500 text-sm text-center">Keine Tickets vorhanden.</div>
) : (
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
<tr>
<th className="px-6 py-4">ID</th>
<th className="px-6 py-4">Titel</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Erstellt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{customer.tickets.map((t: any) => (
<tr key={t.id} onClick={() => router.push(`/tickets/${t.id}`)} className="hover:bg-indigo-50/40 transition-colors cursor-pointer">
<td className="px-6 py-4 font-mono text-slate-400">#{t.id.toString().padStart(4,'0')}</td>
<td className="px-6 py-4 font-medium text-slate-900">{t.title}</td>
<td className="px-6 py-4">{getStatusBadge(t.status)}</td>
<td className="px-6 py-4 text-slate-500">{new Date(t.createdAt).toLocaleDateString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── CONTACTS ── */}
{activeTab === 'CONTACTS' && (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
{customer.contacts?.length === 0 ? (
<div className="p-8 text-slate-500 text-sm text-center">Keine Mitarbeiter hinterlegt.</div>
) : (
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
<tr>
<th className="px-6 py-4">Name</th>
<th className="px-6 py-4">E-Mail</th>
<th className="px-6 py-4">Telefon</th>
<th className="px-6 py-4 text-right">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{customer.contacts.map((contact: any) => (
<tr key={contact.id} className="hover:bg-slate-50/80 transition-colors">
<td className="px-6 py-4 font-medium text-slate-900">{contact.firstName} {contact.lastName}</td>
<td className="px-6 py-4 text-slate-600">{contact.email}</td>
<td className="px-6 py-4 text-slate-600">{contact.phone || '-'}</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleDeleteContact(contact.id)} className="text-slate-400 hover:text-red-600 transition p-1">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── CONTRACTS ── */}
{activeTab === 'CONTRACTS' && (
<div className="space-y-4 animate-fade-in-up">
{customer.contracts?.length === 0 ? (
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200 text-center text-slate-500">
Keine Verträge vorhanden.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{customer.contracts?.map((contract: any) => (
<div key={contract.id} className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 hover-lift group relative">
<div className="flex items-center gap-3 mb-4">
<div className={`p-2.5 rounded-xl ${contract.status === 'ACTIVE' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
<FileBadge className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-slate-900">{contract.title}</h3>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${contract.status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'}`}>
{contract.status === 'ACTIVE' ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
</div>
{contract.description && <p className="text-sm text-slate-600 mb-4 line-clamp-2">{contract.description}</p>}
<div className="flex items-end justify-between pt-4 border-t border-slate-100">
<div>
<p className="text-xs text-slate-400">Gültig ab</p>
<p className="text-sm font-semibold text-slate-700">{new Date(contract.startDate).toLocaleDateString('de-DE')}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-400">Preis / Monat</p>
<p className="text-lg font-bold text-indigo-600">{contract.monthlyPrice.toFixed(2)} </p>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── MASTER DATA ── */}
{activeTab === 'MASTER_DATA' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-fade-in-up">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 space-y-4">
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<Building2 className="w-5 h-5 text-indigo-600" /> Stammdaten
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Firmenname</label>
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.companyName} onChange={e => setFormData({...formData, companyName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
<input type="text" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.firstName} onChange={e => setFormData({...formData, firstName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
<input type="text" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.lastName} onChange={e => setFormData({...formData, lastName: e.target.value})} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Straße & Hausnummer</label>
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PLZ</label>
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.zipCode} onChange={e => setFormData({...formData, zipCode: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Stadt</label>
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.city} onChange={e => setFormData({...formData, city: e.target.value})} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Telefon</label>
<input type="text" disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all disabled:opacity-60 disabled:bg-slate-50" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} />
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<Key className="w-5 h-5 text-indigo-600" /> Portal-Zugang
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Haupt-Login (E-Mail) *</label>
<input type="email" required disabled={!canEdit} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all bg-slate-50 text-slate-600 disabled:opacity-60" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h2 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-indigo-600" /> Ticket E-Mails
</h2>
<div className="flex gap-2 mb-4">
<input type="email" placeholder="E-Mail hinzufügen..." className="flex-1 border border-slate-300 p-2 rounded-xl text-sm outline-none focus-ring" value={newEmailInput} onChange={e => setNewEmailInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && setAdditionalEmails([...additionalEmails, newEmailInput])} />
<button onClick={() => { setAdditionalEmails([...additionalEmails, newEmailInput]); setNewEmailInput(''); }} type="button" className="bg-slate-100 text-slate-700 px-3 py-2 rounded-xl text-sm font-medium hover:bg-slate-200 transition">Add</button>
</div>
<div className="space-y-2">
{additionalEmails.map((email, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 border border-slate-100 px-3 py-2 rounded-xl group">
<span className="text-sm text-slate-700">{email}</span>
<button type="button" onClick={() => setAdditionalEmails(additionalEmails.filter(e => e !== email))} className="text-slate-400 hover:text-red-600 transition opacity-0 group-hover:opacity-100">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* ── CREDENTIALS ── */}
{activeTab === 'CREDENTIALS' && (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
{customer.credentials?.length === 0 ? (
<div className="p-8 text-slate-500 text-sm text-center">Keine Zugangsdaten hinterlegt.</div>
) : (
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
<tr>
<th className="px-6 py-4">Bezeichnung</th>
<th className="px-6 py-4">Benutzername</th>
<th className="px-6 py-4">Passwort</th>
<th className="px-6 py-4 text-right">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{customer.credentials.map((cred: any) => (
<tr key={cred.id} className="hover:bg-slate-50/80 transition-colors">
<td className="px-6 py-4 font-medium text-slate-900">{cred.title}</td>
<td className="px-6 py-4 text-slate-600">
<button onClick={() => { navigator.clipboard.writeText(cred.username); toast('Kopiert', 'success'); }} className="hover:text-indigo-600 transition flex items-center gap-1.5" title="Kopieren">
{cred.username} <Copy className="w-3 h-3 opacity-40" />
</button>
</td>
<td className="px-6 py-4 text-slate-600 font-mono">
<div className="flex items-center gap-2">
<span>{visiblePasswords[cred.id] ? cred.password : '••••••••'}</span>
<button onClick={() => setVisiblePasswords(p => ({...p, [cred.id]: !p[cred.id]}))} className="text-slate-400 hover:text-indigo-600 transition" title="Anzeigen/Verbergen">
{visiblePasswords[cred.id] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button onClick={() => { navigator.clipboard.writeText(cred.password); toast('Passwort kopiert', 'success'); }} className="text-slate-400 hover:text-indigo-600 transition" title="Kopieren">
<Copy className="w-3.5 h-3.5" />
</button>
</div>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleDeleteCredential(cred.id)} className="text-slate-400 hover:text-red-600 transition p-1">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── DOCUMENTS ── */}
{activeTab === 'DOCUMENTS' && (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
{customer.documents?.length === 0 ? (
<div className="p-8 text-slate-500 text-sm text-center">Keine Dokumente hinterlegt.</div>
) : (
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
<tr>
<th className="px-6 py-4">Dateiname</th>
<th className="px-6 py-4">Größe</th>
<th className="px-6 py-4">Hochgeladen</th>
<th className="px-6 py-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{customer.documents.map((doc: any) => (
<tr key={doc.id} className="hover:bg-slate-50/80 transition-colors group">
<td className="px-6 py-4 font-medium text-slate-900 flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
{doc.fileName}
</td>
<td className="px-6 py-4 text-slate-600">{formatBytes(doc.fileSize)}</td>
<td className="px-6 py-4 text-slate-600">{new Date(doc.createdAt).toLocaleDateString('de-DE')}</td>
<td className="px-6 py-4 text-right flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<a href={`/api/customers/${customerId}/documents?download=${doc.id}`} download className="p-1.5 text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
<Download className="w-4 h-4" />
</a>
<button onClick={() => handleDeleteDocument(doc.id)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── SALES DOCS ── */}
{activeTab === 'SALES_DOCS' && (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up">
{salesDocs.length === 0 ? (
<div className="p-8 text-slate-500 text-sm text-center">Keine Belege vorhanden.</div>
) : (
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-100">
<tr>
<th className="px-6 py-4">Nummer</th>
<th className="px-6 py-4">Typ</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Betrag</th>
<th className="px-6 py-4">Datum</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{salesDocs.map((d: any) => (
<tr key={d.id} onClick={() => router.push(`/sales/${d.id}`)} className="hover:bg-indigo-50/40 transition-colors cursor-pointer">
<td className="px-6 py-4 font-mono font-semibold text-slate-900">{d.number}</td>
<td className="px-6 py-4"><span className="px-2 py-0.5 rounded-md text-xs font-semibold bg-slate-100 text-slate-600">{{'QUOTE':'Angebot','ORDER_CONFIRMATION':'AB','DELIVERY_NOTE':'Lieferschein','INVOICE':'Rechnung','CREDIT_NOTE':'RK'}[d.type as string]}</span></td>
<td className="px-6 py-4"><span className="px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600">{{'DRAFT':'Entwurf','SENT':'Gesendet','ACCEPTED':'Angenommen','REJECTED':'Abgelehnt','DELIVERED':'Geliefert','PAID':'Bezahlt','CANCELLED':'Storniert','ARCHIVED':'Archiviert'}[d.status as string]}</span></td>
<td className="px-6 py-4 text-right font-semibold">{d.total.toFixed(2)} </td>
<td className="px-6 py-4 text-slate-500">{new Date(d.createdAt).toLocaleDateString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
{/* MODAL: Mitarbeiter */}
{showContactModal && (
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
<h2 className="text-xl font-bold text-slate-900 mb-6">Mitarbeiter hinzufügen</h2>
<form onSubmit={handleAddContact} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.firstName} onChange={e => setContactForm({...contactForm, firstName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.lastName} onChange={e => setContactForm({...contactForm, lastName: e.target.value})} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
<input type="email" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.email} onChange={e => setContactForm({...contactForm, email: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Telefon</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={contactForm.phone} onChange={e => setContactForm({...contactForm, phone: e.target.value})} />
</div>
<div className="flex justify-end gap-3 mt-8">
<button type="button" onClick={() => setShowContactModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Speichern</button>
</div>
</form>
</div>
</div>
)}
{/* MODAL: Zugangsdaten */}
{showCredentialModal && (
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
<h2 className="text-xl font-bold text-slate-900 mb-6">Zugangsdaten anlegen</h2>
<form onSubmit={handleAddCredential} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Bezeichnung *</label>
<input type="text" required placeholder="z.B. Microsoft 365 Admin" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.title} onChange={e => setCredentialForm({...credentialForm, title: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.username} onChange={e => setCredentialForm({...credentialForm, username: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input type="text" placeholder="Optional" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={credentialForm.description} onChange={e => setCredentialForm({...credentialForm, description: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort *</label>
<div className="flex gap-2">
<input type="text" required className="flex-1 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all font-mono" value={credentialForm.password} onChange={e => setCredentialForm({...credentialForm, password: e.target.value})} />
<button type="button" onClick={() => setCredentialForm({...credentialForm, password: generatePassword()})} className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-2 rounded-xl transition flex items-center gap-1.5 text-sm font-medium flex-shrink-0" title="Passwort generieren">
<RefreshCw className="w-4 h-4" /> Generieren
</button>
</div>
</div>
<div className="flex justify-end gap-3 mt-8">
<button type="button" onClick={() => setShowCredentialModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Speichern</button>
</div>
</form>
</div>
</div>
)}
{/* MODAL: Neues Ticket */}
{showTicketModal && (
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white p-8 rounded-3xl shadow-2xl max-w-md w-full">
<h2 className="text-xl font-bold text-slate-900 mb-6">Neues Ticket erstellen</h2>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={ticketForm.title} onChange={e => setTicketForm({...ticketForm, title: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung *</label>
<textarea required rows={3} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all resize-none" value={ticketForm.description} onChange={e => setTicketForm({...ticketForm, description: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Priorität</label>
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={ticketForm.priority} onChange={e => setTicketForm({...ticketForm, priority: e.target.value})}>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
<option value="CRITICAL">Kritisch</option>
</select>
</div>
<div className="flex justify-end gap-3 mt-8">
<button type="button" onClick={() => setShowTicketModal(false)} className="px-4 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-xl font-medium hover:bg-indigo-700 transition">Ticket erstellen</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
// /opt/erp-system/app/customers/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Users, Plus, Mail, Building2, Phone, X, Trash2 } from 'lucide-react';
import { useToast } from '../components/ToastProvider';
import { useSession } from 'next-auth/react';
type Customer = {
id: number;
firstName: string;
lastName: string;
email: string;
companyName: string | null;
phone: string | null;
};
export default function CustomersPage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '', companyName: '', phone: '' });
const { toast, confirm } = useToast();
const { data: session } = useSession();
const permissions = (session?.user as any)?.permissions || [];
const canDelete = permissions.includes('DATA_DELETE');
useEffect(() => {
fetchCustomers();
}, []);
const fetchCustomers = async () => {
const res = await fetch('/api/customers');
if (res.ok) setCustomers(await res.json());
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (res.ok) {
setFormData({ firstName: '', lastName: '', email: '', companyName: '', phone: '' });
setShowForm(false);
fetchCustomers();
}
};
const handleDelete = async (customer: Customer) => {
const isConfirmed = await confirm({
title: 'Kunde löschen',
message: `"${customer.companyName || customer.firstName + ' ' + customer.lastName}" und alle verknüpften Daten (Tickets, Verträge, Dokumente) unwiderruflich löschen?`,
danger: true
});
if (!isConfirmed) return;
const res = await fetch(`/api/customers?id=${customer.id}`, { method: 'DELETE' });
if (res.ok) {
toast('Kunde gelöscht', 'success');
fetchCustomers();
} else {
const data = await res.json();
toast(data.error || 'Fehler beim Löschen', 'error');
}
};
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Users className="w-6 h-6 text-indigo-600" /> Kundenverwaltung
</h1>
<p className="text-slate-500 mt-1">Verwalte deinen Kundenstamm und Ansprechpartner.</p>
</div>
<button
onClick={() => setShowForm(!showForm)}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2 font-medium shadow-sm"
>
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neuer Kunde'}
</button>
</div>
{/* Formular (Toggle) */}
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4 duration-200">
<h2 className="text-lg font-semibold text-slate-800 mb-4">Kundendaten erfassen</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.firstName} onChange={e => setFormData({...formData, firstName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.lastName} onChange={e => setFormData({...formData, lastName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
<input type="email" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Telefon</label>
<input type="tel" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Firma (Optional)</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.companyName} onChange={e => setFormData({...formData, companyName: e.target.value})} />
</div>
<div className="md:col-span-2 flex justify-end mt-2">
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">
Kunde speichern
</button>
</div>
</form>
</div>
)}
{/* Datentabelle */}
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th className="py-4 px-6">ID</th>
<th className="py-4 px-6">Kunde</th>
<th className="py-4 px-6">Kontakt</th>
<th className="py-4 px-6 text-right">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{customers.map((c) => (
<tr key={c.id} className="hover:bg-slate-50/80 transition-colors group border-b border-slate-50 last:border-0">
<td className="py-4 px-6 font-mono text-slate-400">#{c.id.toString().padStart(4, '0')}</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-semibold text-slate-900">{c.firstName} {c.lastName}</span>
{c.companyName && (
<span className="text-slate-500 text-xs flex items-center gap-1 mt-0.5">
<Building2 className="w-3 h-3" /> {c.companyName}
</span>
)}
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col gap-1 text-slate-600">
<span className="flex items-center gap-2"><Mail className="w-4 h-4 text-slate-400" /> {c.email}</span>
{c.phone && <span className="flex items-center gap-2 text-xs"><Phone className="w-3 h-3 text-slate-400" /> {c.phone}</span>}
</div>
</td>
<td className="py-4 px-6 text-right">
<div className="flex items-center justify-end gap-1">
<a href={`/customers/${c.id}`} className="text-indigo-600 font-medium hover:text-indigo-800 transition-opacity inline-block p-2">
Akte öffnen
</a>
{canDelete && (
<button onClick={() => handleDelete(c)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all" title="Kunde löschen">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
{customers.length === 0 && (
<tr><td colSpan={4} className="py-8 text-center text-slate-500">Keine Kunden vorhanden.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useEffect } from 'react';
import { AlertTriangle, RefreshCcw } from 'lucide-react';
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-[400px] flex items-center justify-center p-8">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full border border-red-100 text-center">
<div className="w-16 h-16 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-8 h-8" />
</div>
<h2 className="text-xl font-bold text-slate-900 mb-2">Ein Fehler ist aufgetreten</h2>
<p className="text-sm text-slate-500 mb-8">
Das System konnte diese Ansicht nicht laden. Bitte versuche es erneut oder kontaktiere den Support.
</p>
<button
onClick={() => reset()}
className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium inline-flex items-center gap-2"
>
<RefreshCcw className="w-4 h-4" /> Erneut versuchen
</button>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
/* /opt/erp-system/app/globals.css */
@import "tailwindcss";
/* ── Base ── */
html, body {
height: 100%;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
/* ── Custom Animations ── */
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes count-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-count-up {
animation: count-up 0.5s ease-out;
}
/* ── Staggered animations for cards ── */
.stagger-1 { animation-delay: 0ms; }
.stagger-2 { animation-delay: 80ms; animation-fill-mode: backwards; }
.stagger-3 { animation-delay: 160ms; animation-fill-mode: backwards; }
.stagger-4 { animation-delay: 240ms; animation-fill-mode: backwards; }
/* ── Mini Chart bar animation ── */
@keyframes grow-bar {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
.animate-grow-bar {
animation: grow-bar 0.6s ease-out;
transform-origin: bottom;
}
/* ── Focus ring utility ── */
.focus-ring {
outline: none;
}
.focus-ring:focus-visible {
box-shadow: 0 0 0 2px white, 0 0 0 4px rgba(99, 102, 241, 0.5);
border-radius: 8px;
}
/* ── Hover Utilities ── */
.hover-lift {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.hover-lift:hover {
transform: translateY(-3px);
box-shadow: 0 12px 24px -10px rgba(99, 102, 241, 0.15), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ── Glass effect ── */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
+28
View File
@@ -0,0 +1,28 @@
// /opt/erp-system/app/layout.tsx
import './globals.css';
import type { Metadata } from 'next';
import { Providers } from './providers';
import AppShell from './components/AppShell';
export const metadata: Metadata = {
title: 'ERP Pro Unternehmenssteuerung',
description: 'Professionelles ERP-System für Ticketverwaltung, Kundenkommunikation und Abrechnung.',
icons: { icon: '/favicon.ico' },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body className="font-sans antialiased">
<Providers>
<AppShell>{children}</AppShell>
</Providers>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="h-[400px] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
<p className="text-sm font-medium text-slate-500 animate-pulse">Inhalte werden geladen...</p>
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { signIn } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { KeyRound, Mail, ArrowRight, ShieldCheck, Loader2 } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.error) {
setError("Zugangsdaten nicht korrekt. Bitte erneut versuchen.");
setLoading(false);
} else {
router.push("/");
router.refresh();
}
};
return (
<div className="min-h-screen flex relative overflow-hidden bg-slate-50 absolute inset-0 z-50">
{/* Background Decor */}
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-indigo-200/50 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob"></div>
<div className="absolute top-[20%] right-[-10%] w-[40%] h-[50%] bg-purple-200/50 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-2000"></div>
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] bg-emerald-100/50 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-4000"></div>
<div className="flex-1 flex flex-col justify-center px-4 sm:px-6 lg:flex-none lg:px-20 xl:px-32 relative z-10 w-full lg:w-1/2">
<div className="mx-auto w-full max-w-sm lg:w-96 animate-slide-up">
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200">
<ShieldCheck className="w-7 h-7 text-white" />
</div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">ERP Pro</h2>
</div>
<div className="bg-white/80 backdrop-blur-xl py-8 px-6 shadow-xl rounded-2xl border border-white">
<div className="mb-6">
<h1 className="text-xl font-bold text-slate-900">Willkommen zurück</h1>
<p className="text-sm text-slate-500 mt-1">Bitte logge dich ein, um fortzufahren.</p>
</div>
{error && (
<div className="mb-6 bg-red-50 text-red-600 p-4 rounded-xl text-sm font-medium border border-red-100 animate-shake">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail Adresse</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-slate-400" />
</div>
<input
type="email"
required
className="block w-full pl-10 pr-3 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-600 focus:border-transparent outline-none bg-slate-50 focus:bg-white transition-all text-sm font-medium"
placeholder="name@firma.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<KeyRound className="h-5 w-5 text-slate-400" />
</div>
<input
type="password"
required
className="block w-full pl-10 pr-3 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-600 focus:border-transparent outline-none bg-slate-50 focus:bg-white transition-all text-sm font-medium"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-bold text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed group"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Anmeldung läuft...
</>
) : (
<>
Anmelden
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
</div>
</div>
</div>
{/* Image / Right Side */}
<div className="hidden lg:block relative w-0 flex-1">
<div className="absolute inset-0 h-full w-full bg-slate-900 object-cover overflow-hidden">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1550439062-609e1531270e?ixlib=rb-1.2.1&auto=format&fit=crop&w=2000&q=80')] bg-cover bg-center opacity-30 mix-blend-luminosity"></div>
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 to-transparent"></div>
<div className="absolute bottom-12 left-12 right-12 text-white">
<h2 className="text-3xl font-bold mb-4">Effizientes Management für dein Business.</h2>
<p className="text-slate-300 text-lg max-w-xl">
Alles an einem Ort: Ticket-Support, Kundenverwaltung und Abrechnungsprozesse nahtlos integriert.
</p>
</div>
</div>
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
// /opt/erp-system/app/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSession } from "next-auth/react";
import { Ticket as TicketIcon, Clock, Activity, ArrowRight, Plus, X, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { getStatusBadge } from './components/AppShell';
export default function DashboardPage() {
const { data: session } = useSession();
const [data, setData] = useState<any>(null);
const [error, setError] = useState(false);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({ title: '', description: '' });
useEffect(() => {
fetchData();
}, []);
const fetchData = () => {
fetch('/api/dashboard')
.then(res => {
if (!res.ok) throw new Error('Netzwerkfehler');
return res.json();
})
.then(setData)
.catch(() => setError(true));
};
const handleCustomerTicketSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
customerId: (session?.user as any)?.id
})
});
if (res.ok) {
setShowForm(false);
setFormData({ title: '', description: '' });
fetchData();
} else {
alert('Fehler beim Erstellen des Tickets');
}
};
if (error) return <div className="p-8 text-red-600 font-medium">Fehler beim Laden des Dashboards.</div>;
if (!data) return <div className="p-8 text-slate-500 font-medium animate-pulse">Lade Metriken...</div>;
const userType = (session?.user as any)?.userType;
// ----------------------------------------------------
// INTERFACE FÜR KUNDEN
// ----------------------------------------------------
if (userType === 'CUSTOMER') {
return (
<div className="space-y-8 animate-fade-in-up stagger-1">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Hallo, {(session?.user as any)?.firstName}!
</h1>
<p className="text-slate-500 mt-1">Willkommen in deinem Service-Portal. Hier kannst du deine Support-Anfragen verwalten.</p>
</div>
<button
onClick={() => setShowForm(!showForm)}
className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium shadow-sm flex items-center gap-2 hover:bg-indigo-700 transition self-start md:self-auto"
>
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neues Ticket eröffnen'}
</button>
</div>
{showForm && (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 animate-in fade-in slide-in-from-top-4">
<h2 className="text-lg font-semibold text-slate-800 mb-4">Unterstützung anfordern</h2>
<form onSubmit={handleCustomerTicketSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff / Thema *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={formData.title} onChange={e => setFormData({...formData, title: e.target.value})} placeholder="Wobei benötigst du Hilfe?" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung des Problems *</label>
<textarea required className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none h-32 resize-none" value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} placeholder="Bitte beschreibe das Problem so genau wie möglich..." />
</div>
<div className="flex justify-end">
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">Ticket absenden</button>
</div>
</form>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-start gap-4 hover-lift">
<div className="p-3 bg-amber-50 text-amber-600 rounded-lg">
<TicketIcon className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">In Bearbeitung / Offen</p>
<p className="text-2xl font-bold text-slate-900">{data.openTickets}</p>
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-start gap-4 hover-lift">
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-lg">
<CheckCircle2 className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Gelöste Tickets</p>
<p className="text-2xl font-bold text-slate-900">{data.closedTickets}</p>
</div>
</div>
</div>
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up stagger-2">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-800">Deine Tickets</h2>
</div>
<div className="divide-y divide-slate-100">
{data.recentTickets.length > 0 ? data.recentTickets.map((ticket: any) => (
<div key={ticket.id} className="p-4 px-6 flex items-center justify-between hover:bg-slate-50 transition group">
<div>
<Link href={`/tickets/${ticket.id}`} className="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
#{ticket.id} {ticket.title}
</Link>
<p className="text-sm text-slate-500 mt-0.5">
Erstellt am: {new Date(ticket.createdAt).toLocaleDateString('de-DE')} Letztes Update: {new Date(ticket.updatedAt).toLocaleString('de-DE')}
</p>
</div>
<div>
{getStatusBadge(ticket.status)}
</div>
</div>
)) : (
<div className="p-8 text-center text-slate-500">Du hast bisher noch keine Tickets erstellt.</div>
)}
</div>
</div>
</div>
);
}
// ----------------------------------------------------
// INTERFACE FÜR TEAM
// ----------------------------------------------------
return (
<div className="max-w-7xl mx-auto space-y-8 animate-fade-in-up stagger-1">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Willkommen zurück, {(session?.user as any)?.firstName}!
</h1>
<p className="text-slate-500 mt-1">Hier ist der aktuelle Status deines ERP-Systems.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-start gap-4 hover-lift">
<div className="p-3 bg-red-50 text-red-600 rounded-lg">
<TicketIcon className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Offene Tickets (Gesamt)</p>
<p className="text-3xl font-extrabold text-slate-900 mt-1">{data.openTickets}</p>
</div>
<div className="ml-auto flex items-end gap-1 h-12">
{[40, 70, 45, 90, 65, 80].map((h, i) => (
<div key={i} className="w-1.5 bg-red-200 rounded-t-sm animate-grow-bar" style={{ height: `${h}%`, animationDelay: `${i * 100}ms` }}></div>
))}
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-start gap-4 hover-lift">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-lg">
<Activity className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Dir zugewiesen (Offen)</p>
<p className="text-3xl font-extrabold text-slate-900 mt-1">{data.myTickets}</p>
</div>
<div className="ml-auto flex items-end gap-1 h-12">
{[20, 40, 30, 60, 50, 70].map((h, i) => (
<div key={i} className="w-1.5 bg-indigo-200 rounded-t-sm animate-grow-bar" style={{ height: `${h}%`, animationDelay: `${i * 100}ms` }}></div>
))}
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex items-start gap-4 hover-lift">
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-lg">
<Clock className="w-6 h-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500">Erfasste Stunden (Gesamt)</p>
<p className="text-3xl font-extrabold text-slate-900 mt-1">{data.totalHours.toFixed(1)} <span className="text-lg text-slate-400 font-medium">h</span></p>
</div>
<div className="ml-auto flex items-end gap-1 h-12">
{[30, 50, 40, 80, 60, 90].map((h, i) => (
<div key={i} className="w-1.5 bg-emerald-200 rounded-t-sm animate-grow-bar" style={{ height: `${h}%`, animationDelay: `${i * 100}ms` }}></div>
))}
</div>
</div>
</div>
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden animate-fade-in-up stagger-2">
<div className="p-6 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-800">Zuletzt aktualisierte Tickets</h2>
<Link href="/tickets" className="text-sm font-medium text-indigo-600 hover:text-indigo-800 flex items-center gap-1">
Alle ansehen <ArrowRight className="w-4 h-4" />
</Link>
</div>
<div className="divide-y divide-slate-100">
{data.recentTickets.length > 0 ? data.recentTickets.map((ticket: any) => (
<div key={ticket.id} className="p-4 px-6 flex items-center justify-between hover:bg-slate-50 transition group">
<div>
<Link href={`/tickets/${ticket.id}`} className="font-semibold text-slate-900 group-hover:text-indigo-600 transition">
#{ticket.id} {ticket.title}
</Link>
<p className="text-sm text-slate-500 mt-0.5">
{ticket.customer.companyName || `${ticket.customer.firstName} ${ticket.customer.lastName}`} Update: {new Date(ticket.updatedAt).toLocaleString('de-DE')}
</p>
</div>
<div>
{getStatusBadge(ticket.status)}
</div>
</div>
)) : (
<div className="p-8 text-center text-slate-500">Keine Tickets vorhanden.</div>
)}
</div>
</div>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Package, Plus, X, Edit2, Trash2, Search, Image as ImageIcon, AlertTriangle } from 'lucide-react';
import { useToast } from '../components/ToastProvider';
import { useSession } from 'next-auth/react';
export default function ProductsPage() {
const [products, setProducts] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const { toast, confirm } = useToast();
const { data: session } = useSession();
const permissions = (session?.user as any)?.permissions || [];
const canEdit = permissions.includes('PURCHASING_MANAGE');
const canDelete = permissions.includes('DATA_DELETE');
const imageRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState({
name: '', description: '', sku: '', purchasePrice: '', salePrice: '',
stock: '', unit: 'Stk', category: '', trackStock: true
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
useEffect(() => { fetchProducts(); }, []);
const fetchProducts = async () => {
const res = await fetch('/api/products');
if (res.ok) setProducts(await res.json());
};
const handleEdit = (p: any) => {
setEditingId(p.id);
setForm({
name: p.name, description: p.description || '', sku: p.sku || '',
purchasePrice: p.purchasePrice.toString(), salePrice: p.salePrice.toString(),
stock: p.stock.toString(), unit: p.unit, category: p.category || '',
trackStock: p.trackStock !== false
});
setImagePreview(p.imagePath);
setImageFile(null);
setShowForm(true);
};
const handleNew = () => {
setEditingId(null);
setForm({ name: '', description: '', sku: '', purchasePrice: '', salePrice: '', stock: '', unit: 'Stk', category: '', trackStock: true });
setImagePreview(null);
setImageFile(null);
setShowForm(!showForm);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, String(v)));
if (editingId) { fd.append('id', editingId.toString()); fd.append('active', 'true'); }
if (imageFile) fd.append('image', imageFile);
const res = await fetch('/api/products', {
method: editingId ? 'PUT' : 'POST',
body: fd
});
if (res.ok) {
toast(editingId ? 'Produkt aktualisiert' : 'Produkt angelegt', 'success');
setShowForm(false);
fetchProducts();
} else {
const data = await res.json();
toast(data.error || 'Fehler', 'error');
}
};
const handleDelete = async (p: any) => {
const ok = await confirm({ title: 'Produkt löschen', message: `"${p.name}" wirklich löschen?`, danger: true });
if (!ok) return;
const res = await fetch(`/api/products?id=${p.id}`, { method: 'DELETE' });
if (res.ok) { toast('Gelöscht', 'success'); fetchProducts(); }
else toast('Fehler beim Löschen', 'error');
};
const filtered = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku && p.sku.toLowerCase().includes(search.toLowerCase())) ||
(p.category && p.category.toLowerCase().includes(search.toLowerCase()))
);
const availableStock = (p: any) => p.stock - p.reservedStock;
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Package className="w-6 h-6 text-indigo-600" /> Produkte & Lager
</h1>
<p className="text-slate-500 mt-1">Verwalte Artikel, Preise und Bestände.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" placeholder="Suchen..." value={search} onChange={e => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 border border-slate-300 rounded-xl text-sm outline-none focus-ring w-56" />
</div>
{canEdit && (
<button onClick={handleNew} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-medium shadow-sm">
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neues Produkt'}
</button>
)}
</div>
</div>
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4 duration-200">
<h2 className="text-lg font-semibold text-slate-800 mb-4">{editingId ? 'Produkt bearbeiten' : 'Neues Produkt'}</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2 grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Produktname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.name} onChange={e => setForm({...form, name: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Artikelnr. (SKU)</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.sku} onChange={e => setForm({...form, sku: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.category} onChange={e => setForm({...form, category: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">EK-Preis ()</label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.purchasePrice} onChange={e => setForm({...form, purchasePrice: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">VK-Preis ()</label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.salePrice} onChange={e => setForm({...form, salePrice: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Bestand</label>
<input type="number" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={form.stock} onChange={e => setForm({...form, stock: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Einheit</label>
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={form.unit} onChange={e => setForm({...form, unit: e.target.value})}>
<option>Stk</option><option>Std</option><option>kg</option><option>m</option><option>Pauschal</option>
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea rows={2} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none resize-none" value={form.description} onChange={e => setForm({...form, description: e.target.value})} />
</div>
<div className="col-span-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.trackStock} onChange={e => setForm({...form, trackStock: e.target.checked})} className="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<span className="text-sm font-medium text-slate-700">Bestand verfolgen</span>
</label>
<p className="text-xs text-slate-400 mt-0.5 ml-6">Deaktivieren für Dienstleistungen, Versand, Anfahrt etc.</p>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-3">
<div className="w-full aspect-square bg-slate-50 border-2 border-dashed border-slate-300 rounded-2xl flex items-center justify-center overflow-hidden cursor-pointer hover:border-indigo-400 transition" onClick={() => imageRef.current?.click()}>
{imagePreview ? (
<img src={imagePreview} alt="Vorschau" className="w-full h-full object-cover rounded-2xl" />
) : (
<div className="text-center text-slate-400"><ImageIcon className="w-8 h-8 mx-auto mb-1" /><span className="text-xs">Bild hochladen</span></div>
)}
</div>
<input ref={imageRef} type="file" accept="image/*" className="hidden" onChange={handleImageChange} />
</div>
<div className="md:col-span-3 flex justify-end gap-3 mt-2">
<button type="button" onClick={() => setShowForm(false)} className="px-6 py-2.5 rounded-lg text-slate-600 hover:bg-slate-100 font-medium">Abbrechen</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">
{editingId ? 'Speichern' : 'Produkt anlegen'}
</button>
</div>
</form>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(p => (
<div key={p.id} className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden hover-lift group relative">
<div className="aspect-video bg-slate-100 flex items-center justify-center overflow-hidden">
{p.imagePath ? (
<img src={p.imagePath} alt={p.name} className="w-full h-full object-cover" />
) : (
<Package className="w-10 h-10 text-slate-300" />
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="font-bold text-slate-900 text-sm">{p.name}</h3>
{p.sku && <p className="text-xs text-slate-400 font-mono">{p.sku}</p>}
</div>
<div className="flex gap-1">
{canEdit && (
<button onClick={() => handleEdit(p)} className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition">
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
{canDelete && (
<button onClick={() => handleDelete(p)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{p.category && <span className="inline-block mt-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-slate-100 text-slate-500">{p.category}</span>}
<div className="flex items-end justify-between mt-3 pt-3 border-t border-slate-100">
<div>
<p className="text-[10px] text-slate-400 uppercase">EK / VK</p>
<p className="text-sm font-semibold text-slate-700">{p.purchasePrice.toFixed(2)} / <span className="text-indigo-600">{p.salePrice.toFixed(2)} </span></p>
</div>
<div className="text-right">
{p.trackStock ? (
<>
<p className="text-[10px] text-slate-400 uppercase">Bestand</p>
<div className="flex items-center gap-1">
{availableStock(p) <= 0 && <AlertTriangle className="w-3 h-3 text-red-500" />}
<p className={`text-sm font-bold ${availableStock(p) <= 0 ? 'text-red-600' : availableStock(p) <= 5 ? 'text-amber-600' : 'text-emerald-600'}`}>
{availableStock(p)} {p.unit}
</p>
</div>
{p.reservedStock > 0 && <p className="text-[10px] text-amber-500">{p.reservedStock} reserviert</p>}
</>
) : (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-500">Dienstleistung</span>
)}
</div>
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="col-span-full text-center py-12 text-slate-500">
{search ? 'Keine Produkte gefunden.' : 'Noch keine Produkte angelegt.'}
</div>
)}
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
// /opt/erp-system/app/providers.tsx
'use client';
import { SessionProvider } from "next-auth/react";
import { ToastProvider } from "./components/ToastProvider";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ToastProvider>
{children}
</ToastProvider>
</SessionProvider>
);
}
+207
View File
@@ -0,0 +1,207 @@
// /opt/erp-system/app/roles/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { ShieldCheck, Plus, X, Check, Edit2, Trash2 } from 'lucide-react';
import { useToast } from '../components/ToastProvider';
import { useSession } from 'next-auth/react';
const AVAILABLE_PERMISSIONS = [
{ id: 'TICKETS_VIEW', label: 'Tickets ansehen' },
{ id: 'TICKETS_EDIT', label: 'Tickets bearbeiten / Zeit buchen' },
{ id: 'CUSTOMERS_MANAGE', label: 'Kundenstamm verwalten' },
{ id: 'CUSTOMERS_EDIT', label: 'Kundendaten bearbeiten (Stammdaten, Zugangsdaten)' },
{ id: 'PURCHASING_MANAGE', label: 'Einkauf: Produkte & Bestand verwalten' },
{ id: 'SALES_MANAGE', label: 'Verkauf: Angebote, Rechnungen & Belege' },
{ id: 'DATA_DELETE', label: 'Daten löschen (Kunden, Mitarbeiter, Gruppen)' },
{ id: 'TEAM_MANAGE', label: 'Mitarbeiter & Rollen verwalten' },
{ id: 'SYSTEM_SETTINGS', label: 'Globale Einstellungen' }
];
export default function RolesPage() {
const [roles, setRoles] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState({ name: '', permissions: [] as string[] });
const { toast, confirm } = useToast();
const { data: session } = useSession();
const permissions = (session?.user as any)?.permissions || [];
const canDelete = permissions.includes('DATA_DELETE');
useEffect(() => { fetchRoles(); }, []);
const fetchRoles = async () => {
const res = await fetch('/api/roles');
if (res.ok) setRoles(await res.json());
};
const handleEdit = (role: any) => {
setEditingId(role.id);
setFormData({ name: role.name, permissions: role.permissions || [] });
setShowForm(true);
};
const handleCreateNew = () => {
setEditingId(null);
setFormData({ name: '', permissions: [] });
setShowForm(!showForm);
};
const togglePermission = (permId: string) => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.includes(permId)
? prev.permissions.filter(p => p !== permId)
: [...prev.permissions, permId]
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const method = editingId ? 'PUT' : 'POST';
const payload = editingId ? { id: editingId, ...formData } : formData;
const res = await fetch('/api/roles', {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
setFormData({ name: '', permissions: [] });
setEditingId(null);
setShowForm(false);
fetchRoles();
// Hinweis für den Nutzer, falls er seine eigenen Rechte ändert
toast('Erfolgreich gespeichert. Hinweis: Änderungen an den eigenen Rechten werden erst nach einem Neu-Login aktiv.', 'success', 8000);
} else {
toast('Fehler beim Speichern.', 'error');
}
};
const handleDelete = async (role: any) => {
const isConfirmed = await confirm({
title: 'Berechtigungsgruppe löschen',
message: `"${role.name}" wirklich löschen?`,
danger: true
});
if (!isConfirmed) return;
const res = await fetch(`/api/roles?id=${role.id}`, { method: 'DELETE' });
if (res.ok) {
toast('Gruppe gelöscht', 'success');
fetchRoles();
} else {
const data = await res.json();
toast(data.error || 'Fehler beim Löschen', 'error');
}
};
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-indigo-600" /> Berechtigungsgruppen
</h1>
<p className="text-slate-500 mt-1">Definiere Rollen und weise granulare Rechte zu.</p>
</div>
<button onClick={handleCreateNew} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-medium">
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neue Gruppe'}
</button>
</div>
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-slate-800">
{editingId ? 'Berechtigungsgruppe bearbeiten' : 'Neue Gruppe anlegen'}
</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name der Gruppe (z.B. Supporter, Buchhaltung) *</label>
<input type="text" required className="w-full md:w-1/2 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">Zugeordnete Berechtigungen</label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{AVAILABLE_PERMISSIONS.map(perm => (
<label key={perm.id} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${formData.permissions.includes(perm.id) ? 'bg-indigo-50 border-indigo-200 text-indigo-900' : 'bg-white border-slate-200 hover:bg-slate-50'}`}>
<input
type="checkbox"
className="hidden"
checked={formData.permissions.includes(perm.id)}
onChange={() => togglePermission(perm.id)}
/>
<div className={`w-5 h-5 rounded flex items-center justify-center border transition-colors ${formData.permissions.includes(perm.id) ? 'bg-indigo-600 border-indigo-600 text-white' : 'border-slate-300 bg-white'}`}>
{formData.permissions.includes(perm.id) && <Check className="w-3 h-3" />}
</div>
<span className="font-medium text-sm">{perm.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end pt-2 gap-3">
<button type="button" onClick={() => setShowForm(false)} className="px-6 py-2.5 rounded-lg text-slate-600 hover:bg-slate-100 font-medium">Abbrechen</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">
{editingId ? 'Änderungen speichern' : 'Gruppe speichern'}
</button>
</div>
</form>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{roles.map(role => (
<div key={role.id} className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 p-6 flex flex-col h-full group relative hover-lift">
{/* Action Buttons */}
<div className="absolute top-4 right-4 flex items-center gap-1">
<button
onClick={() => handleEdit(role)}
className="p-2 bg-slate-100 text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
title="Bearbeiten"
>
<Edit2 className="w-4 h-4" />
</button>
{canDelete && (
<button
onClick={() => handleDelete(role)}
className="p-2 bg-slate-100 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex justify-between items-start mb-4 pr-8">
<h3 className="font-bold text-lg text-slate-900">{role.name}</h3>
</div>
<div className="mb-4">
<span className="bg-slate-100 text-slate-600 px-2.5 py-1 rounded-md text-xs font-semibold">{role._count.users} Nutzer zugeordnet</span>
</div>
<div className="flex-1 space-y-2 mt-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Rechte</p>
{role.permissions.map((p: string) => (
<div key={p} className="text-sm text-slate-600 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-400"></div>
{AVAILABLE_PERMISSIONS.find(ap => ap.id === p)?.label || p}
</div>
))}
{(!role.permissions || role.permissions.length === 0) && <span className="text-sm text-slate-400 italic">Keine Rechte zugewiesen.</span>}
</div>
</div>
))}
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Download, PenTool, Send, Check, X, Printer, FilePlus2 } from 'lucide-react';
import { useToast } from '../../components/ToastProvider';
import { useSession } from 'next-auth/react';
const typeLabels: Record<string, string> = {
QUOTE: 'Angebot', ORDER_CONFIRMATION: 'Auftragsbestätigung',
DELIVERY_NOTE: 'Lieferschein', INVOICE: 'Rechnung', CREDIT_NOTE: 'Rechnungskorrektur'
};
const statusLabels: Record<string, string> = {
DRAFT: 'Entwurf', SENT: 'Gesendet', ACCEPTED: 'Angenommen',
REJECTED: 'Abgelehnt', DELIVERED: 'Geliefert', PAID: 'Bezahlt',
CANCELLED: 'Storniert', ARCHIVED: 'Archiviert'
};
const statusColors: Record<string, string> = {
DRAFT: 'bg-slate-100 text-slate-600', SENT: 'bg-blue-100 text-blue-700',
ACCEPTED: 'bg-emerald-100 text-emerald-700', REJECTED: 'bg-red-100 text-red-700',
DELIVERED: 'bg-amber-100 text-amber-700', PAID: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-slate-200 text-slate-500', ARCHIVED: 'bg-slate-100 text-slate-400'
};
export default function SalesDocDetailPage() {
const params = useParams();
const router = useRouter();
const { toast, confirm } = useToast();
const { data: session } = useSession();
const perms = (session?.user as any)?.permissions || [];
const canManage = perms.includes('SALES_MANAGE');
const [doc, setDoc] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showSignature, setShowSignature] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawing = useRef(false);
useEffect(() => { fetchDoc(); }, [params.id]);
const fetchDoc = async () => {
const res = await fetch(`/api/sales/${params.id}`);
if (res.ok) setDoc(await res.json());
setLoading(false);
};
const updateStatus = async (status: string) => {
const res = await fetch(`/api/sales/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (res.ok) {
const data = await res.json();
// If quote was accepted, auto-AB was created → show info
if (data.followUpId) {
toast(`Auftragsbestätigung ${data.followUpNumber} wurde automatisch erstellt & versendet. Angebot archiviert.`, 'success');
} else {
toast(`Status: ${statusLabels[status]}`, 'success');
}
fetchDoc();
}
};
const createFollowUp = async () => {
const followUpLabels: Record<string, string> = { ORDER_CONFIRMATION: 'Lieferschein', DELIVERY_NOTE: 'Rechnung', INVOICE: 'Rechnungskorrektur' };
const nextLabel = followUpLabels[doc.type] || 'Folgebeleg';
const msg = doc.type === 'INVOICE'
? `Rechnungskorrektur erstellen? Die Rechnung ${doc.number} wird storniert.`
: `Aus dieser ${typeLabels[doc.type]} einen ${nextLabel} erstellen?`;
const ok = await confirm({ title: `${nextLabel} erstellen`, message: msg, danger: doc.type === 'INVOICE' });
if (!ok) return;
const res = await fetch(`/api/sales/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'CREATE_FOLLOWUP' })
});
if (res.ok) {
const newDoc = await res.json();
toast(`${nextLabel} ${newDoc.number} erstellt.`, 'success');
router.push(`/sales/${newDoc.id}`);
} else {
toast('Fehler beim Erstellen', 'error');
}
};
// Signature Canvas
const startDraw = (e: React.MouseEvent | React.TouchEvent) => {
isDrawing.current = true;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
};
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = 'touches' in e ? e.touches[0].clientX - rect.left : e.clientX - rect.left;
const y = 'touches' in e ? e.touches[0].clientY - rect.top : e.clientY - rect.top;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#1e293b';
ctx.lineTo(x, y);
ctx.stroke();
};
const endDraw = () => { isDrawing.current = false; };
const clearSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx?.clearRect(0, 0, canvas.width, canvas.height);
};
const saveSignature = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
const res = await fetch(`/api/sales/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureData: dataUrl })
});
if (res.ok) { toast('Unterschrift gespeichert', 'success'); setShowSignature(false); fetchDoc(); }
};
if (loading) return <div className="p-8 text-slate-500 font-medium">Lade...</div>;
if (!doc) return <div className="p-8 text-red-600 font-medium">Beleg nicht gefunden.</div>;
const nextActions: Record<string, { label: string, status: string, icon: any }[]> = {
DRAFT: [{ label: 'Als gesendet markieren', status: 'SENT', icon: Send }],
SENT: doc.type === 'ORDER_CONFIRMATION' ? [] :
doc.type === 'INVOICE' ? [{ label: 'Bezahlt', status: 'PAID', icon: Check }] :
doc.type === 'CREDIT_NOTE' ? [] :
[{ label: 'Angenommen', status: 'ACCEPTED', icon: Check }, { label: 'Abgelehnt', status: 'REJECTED', icon: X }],
ACCEPTED: doc.type === 'DELIVERY_NOTE' ? [{ label: 'Geliefert', status: 'DELIVERED', icon: Check }] : [],
};
const actions = nextActions[doc.status] || [];
// Can create follow-up? AB → LS, LS → RE, RE → RK
const canCreateFollowUp = canManage && (
(doc.type === 'ORDER_CONFIRMATION' && doc.status === 'SENT') ||
(doc.type === 'DELIVERY_NOTE' && ['SENT', 'ACCEPTED', 'DELIVERED'].includes(doc.status)) ||
(doc.type === 'INVOICE' && ['SENT', 'PAID'].includes(doc.status))
);
return (
<div className="max-w-5xl mx-auto space-y-6 pb-12 animate-fade-in-up">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/sales')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900">{typeLabels[doc.type]} {doc.number}</h1>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[doc.status]}`}>{statusLabels[doc.status]}</span>
{doc.previousStatus && ['ARCHIVED', 'CANCELLED'].includes(doc.status) && (
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[doc.previousStatus] || 'bg-slate-100 text-slate-500'}`}>
vorher: {statusLabels[doc.previousStatus] || doc.previousStatus}
</span>
)}
<span className="text-sm text-slate-500">{new Date(doc.createdAt).toLocaleDateString('de-DE')}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{canManage && !doc.signatureData && !['INVOICE', 'ORDER_CONFIRMATION'].includes(doc.type) && !['CANCELLED', 'ARCHIVED'].includes(doc.status) && (
<button onClick={() => setShowSignature(true)} className="bg-amber-500 text-white px-4 py-2 rounded-lg hover:bg-amber-600 transition flex items-center gap-2 text-sm font-medium">
<PenTool className="w-4 h-4" /> Unterschreiben
</button>
)}
{canManage && actions.map(a => (
<button key={a.status} onClick={() => updateStatus(a.status)} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 text-sm font-medium">
<a.icon className="w-4 h-4" /> {a.label}
</button>
))}
{canCreateFollowUp && (
<button onClick={createFollowUp} className={`${doc.type === 'INVOICE' ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'} text-white px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm font-medium`}>
<FilePlus2 className="w-4 h-4" /> {{ ORDER_CONFIRMATION: 'Lieferschein erstellen', DELIVERY_NOTE: 'Rechnung erstellen', INVOICE: 'Rechnungskorrektur' }[doc.type]}
</button>
)}
<button onClick={() => window.print()} className="bg-slate-100 text-slate-700 px-4 py-2 rounded-lg hover:bg-slate-200 transition flex items-center gap-2 text-sm font-medium">
<Printer className="w-4 h-4" /> Drucken
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
{/* Document Body (print-friendly) */}
<div className="bg-white p-8 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 print:shadow-none print:border-none" id="print-area">
<div className="flex justify-between items-start mb-8">
<div>
<h2 className="text-xl font-bold text-slate-900">{typeLabels[doc.type]}</h2>
<p className="text-sm text-slate-500 mt-1">Nr. {doc.number}</p>
<p className="text-sm text-slate-500">Datum: {new Date(doc.createdAt).toLocaleDateString('de-DE')}</p>
{doc.validUntil && <p className="text-sm text-slate-500">Gültig bis: {new Date(doc.validUntil).toLocaleDateString('de-DE')}</p>}
</div>
<div className="text-right text-sm text-slate-600">
<p className="font-semibold">{doc.customer?.companyName}</p>
<p>{doc.customer?.firstName} {doc.customer?.lastName}</p>
<p>{doc.customer?.address}</p>
<p>{doc.customer?.zipCode} {doc.customer?.city}</p>
<p>{doc.customer?.email}</p>
</div>
</div>
<table className="w-full text-sm mb-6">
<thead>
<tr className="border-b-2 border-slate-200 text-slate-600">
<th className="text-left py-3 font-medium">Pos.</th>
<th className="text-left py-3 font-medium">Beschreibung</th>
<th className="text-right py-3 font-medium">Menge</th>
<th className="text-right py-3 font-medium">Einzelpreis</th>
<th className="text-right py-3 font-medium">Gesamt</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{doc.items?.map((item: any, idx: number) => (
<tr key={item.id}>
<td className="py-3 text-slate-400">{idx + 1}</td>
<td className="py-3 font-medium text-slate-800">{item.description}</td>
<td className="py-3 text-right text-slate-600">{item.quantity}</td>
<td className="py-3 text-right text-slate-600">{item.unitPrice.toFixed(2)} </td>
<td className="py-3 text-right font-semibold text-slate-800">{item.total.toFixed(2)} </td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-end">
<div className="w-64 space-y-2 text-sm">
<div className="flex justify-between text-slate-600"><span>Netto</span><span>{doc.subtotal.toFixed(2)} </span></div>
<div className="flex justify-between text-slate-600"><span>MwSt (19%)</span><span>{doc.taxAmount.toFixed(2)} </span></div>
<div className="flex justify-between text-lg font-bold text-slate-900 pt-2 border-t-2 border-slate-200"><span>Gesamt</span><span>{doc.total.toFixed(2)} </span></div>
</div>
</div>
{doc.notes && (
<div className="mt-6 p-4 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-xs font-semibold text-slate-500 mb-1">Notizen</p>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{doc.notes}</p>
</div>
)}
{doc.signatureData && (
<div className="mt-6 pt-4 border-t border-slate-200">
<p className="text-xs text-slate-500 mb-2">Digitale Unterschrift ({new Date(doc.signedAt).toLocaleDateString('de-DE')})</p>
<img src={doc.signatureData} alt="Unterschrift" className="h-16 border border-slate-200 rounded-lg bg-white" />
</div>
)}
</div>
</div>
{/* Sidebar Info */}
<div className="space-y-4">
<div className="bg-white p-5 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h3 className="text-sm font-semibold text-slate-700 mb-3">Details</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span className="text-slate-500">Erstellt von</span><span className="font-medium">{doc.createdBy?.firstName} {doc.createdBy?.lastName}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Typ</span><span className="font-medium">{typeLabels[doc.type]}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Positionen</span><span className="font-medium">{doc.items?.length}</span></div>
{doc.signedAt && <div className="flex justify-between"><span className="text-slate-500">Unterschrieben</span><span className="font-medium text-emerald-600"></span></div>}
</div>
</div>
{canManage && !['CANCELLED', 'PAID', 'ARCHIVED'].includes(doc.status) && (
<button onClick={async () => {
const ok = await confirm({ title: 'Stornieren', message: 'Beleg wirklich stornieren?', danger: true });
if (ok) updateStatus('CANCELLED');
}} className="w-full bg-red-50 text-red-600 py-2.5 rounded-xl font-medium hover:bg-red-100 transition text-sm">
Beleg stornieren
</button>
)}
{doc.status === 'ARCHIVED' && (
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 text-center">
<p className="text-xs text-slate-400 font-medium">Dieser Beleg wurde archiviert.</p>
</div>
)}
</div>
</div>
{/* Signature Modal */}
{showSignature && (
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4">
<div className="bg-white p-6 rounded-3xl shadow-2xl max-w-lg w-full">
<h2 className="text-lg font-bold text-slate-900 mb-4">Digitale Unterschrift</h2>
<p className="text-sm text-slate-500 mb-4">Bitte unterschreiben Sie im Feld unten.</p>
<canvas ref={canvasRef} width={460} height={200}
className="w-full border-2 border-slate-300 rounded-xl cursor-crosshair bg-white touch-none"
onMouseDown={startDraw} onMouseMove={draw} onMouseUp={endDraw} onMouseLeave={endDraw}
onTouchStart={startDraw} onTouchMove={draw} onTouchEnd={endDraw}
/>
<div className="flex justify-between mt-4">
<button onClick={clearSignature} className="text-sm text-slate-500 hover:text-slate-700 font-medium">Löschen</button>
<div className="flex gap-3">
<button onClick={() => setShowSignature(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-100 rounded-xl transition">Abbrechen</button>
<button onClick={saveSignature} className="bg-indigo-600 text-white px-6 py-2 rounded-xl font-medium hover:bg-indigo-700 transition">Unterschreiben</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
'use client';
import { useState, useEffect } from 'react';
import { ArrowLeft, Plus, Trash2, Search } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useToast } from '../../components/ToastProvider';
const docTypes = [
{ value: 'QUOTE', label: 'Angebot' },
{ value: 'ORDER_CONFIRMATION', label: 'Auftragsbestätigung' },
{ value: 'DELIVERY_NOTE', label: 'Lieferschein' },
{ value: 'INVOICE', label: 'Rechnung' },
];
export default function NewSalesDocPage() {
const router = useRouter();
const { toast } = useToast();
const [type, setType] = useState('QUOTE');
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerSearch, setCustomerSearch] = useState('');
const [customerResults, setCustomerResults] = useState<any[]>([]);
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
const [notes, setNotes] = useState('');
const [validUntil, setValidUntil] = useState('');
const [items, setItems] = useState<any[]>([{ description: '', quantity: 1, unitPrice: 0, productId: null }]);
const [products, setProducts] = useState<any[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts).catch(() => {});
}, []);
const searchCustomers = async (q: string) => {
setCustomerSearch(q);
if (q.length < 2) { setCustomerResults([]); return; }
const res = await fetch(`/api/customers/search?q=${encodeURIComponent(q)}`);
if (res.ok) setCustomerResults(await res.json());
};
const selectCustomer = (c: any) => {
setSelectedCustomer(c);
setCustomerId(c.id);
setCustomerSearch(c.companyName || `${c.firstName} ${c.lastName}`);
setCustomerResults([]);
};
const updateItem = (idx: number, field: string, value: any) => {
const newItems = [...items];
newItems[idx] = { ...newItems[idx], [field]: value };
// Auto-fill price from product
if (field === 'productId' && value) {
const prod = products.find(p => p.id === parseInt(value));
if (prod) {
newItems[idx].unitPrice = prod.salePrice;
newItems[idx].description = prod.name;
}
}
setItems(newItems);
};
const addItem = () => setItems([...items, { description: '', quantity: 1, unitPrice: 0, productId: null }]);
const removeItem = (idx: number) => setItems(items.filter((_, i) => i !== idx));
const subtotal = items.reduce((sum, i) => sum + (i.quantity * i.unitPrice), 0);
const tax = subtotal * 0.19;
const total = subtotal + tax;
const handleSubmit = async () => {
if (!customerId) { toast('Bitte Kunde auswählen', 'error'); return; }
if (items.length === 0 || !items[0].description) { toast('Bitte mindestens eine Position', 'error'); return; }
setSaving(true);
const res = await fetch('/api/sales', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type, customerId, notes, validUntil: validUntil || null,
items: items.map(i => ({
description: i.description, quantity: parseFloat(i.quantity) || 1,
unitPrice: parseFloat(i.unitPrice) || 0,
productId: i.productId ? parseInt(i.productId) : null
}))
})
});
if (res.ok) {
const doc = await res.json();
toast('Beleg erstellt', 'success');
router.push(`/sales/${doc.id}`);
} else {
toast('Fehler beim Erstellen', 'error');
}
setSaving(false);
};
return (
<div className="max-w-5xl mx-auto space-y-6 pb-12 animate-fade-in-up">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/sales')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-2xl font-bold text-slate-900">Neuen Beleg erstellen</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Details */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h2 className="font-semibold text-slate-800 mb-4">Belegdaten</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Belegart *</label>
<select className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white" value={type} onChange={e => setType(e.target.value)}>
{docTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Gültig bis</label>
<input type="date" className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={validUntil} onChange={e => setValidUntil(e.target.value)} />
</div>
<div className="col-span-2 relative">
<label className="block text-sm font-medium text-slate-700 mb-1">Kunde *</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" placeholder="Kunde suchen..." className="w-full pl-9 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none" value={customerSearch} onChange={e => searchCustomers(e.target.value)} />
</div>
{customerResults.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto">
{customerResults.map(c => (
<button key={c.id} onClick={() => selectCustomer(c)} className="w-full text-left px-4 py-3 hover:bg-indigo-50 transition text-sm">
<span className="font-semibold">{c.companyName || `${c.firstName} ${c.lastName}`}</span>
<span className="text-slate-400 ml-2">{c.email}</span>
</button>
))}
</div>
)}
{selectedCustomer && <p className="text-xs text-emerald-600 mt-1 font-medium"> {selectedCustomer.companyName || `${selectedCustomer.firstName} ${selectedCustomer.lastName}`}</p>}
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Notizen</label>
<textarea rows={2} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none resize-none" value={notes} onChange={e => setNotes(e.target.value)} />
</div>
</div>
</div>
{/* Positions */}
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-slate-800">Positionen</h2>
<button type="button" onClick={addItem} className="text-indigo-600 hover:text-indigo-800 transition flex items-center gap-1 text-sm font-medium">
<Plus className="w-4 h-4" /> Position
</button>
</div>
<div className="space-y-3">
{items.map((item, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-end p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="col-span-3">
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Produkt</label>
<select className="w-full border border-slate-300 p-2 rounded-lg text-sm bg-white" value={item.productId || ''} onChange={e => updateItem(idx, 'productId', e.target.value || null)}>
<option value="">Freitext</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="col-span-4">
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Beschreibung *</label>
<input type="text" required className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.description} onChange={e => updateItem(idx, 'description', e.target.value)} />
</div>
<div className="col-span-2">
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Menge</label>
<input type="number" step="0.01" min="0.01" className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.quantity} onChange={e => updateItem(idx, 'quantity', e.target.value)} />
</div>
<div className="col-span-2">
<label className="block text-[10px] font-medium text-slate-500 mb-0.5">Einzelpreis </label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2 rounded-lg text-sm" value={item.unitPrice} onChange={e => updateItem(idx, 'unitPrice', e.target.value)} />
</div>
<div className="col-span-1 flex justify-center">
{items.length > 1 && (
<button type="button" onClick={() => removeItem(idx)} className="p-2 text-slate-400 hover:text-red-600 rounded-lg transition">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Right: Summary */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 sticky top-6">
<h2 className="font-semibold text-slate-800 mb-4">Zusammenfassung</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-slate-600">
<span>Netto</span><span className="font-semibold">{subtotal.toFixed(2)} </span>
</div>
<div className="flex justify-between text-slate-600">
<span>MwSt (19%)</span><span className="font-semibold">{tax.toFixed(2)} </span>
</div>
<div className="flex justify-between text-lg font-bold text-slate-900 pt-3 border-t border-slate-200">
<span>Gesamt</span><span>{total.toFixed(2)} </span>
</div>
<div className="text-xs text-slate-400">{items.filter(i => i.description).length} Position(en)</div>
</div>
<button onClick={handleSubmit} disabled={saving}
className="w-full mt-6 bg-indigo-600 text-white py-3 rounded-xl font-bold hover:bg-indigo-700 transition disabled:opacity-50">
{saving ? 'Erstelle...' : 'Beleg erstellen'}
</button>
</div>
</div>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { FileText, Plus, Search, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
const typeLabels: Record<string, string> = {
QUOTE: 'Angebot', ORDER_CONFIRMATION: 'Auftragsbestätigung',
DELIVERY_NOTE: 'Lieferschein', INVOICE: 'Rechnung', CREDIT_NOTE: 'Rechnungskorrektur'
};
const typeColors: Record<string, string> = {
QUOTE: 'bg-blue-50 text-blue-700 border-blue-200',
ORDER_CONFIRMATION: 'bg-indigo-50 text-indigo-700 border-indigo-200',
DELIVERY_NOTE: 'bg-amber-50 text-amber-700 border-amber-200',
INVOICE: 'bg-emerald-50 text-emerald-700 border-emerald-200',
CREDIT_NOTE: 'bg-red-50 text-red-700 border-red-200'
};
const statusLabels: Record<string, string> = {
DRAFT: 'Entwurf', SENT: 'Gesendet', ACCEPTED: 'Angenommen',
REJECTED: 'Abgelehnt', DELIVERED: 'Geliefert', PAID: 'Bezahlt',
CANCELLED: 'Storniert', ARCHIVED: 'Archiviert'
};
const statusColors: Record<string, string> = {
DRAFT: 'bg-slate-100 text-slate-600', SENT: 'bg-blue-100 text-blue-700',
ACCEPTED: 'bg-emerald-100 text-emerald-700', REJECTED: 'bg-red-100 text-red-700',
DELIVERED: 'bg-amber-100 text-amber-700', PAID: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-slate-200 text-slate-500', ARCHIVED: 'bg-slate-100 text-slate-400'
};
export default function SalesPage() {
const [docs, setDocs] = useState<any[]>([]);
const [filter, setFilter] = useState('ACTIVE');
const [search, setSearch] = useState('');
const router = useRouter();
const { data: session } = useSession();
const perms = (session?.user as any)?.permissions || [];
const canCreate = perms.includes('SALES_MANAGE');
const debounceRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { fetchDocs(); }, []);
const fetchDocs = async () => {
const res = await fetch('/api/sales');
if (res.ok) setDocs(await res.json());
};
// Filter + Search logic
const filtered = docs.filter(d => {
// Type filter
if (filter === 'ACTIVE' && (d.status === 'ARCHIVED' || d.status === 'CANCELLED')) return false;
if (filter === 'ARCHIVED' && d.status !== 'ARCHIVED') return false;
if (filter !== 'ALL' && filter !== 'ACTIVE' && filter !== 'ARCHIVED' && d.type !== filter) return false;
// Search
if (search.trim()) {
const q = search.toLowerCase();
const customerName = d.customer?.companyName || `${d.customer?.firstName} ${d.customer?.lastName}`;
return (
d.number.toLowerCase().includes(q) ||
customerName.toLowerCase().includes(q) ||
(typeLabels[d.type] || '').toLowerCase().includes(q) ||
(statusLabels[d.status] || '').toLowerCase().includes(q)
);
}
return true;
});
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<FileText className="w-6 h-6 text-indigo-600" /> Verkauf
</h1>
<p className="text-slate-500 mt-1">Angebote, Auftragsbestätigungen, Lieferscheine & Rechnungen.</p>
</div>
<div className="flex items-center gap-3">
{/* Live Search */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input type="text" placeholder="Nr., Kunde, Typ suchen..." value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9 pr-8 py-2 border border-slate-300 rounded-xl text-sm outline-none focus-ring w-64" />
{search && (
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
<X className="w-4 h-4" />
</button>
)}
</div>
{canCreate && (
<button onClick={() => router.push('/sales/new')} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-medium shadow-sm">
<Plus className="w-4 h-4" /> Neuer Beleg
</button>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
{[
{ key: 'ACTIVE', label: 'Aktiv' },
{ key: 'ALL', label: 'Alle' },
{ key: 'QUOTE', label: 'Angebote' },
{ key: 'ORDER_CONFIRMATION', label: 'ABs' },
{ key: 'DELIVERY_NOTE', label: 'Lieferscheine' },
{ key: 'INVOICE', label: 'Rechnungen' },
{ key: 'CREDIT_NOTE', label: 'Korrekturen' },
{ key: 'ARCHIVED', label: 'Archiv' }
].map(f => (
<button key={f.key} onClick={() => setFilter(f.key)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${filter === f.key ? 'bg-indigo-600 text-white' : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'}`}>
{f.label}
</button>
))}
</div>
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th className="py-4 px-6">Nummer</th>
<th className="py-4 px-6">Typ</th>
<th className="py-4 px-6">Kunde</th>
<th className="py-4 px-6">Status</th>
<th className="py-4 px-6 text-right">Betrag</th>
<th className="py-4 px-6">Datum</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map(d => (
<tr key={d.id} onClick={() => router.push(`/sales/${d.id}`)}
className={`hover:bg-indigo-50/40 transition-colors cursor-pointer ${d.status === 'ARCHIVED' ? 'opacity-60' : ''}`}>
<td className="py-4 px-6 font-mono font-semibold text-slate-900">{d.number}</td>
<td className="py-4 px-6">
<span className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${typeColors[d.type]}`}>{typeLabels[d.type]}</span>
</td>
<td className="py-4 px-6 text-slate-700">{d.customer?.companyName || `${d.customer?.firstName} ${d.customer?.lastName}`}</td>
<td className="py-4 px-6">
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${statusColors[d.status]}`}>{statusLabels[d.status]}</span>
</td>
<td className="py-4 px-6 text-right font-semibold text-slate-900">{d.total.toFixed(2)} </td>
<td className="py-4 px-6 text-slate-500">{new Date(d.createdAt).toLocaleDateString('de-DE')}</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={6} className="py-8 text-center text-slate-500">
{search ? `Keine Ergebnisse für "${search}"` : 'Keine Belege vorhanden.'}
</td></tr>
)}
</tbody>
</table>
{filtered.length > 0 && (
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 text-xs text-slate-500">
{filtered.length} Beleg{filtered.length !== 1 ? 'e' : ''} angezeigt
</div>
)}
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
// /opt/erp-system/app/search/page.tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import Link from 'next/link';
import { Search, Ticket, Users } from 'lucide-react';
import { getStatusBadge } from '../components/AppShell';
function SearchResults() {
const searchParams = useSearchParams();
const q = searchParams.get('q');
const [results, setResults] = useState({ tickets: [], customers: [] });
const [loading, setLoading] = useState(false);
useEffect(() => {
if (q) {
setLoading(true);
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
});
}
}, [q]);
if (!q) return <div className="text-slate-500 font-medium">Bitte gib einen Suchbegriff ein.</div>;
return (
<div className="max-w-5xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Search className="w-6 h-6 text-indigo-600" /> Suchergebnisse für "{q}"
</h1>
{loading ? (
<div className="text-slate-500 font-medium animate-pulse">Suche läuft...</div>
) : (
<div className="space-y-8">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-4 border-b border-slate-200 bg-slate-50 flex items-center gap-2 font-semibold text-slate-800">
<Users className="w-5 h-5 text-slate-500" /> Kunden ({results.customers?.length || 0})
</div>
<div className="divide-y divide-slate-100">
{results.customers?.length > 0 ? results.customers.map((c: any) => (
<Link key={c.id} href={`/customers/${c.id}`} className="block p-4 hover:bg-slate-50 transition">
<p className="font-semibold text-indigo-600">{c.companyName || `${c.firstName} ${c.lastName}`}</p>
<p className="text-sm text-slate-500">{c.email}</p>
</Link>
)) : (
<div className="p-4 text-sm text-slate-500">Keine Kunden gefunden.</div>
)}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-4 border-b border-slate-200 bg-slate-50 flex items-center gap-2 font-semibold text-slate-800">
<Ticket className="w-5 h-5 text-slate-500" /> Tickets ({results.tickets?.length || 0})
</div>
<div className="divide-y divide-slate-100">
{results.tickets?.length > 0 ? results.tickets.map((t: any) => (
<Link key={t.id} href={`/tickets/${t.id}`} className="block p-4 hover:bg-slate-50 transition flex justify-between items-start">
<div>
<p className="font-semibold text-indigo-600">#{t.id} - {t.title}</p>
<p className="text-sm text-slate-500 mt-1 line-clamp-1">{t.description}</p>
<p className="text-xs text-slate-400 mt-1">
Kunde: {t.customer.companyName || `${t.customer.firstName} ${t.customer.lastName}`}
</p>
</div>
<div className="ml-4 shrink-0">
{getStatusBadge(t.status)}
</div>
</Link>
)) : (
<div className="p-4 text-sm text-slate-500">Keine Tickets gefunden.</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="text-slate-500">Lade Suche...</div>}>
<SearchResults />
</Suspense>
);
}
+178
View File
@@ -0,0 +1,178 @@
// /opt/erp-system/app/settings/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Settings, Save, Percent, Euro, Mail, Inbox, Hash } from 'lucide-react';
export default function SettingsPage() {
const [data, setData] = useState({
hourlyRate: 0, taxRate: 0, companyName: '', companyInfo: '',
smtpHost: '', smtpPort: 587, smtpUser: '', smtpPass: '', smtpFrom: '', hasSmtpPass: false,
imapHost: '', imapPort: 993, imapUser: '', imapPass: '', hasImapPass: false,
nextQuoteNumber: 1, nextOrderNumber: 1, nextDeliveryNumber: 1, nextInvoiceNumber: 1, nextCreditNoteNumber: 1,
defaultQuoteValidityDays: 14
});
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState('');
useEffect(() => {
fetch('/api/settings').then(res => res.json()).then(d => {
setData({
hourlyRate: d.hourlyRate || 0, taxRate: d.taxRate || 0, companyName: d.companyName || '', companyInfo: d.companyInfo || '',
smtpHost: d.smtpHost || '', smtpPort: d.smtpPort || 587, smtpUser: d.smtpUser || '', smtpPass: '', smtpFrom: d.smtpFrom || '', hasSmtpPass: d.hasSmtpPass,
imapHost: d.imapHost || '', imapPort: d.imapPort || 993, imapUser: d.imapUser || '', imapPass: '', hasImapPass: d.hasImapPass,
nextQuoteNumber: d.nextQuoteNumber || 1, nextOrderNumber: d.nextOrderNumber || 1, nextDeliveryNumber: d.nextDeliveryNumber || 1, nextInvoiceNumber: d.nextInvoiceNumber || 1, nextCreditNoteNumber: d.nextCreditNoteNumber || 1,
defaultQuoteValidityDays: d.defaultQuoteValidityDays || 14
});
setLoading(false);
});
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
setMsg('Einstellungen erfolgreich gespeichert.');
setTimeout(() => setMsg(''), 3000);
setData(prev => ({
...prev,
smtpPass: '', hasSmtpPass: prev.smtpPass ? true : prev.hasSmtpPass,
imapPass: '', hasImapPass: prev.imapPass ? true : prev.hasImapPass
}));
}
};
if (loading) return <div className="p-8">Lade Konfiguration...</div>;
return (
<div className="max-w-4xl mx-auto space-y-6 pb-12">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Settings className="w-6 h-6 text-indigo-600" /> Systemeinstellungen
</h1>
{msg && <div className="p-4 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-lg font-medium">{msg}</div>}
<form onSubmit={handleSave} className="bg-white rounded-xl shadow-sm border border-slate-200 divide-y divide-slate-100">
{/* Abrechnung */}
<div className="p-6 space-y-4">
<h2 className="font-semibold text-slate-800">Abrechnungsparameter</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-2">
<Euro className="w-4 h-4" /> Standard-Stundensatz
</label>
<input type="number" step="0.01" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.hourlyRate} onChange={e => setData({...data, hourlyRate: parseFloat(e.target.value)})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1 flex items-center gap-2">
<Percent className="w-4 h-4" /> Mehrwertsteuer (%)
</label>
<input type="number" step="0.1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.taxRate} onChange={e => setData({...data, taxRate: parseFloat(e.target.value)})} />
</div>
</div>
</div>
{/* SMTP */}
<div className="p-6 space-y-4">
<h2 className="font-semibold text-slate-800 flex items-center gap-2">
<Mail className="w-5 h-5" /> Ausgehende E-Mails (SMTP)
</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-3">
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server (Host)</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.smtpHost} onChange={e => setData({...data, smtpHost: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Port</label>
<input type="number" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.smtpPort} onChange={e => setData({...data, smtpPort: parseInt(e.target.value)})} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Benutzername</label>
<input type="text" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.smtpUser} onChange={e => setData({...data, smtpUser: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Passwort</label>
<input type="password" placeholder={data.hasSmtpPass ? "****** (Gespeichert)" : "Passwort eingeben"} className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.smtpPass} onChange={e => setData({...data, smtpPass: e.target.value})} />
</div>
</div>
</div>
{/* IMAP */}
<div className="p-6 space-y-4">
<h2 className="font-semibold text-slate-800 flex items-center gap-2">
<Inbox className="w-5 h-5" /> Eingehende E-Mails (IMAP)
</h2>
<p className="text-sm text-slate-500">Diese E-Mail-Adresse wird überwacht, um automatisch Tickets aus eingehenden E-Mails zu erstellen.</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-3">
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server (Host)</label>
<input type="text" placeholder="z.B. imap.strato.de" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.imapHost} onChange={e => setData({...data, imapHost: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Port</label>
<input type="number" placeholder="993" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.imapPort} onChange={e => setData({...data, imapPort: parseInt(e.target.value)})} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Benutzername</label>
<input type="text" placeholder="z.B. support@deine-firma.de" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.imapUser} onChange={e => setData({...data, imapUser: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Passwort</label>
<input type="password" placeholder={data.hasImapPass ? "****** (Gespeichert)" : "Passwort eingeben"} className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.imapPass} onChange={e => setData({...data, imapPass: e.target.value})} />
</div>
</div>
</div>
{/* Nummernkreise */}
<div className="p-6 space-y-4">
<h2 className="font-semibold text-slate-800 flex items-center gap-2"><Hash className="w-4 h-4 text-indigo-600" /> Nummernkreise (Warenwirtschaft)</h2>
<p className="text-sm text-slate-500">Laufende Nummern für Belege. Format: <span className="font-mono bg-slate-100 px-1 rounded">ANG-{new Date().getFullYear()}-{data.nextQuoteNumber.toString().padStart(4, '0')}</span></p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Angebot (ANG)</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.nextQuoteNumber} onChange={e => setData({...data, nextQuoteNumber: parseInt(e.target.value) || 1})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AB</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.nextOrderNumber} onChange={e => setData({...data, nextOrderNumber: parseInt(e.target.value) || 1})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Lieferschein (LS)</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.nextDeliveryNumber} onChange={e => setData({...data, nextDeliveryNumber: parseInt(e.target.value) || 1})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rechnung (RE)</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.nextInvoiceNumber} onChange={e => setData({...data, nextInvoiceNumber: parseInt(e.target.value) || 1})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Korrektur (RK)</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.nextCreditNoteNumber} onChange={e => setData({...data, nextCreditNoteNumber: parseInt(e.target.value) || 1})} />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-2">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Standard-Gültigkeit Angebot (Tage)</label>
<input type="number" min="1" className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" value={data.defaultQuoteValidityDays} onChange={e => setData({...data, defaultQuoteValidityDays: parseInt(e.target.value) || 14})} />
</div>
</div>
</div>
<div className="p-6 bg-slate-50 flex justify-end">
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-bold shadow-sm">
<Save className="w-4 h-4" /> Alle Einstellungen speichern
</button>
</div>
</form>
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
// /opt/erp-system/app/tickets/[id]/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useSession } from "next-auth/react";
import { Send, CheckCircle, Clock, ArrowLeft, StickyNote, Activity, User as UserIcon, Paperclip, FileText, Download, AlertTriangle } from 'lucide-react';
import { useToast } from '../../components/ToastProvider';
import { getStatusBadge, getPriorityBadge } from '../../components/AppShell';
export default function TicketDetailPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const ticketId = params.id;
const { toast } = useToast();
const [ticket, setTicket] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [attachments, setAttachments] = useState<any[]>([]);
const [error, setError] = useState(false);
const [messageInput, setMessageInput] = useState('');
const [uploading, setUploading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<'addTime' | 'close' | 'addNote'>('addTime');
const [modalData, setModalData] = useState({ durationMins: 15, description: '', content: '' });
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ticketId) {
fetchTicket();
fetchAttachments();
if ((session?.user as any)?.userType === 'TEAM') {
fetchUsers();
}
}
}, [ticketId, session]);
const fetchTicket = async () => {
try {
const res = await fetch(`/api/tickets/${ticketId}`);
if (res.ok) setTicket(await res.json());
else setError(true);
} catch (err) {
setError(true);
}
};
const fetchUsers = async () => {
const res = await fetch('/api/users');
if (res.ok) setUsers(await res.json());
};
const fetchAttachments = async () => {
const res = await fetch(`/api/tickets/${ticketId}/attachments`);
if (res.ok) setAttachments(await res.json());
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!messageInput.trim()) return;
await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'addMessage', content: messageInput })
});
setMessageInput('');
fetchTicket();
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
method: 'POST',
body: formData
});
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
if (res.ok) {
fetchAttachments();
fetchTicket();
} else {
toast('Fehler beim Hochladen der Datei.', 'error');
}
};
const handleAssign = async (userId: string) => {
await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'assign', userId })
});
fetchTicket();
};
const handleChangePriority = async (priority: string) => {
await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'changePriority', priority })
});
fetchTicket();
};
const handleModalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let payload = {};
if (modalMode === 'addNote') {
payload = { action: 'addNote', content: modalData.content };
} else {
const action = modalMode === 'close' ? 'closeTicket' : 'addTimeEntry';
payload = { action, durationMins: modalData.durationMins, description: modalData.description };
}
const res = await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
setShowModal(false);
setModalData({ durationMins: 15, description: '', content: '' });
fetchTicket();
}
};
const openModal = (mode: 'addTime' | 'close' | 'addNote') => {
setModalMode(mode);
setShowModal(true);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (error) return <div className="p-8 text-red-600 font-medium">Fehler: Ticket konnte nicht geladen werden.</div>;
if (!ticket) return <div className="p-8 text-slate-500 font-medium animate-pulse">Lade Ticketdaten...</div>;
const isClosed = ticket.status === 'RESOLVED' || ticket.status === 'CLOSED';
const userType = (session?.user as any)?.userType;
const timelineItems = [
...(ticket.timeEntries || []).map((entry: any) => ({ ...entry, type: 'time' })),
...(ticket.notes || []).map((note: any) => ({ ...note, type: 'note' }))
].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div className="max-w-7xl mx-auto space-y-6 relative pb-12 animate-fade-in-up">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/')} className="p-2 hover:bg-slate-200 rounded-lg text-slate-600 transition">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-1">#{ticket.id}: {ticket.title}</h1>
<div className="flex items-center gap-3 mt-2">
{getStatusBadge(ticket.status)}
{getPriorityBadge(ticket.priority)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h3 className="font-semibold text-slate-800 mb-2">Beschreibung</h3>
<p className="text-slate-600 whitespace-pre-wrap">{ticket.description}</p>
</div>
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 flex flex-col h-[500px]">
<div className="p-4 border-b border-slate-200 font-semibold text-slate-800">Nachrichtenverlauf</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4 bg-slate-50">
{ticket.messages.length === 0 ? (
<p className="text-center text-slate-400 mt-4">Noch keine Nachrichten.</p>
) : (
ticket.messages.map((msg: any) => (
<div key={msg.id} className={`flex ${msg.isFromCustomer ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[75%] p-3 rounded-lg ${msg.isFromCustomer ? 'bg-indigo-600 text-white shadow-sm' : 'bg-white border border-slate-200 text-slate-800'}`}>
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
<span className={`text-[10px] mt-1 block ${msg.isFromCustomer ? 'text-indigo-200' : 'text-slate-400'}`}>
{new Date(msg.createdAt).toLocaleString('de-DE')}
</span>
</div>
</div>
))
)}
</div>
{!isClosed && (
<div className="p-4 border-t border-slate-200 bg-white">
<form onSubmit={handleSendMessage} className="flex gap-2">
<input type="text" value={messageInput} onChange={(e) => setMessageInput(e.target.value)} placeholder="Antwort schreiben..." className="flex-1 border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" />
<input type="file" className="hidden" ref={fileInputRef} onChange={handleFileUpload} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading} className="bg-slate-100 text-slate-600 p-2.5 rounded-lg hover:bg-slate-200 transition">
<Paperclip className="w-5 h-5" />
</button>
<button type="submit" className="bg-indigo-600 text-white p-2.5 rounded-lg hover:bg-indigo-700 transition">
<Send className="w-5 h-5" />
</button>
</form>
</div>
)}
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h3 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<Paperclip className="w-5 h-5 text-slate-500" /> Anhänge ({attachments.length})
</h3>
{attachments.length > 0 ? (
<div className="space-y-2">
{attachments.map(att => (
<div key={att.id} className="flex items-center justify-between p-2 hover:bg-slate-50 rounded-lg border border-slate-200 group">
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="truncate">
<p className="text-sm font-medium text-slate-700 truncate">{att.fileName}</p>
<p className="text-[10px] text-slate-400">{formatBytes(att.fileSize)}</p>
</div>
</div>
<a href={`/api/tickets/${ticketId}/attachments?download=${att.id}`} download className="p-1.5 text-slate-500 hover:text-indigo-600 transition">
<Download className="w-4 h-4" />
</a>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">Keine Dateien vorhanden.</p>
)}
</div>
{userType === 'TEAM' && (
<>
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 space-y-4">
<div>
<h3 className="font-semibold text-slate-800 mb-2 flex items-center gap-2">
<UserIcon className="w-4 h-4 text-slate-400" /> Bearbeiter
</h3>
<select className="w-full border border-slate-300 p-2 rounded-lg text-sm bg-white" value={ticket.assignedToId || ''} onChange={(e) => handleAssign(e.target.value)} disabled={isClosed}>
<option value="">Nicht zugewiesen</option>
{users.map(u => <option key={u.id} value={u.id}>{u.firstName} {u.lastName}</option>)}
</select>
</div>
<div>
<h3 className="font-semibold text-slate-800 mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-slate-400" /> Priorität
</h3>
<select className="w-full border border-slate-300 p-2 rounded-lg text-sm bg-white" value={ticket.priority} onChange={(e) => handleChangePriority(e.target.value)} disabled={isClosed}>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
<option value="CRITICAL">Kritisch</option>
</select>
</div>
</div>
{!isClosed && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h3 className="font-semibold text-slate-800 mb-4">Aktionen</h3>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<button onClick={() => openModal('addTime')} className="bg-indigo-50 text-indigo-700 border border-indigo-200 px-4 py-2.5 rounded-lg text-sm font-medium"><Clock className="w-4 h-4" /> Zeit buchen</button>
<button onClick={() => openModal('addNote')} className="bg-slate-50 text-slate-700 border border-slate-200 px-4 py-2.5 rounded-lg text-sm font-medium"><StickyNote className="w-4 h-4" /> Int. Notiz</button>
</div>
<button onClick={() => openModal('close')} className="w-full bg-emerald-600 text-white px-4 py-2.5 rounded-lg text-sm font-medium shadow-sm"><CheckCircle className="w-4 h-4" /> Ticket abschließen</button>
</div>
</div>
)}
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100">
<h3 className="font-semibold text-slate-800 mb-4 flex items-center gap-2"><Activity className="w-5 h-5 text-slate-500" /> Interne Chronik</h3>
<div className="space-y-6">
{timelineItems.map((item: any) => (
<div key={`${item.type}-${item.id}`} className={`border-l-2 pl-4 relative ${item.type === 'time' ? 'border-amber-400' : 'border-indigo-400'}`}>
<p className="font-semibold text-slate-800 text-sm">{item.type === 'time' ? `Zeit: ${item.durationMins} Min.` : 'Interne Notiz'}</p>
<p className="text-sm text-slate-600 mt-1">{item.type === 'time' ? item.description : item.content}</p>
<div className="text-[10px] text-slate-400 mt-1">{new Date(item.createdAt).toLocaleString('de-DE')} {item.user?.firstName}</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
{showModal && (
<div className="fixed inset-0 bg-slate-900/60 flex items-center justify-center z-50 backdrop-blur-sm p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<h2 className="text-xl font-bold text-slate-900 mb-2">{modalMode === 'addNote' ? 'Interne Notiz' : 'Zeit erfassen'}</h2>
<form onSubmit={handleModalSubmit} className="space-y-4 mt-6">
{modalMode !== 'addNote' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Dauer (Min.) *</label>
<input type="number" step="15" required value={modalData.durationMins} onChange={(e) => setModalData({...modalData, durationMins: parseInt(e.target.value)})} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" />
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt *</label>
{/* HIER WAR DER FEHLER: setModalData statt setFormData */}
<textarea required value={modalMode === 'addNote' ? modalData.content : modalData.description} onChange={(e) => setModalData(modalMode === 'addNote' ? {...modalData, content: e.target.value} : {...modalData, description: e.target.value})} className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none h-24 resize-none transition-all" />
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2.5 text-slate-600">Abbrechen</button>
<button type="submit" className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg">Speichern</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
// /opt/erp-system/app/tickets/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSession } from "next-auth/react";
import { Ticket as TicketIcon, Plus, X } from 'lucide-react';
import { getStatusBadge, getPriorityBadge } from '../components/AppShell';
import { useToast } from '../components/ToastProvider';
export default function TicketsPage() {
const { data: session } = useSession();
const [tickets, setTickets] = useState<any[]>([]);
const [customers, setCustomers] = useState<any[]>([]);
const [filter, setFilter] = useState<'ALL' | 'MINE' | 'OPEN'>('ALL');
// States für das Formular
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({ title: '', description: '', customerId: '', priority: 'MEDIUM' });
const { toast } = useToast();
useEffect(() => {
fetchTickets();
fetchCustomers();
}, []);
const fetchTickets = async () => {
const res = await fetch('/api/tickets');
if (res.ok) setTickets(await res.json());
};
const fetchCustomers = async () => {
const res = await fetch('/api/customers');
if (res.ok) {
const data = await res.json();
setCustomers(data);
if (data.length > 0) {
setFormData(prev => ({ ...prev, customerId: data[0].id.toString() }));
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
setShowForm(false);
setFormData({ title: '', description: '', customerId: customers[0]?.id.toString() || '', priority: 'MEDIUM' });
fetchTickets();
toast('Ticket erfolgreich erstellt', 'success');
} else {
toast('Fehler beim Erstellen des Tickets', 'error');
}
};
// getStatusBadge is imported from AppShell
// Filter-Logik
const filteredTickets = tickets.filter(t => {
if (filter === 'OPEN') return t.status === 'OPEN' || t.status === 'IN_PROGRESS';
if (filter === 'MINE') return t.assignedToId === parseInt((session?.user as any)?.id);
return true;
});
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<TicketIcon className="w-6 h-6 text-indigo-600" /> Ticketsystem
</h1>
<button onClick={() => setShowForm(!showForm)} className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium shadow-sm flex items-center gap-2 hover:bg-indigo-700 transition">
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />} {showForm ? 'Abbrechen' : 'Neues Ticket'}
</button>
</div>
{/* Neues Ticket Formular */}
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4">
<h2 className="text-lg font-semibold text-slate-800 mb-4">Neues Ticket erfassen</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.title} onChange={e => setFormData({...formData, title: e.target.value})} placeholder="Kurze Beschreibung des Problems..." />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Kunde *</label>
<select required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white transition-all" value={formData.customerId} onChange={e => setFormData({...formData, customerId: e.target.value})}>
{customers.map(c => (
<option key={c.id} value={c.id}>{c.companyName || `${c.firstName} ${c.lastName}`}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Priorität *</label>
<select required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white transition-all" value={formData.priority} onChange={e => setFormData({...formData, priority: e.target.value})}>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
<option value="CRITICAL">Kritisch</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Details *</label>
<textarea required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none h-32 resize-none transition-all" value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} placeholder="Ausführliche Problembeschreibung..." />
</div>
</div>
<div className="flex justify-end pt-2">
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">Ticket erstellen</button>
</div>
</form>
</div>
)}
{/* Filter Tabs */}
<div className="flex border-b border-slate-200 gap-6">
{[
{ id: 'ALL', label: 'Alle Tickets' },
{ id: 'MINE', label: 'Meine Tickets' },
{ id: 'OPEN', label: 'Nur Offene' }
].map(tab => (
<button
key={tab.id}
onClick={() => setFilter(tab.id as any)}
className={`pb-3 text-sm font-medium transition-colors relative ${filter === tab.id ? 'text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
{tab.label}
{filter === tab.id && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600 animate-in fade-in duration-300"></div>}
</button>
))}
</div>
{/* Datentabelle */}
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th className="py-4 px-6">ID</th>
<th className="py-4 px-6">Titel & Kunde</th>
<th className="py-4 px-6">Bearbeiter</th>
<th className="py-4 px-6">Status & Prio</th>
<th className="py-4 px-6 text-right">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredTickets.map(t => (
<tr key={t.id} className="hover:bg-slate-50/80 transition-colors border-b border-slate-50 last:border-0 group">
<td className="py-4 px-6 font-mono text-slate-400">#{t.id.toString().padStart(5, '0')}</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-semibold text-slate-900">{t.title}</span>
<span className="text-slate-500 text-xs">{t.customer.companyName || `${t.customer.firstName} ${t.customer.lastName}`}</span>
</div>
</td>
<td className="py-4 px-6">
{t.assignedTo ? (
<span className="flex items-center gap-1.5 text-slate-700">
<div className="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center text-[10px] font-bold text-indigo-700 border border-indigo-200">
{t.assignedTo.firstName[0]}
</div>
{t.assignedTo.firstName} {t.assignedTo.lastName}
</span>
) : <span className="text-slate-400 italic text-xs">Unzugewiesen</span>}
</td>
<td className="py-4 px-6">
<div className="flex flex-col gap-2 items-start">
{getStatusBadge(t.status)}
{getPriorityBadge(t.priority)}
</div>
</td>
<td className="py-4 px-6 text-right">
<a href={`/tickets/${t.id}`} className="text-indigo-600 font-medium hover:text-indigo-800">Akte öffnen</a>
</td>
</tr>
))}
{filteredTickets.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">Keine Tickets in dieser Ansicht gefunden.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
// /opt/erp-system/app/users/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Users, Plus, X, Mail, ShieldAlert, Edit2, Trash2 } from 'lucide-react';
import { useToast } from '../components/ToastProvider';
import { useSession } from 'next-auth/react';
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const { toast, confirm } = useToast();
const { data: session } = useSession();
const permissions = (session?.user as any)?.permissions || [];
const canDelete = permissions.includes('DATA_DELETE');
const [formData, setFormData] = useState({
firstName: '', lastName: '', email: '', password: '', roleId: ''
});
useEffect(() => {
fetchRoles();
fetchUsers();
}, []);
const fetchUsers = async () => {
const res = await fetch('/api/users');
if (res.ok) setUsers(await res.json());
};
const fetchRoles = async () => {
const res = await fetch('/api/roles');
if (res.ok) {
const data = await res.json();
setRoles(data);
if (data.length > 0 && !editingId) {
setFormData(prev => ({ ...prev, roleId: data[0].id.toString() }));
}
}
};
const handleCreateNew = () => {
setEditingId(null);
setFormData({
firstName: '', lastName: '', email: '', password: '',
roleId: roles.length > 0 ? roles[0].id.toString() : ''
});
setShowForm(!showForm);
};
const handleEdit = (user: any) => {
setEditingId(user.id);
setFormData({
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
password: '', // Passwort wird aus Sicherheitsgründen nie geladen
roleId: user.roleId ? user.roleId.toString() : (roles[0]?.id.toString() || '')
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const method = editingId ? 'PUT' : 'POST';
const payload = editingId ? { id: editingId, ...formData } : formData;
const res = await fetch('/api/users', {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
setShowForm(false);
fetchUsers();
toast(editingId ? 'Mitarbeiter erfolgreich aktualisiert' : 'Mitarbeiter erfolgreich angelegt', 'success');
} else {
const data = await res.json();
toast(data.error || 'Fehler beim Speichern', 'error');
}
};
const handleDelete = async (user: any) => {
const isConfirmed = await confirm({
title: 'Mitarbeiter löschen',
message: `"${user.firstName} ${user.lastName}" wirklich löschen? Zugewiesene Tickets werden freigestellt.`,
danger: true
});
if (!isConfirmed) return;
const res = await fetch(`/api/users?id=${user.id}`, { method: 'DELETE' });
if (res.ok) {
toast('Mitarbeiter gelöscht', 'success');
fetchUsers();
} else {
const data = await res.json();
toast(data.error || 'Fehler beim Löschen', 'error');
}
};
return (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Users className="w-6 h-6 text-indigo-600" /> Team
</h1>
<p className="text-slate-500 mt-1">Verwalte Systemzugänge und weise Berechtigungsgruppen zu.</p>
</div>
<button
onClick={handleCreateNew}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2 font-medium shadow-sm"
>
{showForm ? <X className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{showForm ? 'Abbrechen' : 'Neuer Mitarbeiter'}
</button>
</div>
{showForm && (
<div className="bg-white p-6 rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 animate-in fade-in slide-in-from-top-4 duration-200">
<h2 className="text-lg font-semibold text-slate-800 mb-4">
{editingId ? 'Mitarbeiter bearbeiten' : 'Zugang anlegen'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Vorname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.firstName} onChange={e => setFormData({...formData, firstName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nachname *</label>
<input type="text" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.lastName} onChange={e => setFormData({...formData, lastName: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail (Login) *</label>
<input type="email" required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{editingId ? 'Neues Passwort (optional)' : 'Initiales Passwort *'}
</label>
<input
type="password"
required={!editingId}
minLength={6}
placeholder={editingId ? 'Leer lassen, um es nicht zu ändern' : ''}
className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none transition-all"
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Berechtigungsgruppe *</label>
<select required className="w-full border border-slate-300 p-2.5 rounded-xl focus-ring outline-none bg-white transition-all" value={formData.roleId} onChange={e => setFormData({...formData, roleId: e.target.value})}>
{roles.map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div className="md:col-span-2 flex justify-end mt-2 gap-3">
<button type="button" onClick={() => setShowForm(false)} className="px-6 py-2.5 rounded-lg text-slate-600 hover:bg-slate-100 font-medium">Abbrechen</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2.5 rounded-lg hover:bg-slate-800 transition font-medium">
{editingId ? 'Änderungen speichern' : 'Zugang erstellen'}
</button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/50 border border-slate-100 overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th className="py-4 px-6">Mitarbeiter</th>
<th className="py-4 px-6">E-Mail</th>
<th className="py-4 px-6">Gruppe</th>
<th className="py-4 px-6">Angelegt am</th>
<th className="py-4 px-6 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((u) => (
<tr key={u.id} className="hover:bg-slate-50/80 transition-colors border-b border-slate-50 last:border-0 group">
<td className="py-4 px-6 font-semibold text-slate-900">{u.firstName} {u.lastName}</td>
<td className="py-4 px-6 text-slate-600">
<span className="flex items-center gap-2"><Mail className="w-4 h-4 text-slate-400" /> {u.email}</span>
</td>
<td className="py-4 px-6">
{u.role ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700 border border-slate-200">
<ShieldAlert className="w-3 h-3 text-slate-500" /> {u.role.name}
</span>
) : (
<span className="text-red-500 text-xs font-medium">Ohne Rechte</span>
)}
</td>
<td className="py-4 px-6 text-slate-500">
{new Date(u.createdAt).toLocaleDateString('de-DE')}
</td>
<td className="py-4 px-6 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleEdit(u)}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all inline-flex items-center"
title="Mitarbeiter bearbeiten"
>
<Edit2 className="w-4 h-4" />
</button>
{canDelete && (
<button
onClick={() => handleDelete(u)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all inline-flex items-center"
title="Mitarbeiter löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-slate-500">Keine Mitarbeiter gefunden.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}