Initial commit - ERP System
This commit is contained in:
@@ -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 };
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user