feat: add typography and utility styles for layout

- Introduced typography styles in _typography.scss for headings, paragraphs, blockquotes, and horizontal rules.
- Added utility classes in _utils.scss for card styling and clearfix.
- Updated layout.scss to include new typography and utility styles.
- Defined common CSS variables in _common.scss for consistent theming.
- Created dark and light theme variables in _dark.scss and _light.scss respectively.
- Integrated Tailwind CSS with custom configurations in tailwind.config.js and postcss.config.js.
- Implemented database migrations for contact and contact_persons tables.
- Added data fixtures for generating sample contact data.
- Developed Contact and ContactPerson entities with appropriate validation and serialization.
- Enhanced ContactRepository with search and type filtering methods.
This commit is contained in:
olli 2025-11-09 11:02:15 +01:00
parent 5d3bffbbad
commit b84dc6c6e9
44 changed files with 5288 additions and 272 deletions

219
CONTACTS.md Normal file
View File

@ -0,0 +1,219 @@
# Kontaktmodul
## Übersicht
Das Kontaktmodul verwaltet Firmenkontakte (Debitoren/Kreditoren) mit bis zu 2 Ansprechpartnern pro Firma.
## Entities
### Contact
Hauptentity für Firmenkontakte mit folgenden Feldern:
**Firmendaten:**
- `companyName` (Pflicht): Firmenname
- `companyNumber`: Kundennummer
- `street`: Straße
- `zipCode`: PLZ
- `city`: Ort
- `country`: Land
- `phone`: Telefon
- `fax`: Fax
- `email`: E-Mail
- `website`: Website
- `taxNumber`: Steuernummer
- `vatNumber`: USt-IdNr.
**Klassifizierung:**
- `isDebtor`: Ist Debitor (boolean)
- `isCreditor`: Ist Kreditor (boolean)
- `isActive`: Ist aktiv (boolean)
**Sonstiges:**
- `notes`: Notizen (Text)
- `contactPersons`: Collection von Ansprechpartnern (OneToMany)
- `createdAt`: Erstellungsdatum
- `updatedAt`: Änderungsdatum
### ContactPerson
Ansprechpartner-Entity mit folgenden Feldern:
- `salutation`: Anrede (Herr/Frau/Divers)
- `title`: Titel (z.B. Dr., Prof.)
- `firstName` (Pflicht): Vorname
- `lastName` (Pflicht): Nachname
- `position`: Position
- `department`: Abteilung
- `phone`: Telefon
- `mobile`: Mobil
- `email`: E-Mail
- `isPrimary`: Hauptansprechpartner (boolean)
## API Endpoints
### Kontakte
- `GET /api/contacts` - Alle Kontakte auflisten
- `GET /api/contacts/{id}` - Einzelnen Kontakt abrufen
- `POST /api/contacts` - Neuen Kontakt erstellen (erfordert ROLE_USER)
- `PUT /api/contacts/{id}` - Kontakt bearbeiten (erfordert ROLE_USER)
- `DELETE /api/contacts/{id}` - Kontakt löschen (erfordert ROLE_ADMIN)
### Request/Response Format
**Kontakt erstellen/bearbeiten:**
```json
{
"companyName": "Beispiel GmbH",
"companyNumber": "K-12345",
"street": "Musterstraße 123",
"zipCode": "12345",
"city": "Berlin",
"country": "Deutschland",
"phone": "+49 30 12345678",
"fax": "+49 30 12345679",
"email": "info@beispiel.de",
"website": "https://www.beispiel.de",
"taxNumber": "123/456/78901",
"vatNumber": "DE123456789",
"isDebtor": true,
"isCreditor": false,
"isActive": true,
"notes": "Wichtiger Kunde",
"contactPersons": [
{
"salutation": "Herr",
"title": "Dr.",
"firstName": "Max",
"lastName": "Mustermann",
"position": "Geschäftsführer",
"department": "Management",
"phone": "+49 30 12345680",
"mobile": "+49 170 1234567",
"email": "max.mustermann@beispiel.de",
"isPrimary": true
}
]
}
```
## Repository-Methoden
### ContactRepository
**findByType(?bool $isDebtor, ?bool $isCreditor): array**
- Filtert Kontakte nach Typ (Debitor/Kreditor)
- Gibt nur aktive Kontakte zurück
- Sortiert nach Firmennamen
**search(string $searchTerm): array**
- Durchsucht Kontakte nach:
- Firmenname
- E-Mail
- Stadt
- Ansprechpartner (Vor-/Nachname, E-Mail)
## Vue.js Komponente
### ContactManagement.vue
**Features:**
- DataTable mit Pagination (10/25/50 Einträge)
- Globale Suche über alle Felder
- Filter nach Typ (Alle/Debitoren/Kreditoren)
- Sortierung nach Spalten
- CRUD-Operationen über Dialoge
- Bis zu 2 Ansprechpartner pro Kontakt
- Validierung (Pflichtfelder, E-Mail-Format, URL-Format)
- Responsive Design (mobile + desktop)
- Toast-Benachrichtigungen für Erfolg/Fehler
- Bestätigungsdialog beim Löschen
**Verwendete PrimeVue Komponenten:**
- DataTable, Column
- Dialog
- Card
- Button
- InputText, Textarea
- Checkbox, Dropdown
- Tag
- Divider
- IconField, InputIcon
## Berechtigungen
- **ROLE_USER**: Kann Kontakte ansehen, erstellen und bearbeiten
- **ROLE_ADMIN**: Kann zusätzlich Kontakte löschen
## Navigation
Das Kontaktmodul ist über den Menüpunkt "Kontakte" erreichbar (Route: `/contacts`).
## Datenbank-Tabellen
### contacts
```sql
CREATE TABLE contacts (
id INT AUTO_INCREMENT PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
company_number VARCHAR(50),
street TEXT,
zip_code VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100),
phone VARCHAR(50),
fax VARCHAR(50),
email VARCHAR(180),
website VARCHAR(255),
tax_number VARCHAR(50),
vat_number VARCHAR(50),
is_debtor TINYINT(1) DEFAULT 0,
is_creditor TINYINT(1) DEFAULT 0,
is_active TINYINT(1) DEFAULT 1,
notes TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME,
INDEX idx_company_name (company_name),
INDEX idx_city (city),
INDEX idx_is_debtor (is_debtor),
INDEX idx_is_creditor (is_creditor),
INDEX idx_is_active (is_active)
);
```
### contact_persons
```sql
CREATE TABLE contact_persons (
id INT AUTO_INCREMENT PRIMARY KEY,
contact_id INT NOT NULL,
salutation VARCHAR(20),
title VARCHAR(100),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
position VARCHAR(100),
department VARCHAR(100),
phone VARCHAR(50),
mobile VARCHAR(50),
email VARCHAR(180),
is_primary TINYINT(1) DEFAULT 0,
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
INDEX idx_contact_id (contact_id),
INDEX idx_is_primary (is_primary)
);
```
## Best Practices
1. **Ansprechpartner-Limit**: Maximal 2 Ansprechpartner pro Kontakt (UI-Limitierung)
2. **Cascade Delete**: Beim Löschen eines Kontakts werden auch alle Ansprechpartner gelöscht
3. **Timestamps**: Automatische Verwaltung von `createdAt` und `updatedAt`
4. **Validierung**: Alle Validierungen erfolgen sowohl im Backend (Symfony Validator) als auch im Frontend (Vue.js)
5. **Session-basierte API**: Alle API-Aufrufe verwenden `credentials: 'include'` für Session-Authentifizierung
## Erweiterungsmöglichkeiten
- Aktivitäten-Tracking (Anrufe, E-Mails, Meetings)
- Dokumente-Verwaltung
- Deals/Opportunities
- Import/Export (CSV, Excel)
- Duplikatserkennung
- Kontakt-Historie
- Tags/Labels
- Mehr als 2 Ansprechpartner (aktuell UI-limitiert)

View File

@ -6,13 +6,16 @@ Eine moderne, modulare CRM-Anwendung basierend auf Symfony 7.1 LTS, Vue.js 3 und
- **Symfony 7.1 LTS** - Stabile PHP-Backend-Framework - **Symfony 7.1 LTS** - Stabile PHP-Backend-Framework
- **Vue.js 3** - Modernes, reaktives Frontend mit Composition API - **Vue.js 3** - Modernes, reaktives Frontend mit Composition API
- **PrimeVue** - Professionelle UI-Komponenten (DataTable, Charts, Forms) - **PrimeVue 4** - Professionelle UI-Komponenten (DataTable, Charts, Forms)
- **Sakai Template** - Professional Admin Layout mit Dark Mode
- **Tailwind CSS v4** - Utility-First CSS Framework
- **API Platform** - RESTful API mit OpenAPI-Dokumentation - **API Platform** - RESTful API mit OpenAPI-Dokumentation
- **MariaDB** - Zuverlässige relationale Datenbank - **MariaDB** - Zuverlässige relationale Datenbank
- **Webpack Encore** - Asset-Management und Hot Module Replacement - **Webpack Encore** - Asset-Management und Hot Module Replacement
- **Modulares Berechtigungssystem** - Flexible Rollen mit Modul-basierter Rechteverwaltung - **Modulares Berechtigungssystem** - Flexible Rollen mit Modul-basierter Rechteverwaltung
- **User CRUD** - Vollständige Benutzerverwaltung via API Platform - **User CRUD** - Vollständige Benutzerverwaltung via API Platform
- **Login-System** - Form-basierte Authentifizierung mit Remember Me - **Login-System** - Form-basierte Authentifizierung mit Remember Me
- **Kontaktmodul** - Firmenstammdaten mit Ansprechpersonen und Debitor/Kreditor-Klassifizierung
## 📋 Voraussetzungen ## 📋 Voraussetzungen
@ -101,18 +104,23 @@ npm run watch
/assets /assets
/js /js
/components - Wiederverwendbare Vue-Komponenten /components - Wiederverwendbare Vue-Komponenten
/views - Page-Level Vue-Komponenten /views - Page-Level Vue-Komponenten (ContactManagement, UserManagement, etc.)
/layout - Sakai Layout-Komponenten (AppLayout, AppTopbar, AppSidebar, AppMenu)
/composables - Vue Composition API Functions /composables - Vue Composition API Functions
/stores - Pinia State Management /stores - Pinia State Management
/api - API Client Wrapper /api - API Client Wrapper
/styles - SCSS/CSS Styles /styles
/layout - Sakai SCSS (Topbar, Sidebar, Menu, Footer, Responsive, Animations)
tailwind.css - Tailwind CSS v4 mit PrimeUI Plugin
sakai.scss - Sakai Layout Imports
/config - Symfony-Konfiguration /config - Symfony-Konfiguration
/src /src
/Controller - HTTP Controllers /Controller - HTTP Controllers
/Entity - Doctrine Entities /Entity - Doctrine Entities (User, Role, Contact, ContactPerson)
/Repository - Database Queries /Repository - Database Queries
/Service - Business Logic /Service - Business Logic
/Security/Voter - Permission Logic /Security/Voter - Permission Logic
/DataFixtures - Test-Daten (200 Kontakte mit realistischen deutschen Daten)
/templates - Twig Templates /templates - Twig Templates
/public - Public Assets & Entry Point /public - Public Assets & Entry Point
/migrations - Doctrine Migrations /migrations - Doctrine Migrations
@ -131,7 +139,9 @@ npm run watch
- **Vue.js 3** - Progressive JavaScript Framework - **Vue.js 3** - Progressive JavaScript Framework
- **Vue Router** - SPA Navigation - **Vue Router** - SPA Navigation
- **Pinia** - State Management - **Pinia** - State Management
- **PrimeVue** - UI Component Library - **PrimeVue 4** - UI Component Library mit Aura Theme
- **Sakai Template** - Professional Admin Layout (von PrimeFaces)
- **Tailwind CSS v4** - Utility-First CSS Framework mit PrimeUI Plugin
- **Webpack Encore** - Asset Bundler - **Webpack Encore** - Asset Bundler
### Database ### Database
@ -140,11 +150,18 @@ npm run watch
## 📱 Module ## 📱 Module
- **Dashboard** - Übersicht und KPIs - **Dashboard** - Übersicht und KPIs
- **Kontakte** - Kontaktverwaltung mit Status-Tracking (in Entwicklung) - **Kontakte** - Firmenstammdatenverwaltung mit Ansprechpersonen (✅ implementiert)
- **Unternehmen** - Firmendatenbank (in Entwicklung) - Vollständige CRUD-Operationen via API Platform
- Server-seitige Filterung (Debitoren/Kreditoren/Status)
- 200 Test-Fixtures mit realistischen deutschen Firmendaten
- Strukturiertes Formular in 6 Kategorien (Basisdaten, Adresse, Kontaktdaten, Steuerdaten, Ansprechpartner, Notizen)
- Bis zu 2 Ansprechpersonen pro Firma
- Debitor/Kreditor-Klassifizierung (beide möglich)
- Aktiv/Inaktiv-Status
- **Deals** - Sales-Pipeline Management (in Entwicklung) - **Deals** - Sales-Pipeline Management (in Entwicklung)
- **Aktivitäten** - Interaktions-Historie (in Entwicklung) - **Aktivitäten** - Interaktions-Historie (in Entwicklung)
- **Benutzerverwaltung** - CRUD für User (✅ implementiert) - **Benutzerverwaltung** - CRUD für User (✅ implementiert)
- **Rollenverwaltung** - Modulare Berechtigungen (✅ implementiert)
## 🔐 Sicherheit ## 🔐 Sicherheit
@ -197,20 +214,31 @@ Dein Team
--- ---
**Status:** ✅ Grundsystem implementiert - Ready for CRM-Module! **Status:** ✅ Grundsystem + Kontaktmodul implementiert - Ready for weitere CRM-Module!
**Implementiert:** **Implementiert:**
- ✅ Projekt-Setup (Symfony 7.1 + Vue.js 3 + PrimeVue) - ✅ Projekt-Setup (Symfony 7.1 + Vue.js 3 + PrimeVue 4)
- ✅ Sakai Template Integration (Professional Admin Layout)
- ✅ Tailwind CSS v4 mit PrimeUI Plugin
- ✅ Modulares Berechtigungssystem (User, Role, Module, RolePermission) - ✅ Modulares Berechtigungssystem (User, Role, Module, RolePermission)
- ✅ Login-System mit Remember Me - ✅ Login-System mit Remember Me
- ✅ User-CRUD mit API Platform - ✅ User-CRUD mit API Platform
- ✅ **Kontaktmodul mit Firmenstammdaten**
- Contact & ContactPerson Entities
- API Platform REST Endpoints mit Filtern
- Vue.js ContactManagement Component
- Strukturiertes Formular (6 Kategorien)
- 200 Test-Fixtures mit deutschen Daten
- Server-seitige Filterung & Pagination
- ✅ Vue.js Frontend mit PrimeVue DataTable, Dialogs, Forms - ✅ Vue.js Frontend mit PrimeVue DataTable, Dialogs, Forms
- ✅ Password-Hashing via State Processor - ✅ Password-Hashing via State Processor
- ✅ Admin-Navigation und Schutz - ✅ Dark Mode Support
- ✅ Responsive Design mit Sakai Layout
**Next Steps:** **Next Steps:**
1. Contact-Entity erstellen: `php bin/console make:entity Contact` 1. Deal-Entity mit Pipeline-Stages erstellen
2. Company-Entity erstellen: `php bin/console make:entity Company` 2. Activity-Entity für Interaktionshistorie
3. Deal-Entity mit Pipeline-Stages erstellen 3. Dashboard mit KPIs und Charts
4. Activity-Entity für Interaktionshistorie 4. Reporting-Modul
5. Vue.js-Komponenten für Contact/Company/Deal-Management 5. E-Mail-Integration
6. Kalender/Termine

View File

@ -5,14 +5,20 @@ import './bootstrap.js';
* This file will be included onto the page via the importmap() Twig function, * This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig. * which should already be in your base.html.twig.
*/ */
import './styles/app.scss'; import './styles/tailwind.css';
import './styles/sakai.scss';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import PrimeVue from 'primevue/config'; import PrimeVue from 'primevue/config';
import Aura from '@primevue/themes/aura'; import Aura from '@primevue/themes/aura';
import Lara from '@primevue/themes/lara';
import Nora from '@primevue/themes/nora';
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes';
import ToastService from 'primevue/toastservice'; import ToastService from 'primevue/toastservice';
import Toast from 'primevue/toast';
import Tooltip from 'primevue/tooltip'; import Tooltip from 'primevue/tooltip';
import StyleClass from 'primevue/styleclass';
import router from './js/router'; import router from './js/router';
import App from './js/App.vue'; import App from './js/App.vue';
import { useAuthStore } from './js/stores/auth'; import { useAuthStore } from './js/stores/auth';
@ -25,22 +31,115 @@ console.log('This log comes from assets/app.js - welcome to myCRM!');
const app = createApp(App); const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
// Load saved theme preferences
const savedPreset = localStorage.getItem('preset') || 'Aura';
const savedPrimary = localStorage.getItem('primaryColor') || 'emerald';
const savedSurface = localStorage.getItem('surfaceColor');
const presets = { Aura, Lara, Nora };
const primaryColors = {
noir: {},
emerald: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' },
green: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' },
lime: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' },
orange: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' },
amber: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' },
yellow: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' },
teal: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' },
cyan: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' },
sky: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' },
blue: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' },
indigo: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' },
violet: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' },
purple: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' },
fuchsia: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' },
pink: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' },
rose: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' }
};
const surfaces = {
slate: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' },
gray: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' },
zinc: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' },
neutral: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' },
stone: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' },
soho: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' },
viva: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' },
ocean: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' }
};
function getPrimaryPreset(primaryName) {
const palette = primaryColors[primaryName];
if (primaryName === 'noir') {
return {
semantic: {
primary: {
50: '{surface.50}', 100: '{surface.100}', 200: '{surface.200}', 300: '{surface.300}',
400: '{surface.400}', 500: '{surface.500}', 600: '{surface.600}', 700: '{surface.700}',
800: '{surface.800}', 900: '{surface.900}', 950: '{surface.950}'
},
colorScheme: {
light: {
primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' },
highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' }
},
dark: {
primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' }
}
}
}
};
}
return {
semantic: {
primary: palette,
colorScheme: {
light: {
primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' },
highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' }
},
dark: {
primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' },
highlight: { background: 'color-mix(in srgb, {primary.400}, transparent 84%)', focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)', color: 'rgba(255,255,255,.87)', focusColor: 'rgba(255,255,255,.87)' }
}
}
}
};
}
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.use(PrimeVue, { app.use(PrimeVue, {
theme: { theme: {
preset: Aura, preset: presets[savedPreset],
options: { options: {
darkModeSelector: false, darkModeSelector: '.app-dark',
cssLayer: false cssLayer: false
} }
} }
}); });
app.use(ToastService); app.use(ToastService);
app.directive('tooltip', Tooltip); app.directive('tooltip', Tooltip);
app.directive('styleclass', StyleClass);
app.component('Toast', Toast);
app.mount('#app'); app.mount('#app');
// Apply saved theme colors after mount
if (savedPrimary || savedSurface) {
setTimeout(() => {
if (savedPrimary) {
updatePreset(getPrimaryPreset(savedPrimary));
}
if (savedSurface && surfaces[savedSurface]) {
updateSurfacePalette(surfaces[savedSurface]);
}
}, 0);
}
// Initialize auth store with user data from backend // Initialize auth store with user data from backend
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.initializeFromElement(); authStore.initializeFromElement();

View File

@ -1,100 +1,16 @@
<template> <template>
<Toast /> <Toast />
<div id="app-layout"> <AppLayout v-if="authStore.isAuthenticated" />
<header class="app-header"> <RouterView v-else />
<div class="header-content">
<div class="logo">
<i class="pi pi-database"></i>
<span>myCRM</span>
</div>
<button class="hamburger" @click="toggleMobileMenu" :class="{ active: mobileMenuOpen }">
<span></span>
<span></span>
<span></span>
</button>
<nav :class="{ open: mobileMenuOpen }">
<RouterLink to="/" @click="closeMobileMenu">
<i class="pi pi-home"></i> Dashboard
</RouterLink>
<RouterLink to="/contacts" @click="closeMobileMenu">
<i class="pi pi-users"></i> Kontakte
</RouterLink>
<RouterLink to="/companies" @click="closeMobileMenu">
<i class="pi pi-building"></i> Unternehmen
</RouterLink>
<RouterLink to="/deals" @click="closeMobileMenu">
<i class="pi pi-chart-line"></i> Deals
</RouterLink>
<!-- Admin Menu -->
<div class="nav-dropdown" v-if="authStore.isAdmin">
<a href="#" class="nav-dropdown-toggle" @click.prevent="toggleAdminMenu" :class="{ active: adminMenuOpen }">
<i class="pi pi-shield"></i> Admin
<i class="pi pi-chevron-down dropdown-icon" :class="{ rotated: adminMenuOpen }"></i>
</a>
<div class="nav-dropdown-menu" v-show="adminMenuOpen">
<RouterLink to="/users" @click="handleAdminMenuClick">
<i class="pi pi-user-edit"></i> Benutzerverwaltung
</RouterLink>
<RouterLink to="/roles" @click="handleAdminMenuClick">
<i class="pi pi-shield"></i> Rollenverwaltung
</RouterLink>
<RouterLink to="/settings" @click="handleAdminMenuClick">
<i class="pi pi-cog"></i> Einstellungen
</RouterLink>
</div>
</div>
</nav>
<div class="user-info" v-if="authStore.isAuthenticated">
<span>{{ authStore.fullName }}</span>
<a href="/logout" class="logout-link">
<i class="pi pi-sign-out"></i> Logout
</a>
</div>
</div>
</header>
<main class="app-main">
<RouterView />
</main>
<footer class="app-footer">
<p>&copy; {{ new Date().getFullYear() }} myCRM</p>
</footer>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { RouterView } from 'vue-router';
import { RouterLink, RouterView } from 'vue-router';
import Toast from 'primevue/toast';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import AppLayout from './layout/AppLayout.vue';
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.initializeFromElement(document.getElementById('app')); authStore.initializeFromElement(document.getElementById('app'));
const mobileMenuOpen = ref(false);
const adminMenuOpen = ref(false);
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value;
};
const closeMobileMenu = () => {
mobileMenuOpen.value = false;
adminMenuOpen.value = false;
};
const toggleAdminMenu = () => {
adminMenuOpen.value = !adminMenuOpen.value;
};
const handleAdminMenuClick = () => {
closeMobileMenu();
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -0,0 +1,261 @@
<script setup>
import { useLayout } from './composables/layout';
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes';
import Aura from '@primeuix/themes/aura';
import Lara from '@primeuix/themes/lara';
import Nora from '@primeuix/themes/nora';
import SelectButton from 'primevue/selectbutton';
import { ref, watch } from 'vue';
const { layoutConfig, isDarkTheme } = useLayout();
const presets = {
Aura,
Lara,
Nora
};
const preset = ref(layoutConfig.preset);
const presetOptions = ref(Object.keys(presets));
const menuMode = ref(layoutConfig.menuMode);
const menuModeOptions = ref([
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
]);
// Sync with layoutConfig changes
watch(() => layoutConfig.preset, (newVal) => {
preset.value = newVal;
});
watch(() => layoutConfig.menuMode, (newVal) => {
menuMode.value = newVal;
});
const primaryColors = ref([
{ name: 'noir', palette: {} },
{ name: 'emerald', palette: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' } },
{ name: 'green', palette: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' } },
{ name: 'lime', palette: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' } },
{ name: 'orange', palette: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' } },
{ name: 'amber', palette: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' } },
{ name: 'yellow', palette: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' } },
{ name: 'teal', palette: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' } },
{ name: 'cyan', palette: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' } },
{ name: 'sky', palette: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' } },
{ name: 'blue', palette: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' } },
{ name: 'indigo', palette: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' } },
{ name: 'violet', palette: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' } },
{ name: 'purple', palette: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' } },
{ name: 'fuchsia', palette: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' } },
{ name: 'pink', palette: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' } },
{ name: 'rose', palette: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' } }
]);
const surfaces = ref([
{
name: 'slate',
palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' }
},
{
name: 'gray',
palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' }
},
{
name: 'zinc',
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' }
},
{
name: 'neutral',
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' }
},
{
name: 'stone',
palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' }
},
{
name: 'soho',
palette: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' }
},
{
name: 'viva',
palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' }
},
{
name: 'ocean',
palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' }
}
]);
function getPresetExt() {
const color = primaryColors.value.find((c) => c.name === layoutConfig.primary);
if (color.name === 'noir') {
return {
semantic: {
primary: {
50: '{surface.50}',
100: '{surface.100}',
200: '{surface.200}',
300: '{surface.300}',
400: '{surface.400}',
500: '{surface.500}',
600: '{surface.600}',
700: '{surface.700}',
800: '{surface.800}',
900: '{surface.900}',
950: '{surface.950}'
},
colorScheme: {
light: {
primary: {
color: '{primary.950}',
contrastColor: '#ffffff',
hoverColor: '{primary.800}',
activeColor: '{primary.700}'
},
highlight: {
background: '{primary.950}',
focusBackground: '{primary.700}',
color: '#ffffff',
focusColor: '#ffffff'
}
},
dark: {
primary: {
color: '{primary.50}',
contrastColor: '{primary.950}',
hoverColor: '{primary.200}',
activeColor: '{primary.300}'
},
highlight: {
background: '{primary.50}',
focusBackground: '{primary.300}',
color: '{primary.950}',
focusColor: '{primary.950}'
}
}
}
}
};
} else {
return {
semantic: {
primary: color.palette,
colorScheme: {
light: {
primary: {
color: '{primary.500}',
contrastColor: '#ffffff',
hoverColor: '{primary.600}',
activeColor: '{primary.700}'
},
highlight: {
background: '{primary.50}',
focusBackground: '{primary.100}',
color: '{primary.700}',
focusColor: '{primary.800}'
}
},
dark: {
primary: {
color: '{primary.400}',
contrastColor: '{surface.900}',
hoverColor: '{primary.300}',
activeColor: '{primary.200}'
},
highlight: {
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
color: 'rgba(255,255,255,.87)',
focusColor: 'rgba(255,255,255,.87)'
}
}
}
}
};
}
}
function updateColors(type, color) {
if (type === 'primary') {
layoutConfig.primary = color.name;
localStorage.setItem('primaryColor', color.name);
} else if (type === 'surface') {
layoutConfig.surface = color.name;
localStorage.setItem('surfaceColor', color.name);
}
applyTheme(type, color);
}
function applyTheme(type, color) {
if (type === 'primary') {
updatePreset(getPresetExt());
} else if (type === 'surface') {
updateSurfacePalette(color.palette);
}
}
function onPresetChange() {
layoutConfig.preset = preset.value;
localStorage.setItem('preset', preset.value);
const presetValue = presets[preset.value];
const surfacePalette = surfaces.value.find((s) => s.name === layoutConfig.surface)?.palette;
$t().preset(presetValue).preset(getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true });
}
function onMenuModeChange() {
layoutConfig.menuMode = menuMode.value;
localStorage.setItem('menuMode', menuMode.value);
}
</script>
<template>
<div
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
>
<div class="flex flex-col gap-4">
<div>
<span class="text-sm text-muted-color font-semibold">Primary</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="primaryColor of primaryColors"
:key="primaryColor.name"
type="button"
:title="primaryColor.name"
@click="updateColors('primary', primaryColor)"
:class="['border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1', { 'outline-primary': layoutConfig.primary === primaryColor.name }]"
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }"
></button>
</div>
</div>
<div>
<span class="text-sm text-muted-color font-semibold">Surface</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="surface of surfaces"
:key="surface.name"
type="button"
:title="surface.name"
@click="updateColors('surface', surface)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === surface.name : isDarkTheme ? surface.name === 'zinc' : surface.name === 'slate' }
]"
:style="{ backgroundColor: `${surface.palette['500']}` }"
></button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Presets</span>
<SelectButton v-model="preset" @change="onPresetChange" :options="presetOptions" :allowEmpty="false" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton v-model="menuMode" @change="onMenuModeChange" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,8 @@
<script setup></script>
<template>
<div class="layout-footer">
SAKAI by
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
</div>
</template>

View File

@ -0,0 +1,71 @@
<script setup>
import { useLayout } from './composables/layout';
import { computed, ref, watch } from 'vue';
import AppFooter from './AppFooter.vue';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
const { layoutConfig, layoutState, isSidebarActive } = useLayout();
const outsideClickListener = ref(null);
watch(isSidebarActive, (newVal) => {
if (newVal) {
bindOutsideClickListener();
} else {
unbindOutsideClickListener();
}
});
const containerClass = computed(() => {
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-static-inactive': layoutState.staticMenuDesktopInactive && layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.staticMenuMobileActive
};
});
function bindOutsideClickListener() {
if (!outsideClickListener.value) {
outsideClickListener.value = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false;
layoutState.staticMenuMobileActive = false;
layoutState.menuHoverActive = false;
}
};
document.addEventListener('click', outsideClickListener.value);
}
}
function unbindOutsideClickListener() {
if (outsideClickListener.value) {
document.removeEventListener('click', outsideClickListener);
outsideClickListener.value = null;
}
}
function isOutsideClicked(event) {
const sidebarEl = document.querySelector('.layout-sidebar');
const topbarEl = document.querySelector('.layout-menu-button');
return !(sidebarEl.isSameNode(event.target) || sidebarEl.contains(event.target) || topbarEl.isSameNode(event.target) || topbarEl.contains(event.target));
}
</script>
<template>
<div class="layout-wrapper" :class="containerClass">
<app-topbar></app-topbar>
<app-sidebar></app-sidebar>
<div class="layout-main-container">
<div class="layout-main">
<router-view></router-view>
</div>
<app-footer></app-footer>
</div>
<div class="layout-mask animate-fadein"></div>
</div>
<Toast />
</template>

View File

@ -0,0 +1,40 @@
<script setup>
import { ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import AppMenuItem from './AppMenuItem.vue';
const authStore = useAuthStore();
const model = ref([
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
},
{
label: 'CRM',
items: [
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' }
]
},
{
label: 'Administration',
visible: () => authStore.isAdmin,
items: [
{ label: 'Benutzerverwaltung', icon: 'pi pi-fw pi-user-edit', to: '/users' },
{ label: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' },
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' }
]
}
]);
</script>
<template>
<ul class="layout-menu">
<template v-for="(item, i) in model" :key="item">
<app-menu-item v-if="!item.separator && (!item.visible || item.visible())" :item="item" :index="i"></app-menu-item>
<li v-if="item.separator" class="menu-separator"></li>
</template>
</ul>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,92 @@
<script setup>
import { useLayout } from './composables/layout';
import { onBeforeMount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const { layoutState, setActiveMenuItem, toggleMenu } = useLayout();
const props = defineProps({
item: {
type: Object,
default: () => ({})
},
index: {
type: Number,
default: 0
},
root: {
type: Boolean,
default: true
},
parentItemKey: {
type: String,
default: null
}
});
const isActiveMenu = ref(false);
const itemKey = ref(null);
onBeforeMount(() => {
itemKey.value = props.parentItemKey ? props.parentItemKey + '-' + props.index : String(props.index);
const activeItem = layoutState.activeMenuItem;
isActiveMenu.value = activeItem === itemKey.value || activeItem ? activeItem.startsWith(itemKey.value + '-') : false;
});
watch(
() => layoutState.activeMenuItem,
(newVal) => {
isActiveMenu.value = newVal === itemKey.value || newVal.startsWith(itemKey.value + '-');
}
);
function itemClick(event, item) {
if (item.disabled) {
event.preventDefault();
return;
}
if ((item.to || item.url) && (layoutState.staticMenuMobileActive || layoutState.overlayMenuActive)) {
toggleMenu();
}
if (item.command) {
item.command({ originalEvent: event, item: item });
}
const foundItemKey = item.items ? (isActiveMenu.value ? props.parentItemKey : itemKey) : itemKey.value;
setActiveMenuItem(foundItemKey);
}
function checkActiveRoute(item) {
return route.path === item.to;
}
</script>
<template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div>
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item, index)" :class="item.class" :target="item.target" tabindex="0">
<i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</a>
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item, index)" :class="[item.class, { 'active-route': checkActiveRoute(item) }]" tabindex="0" :to="item.to">
<i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</router-link>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<ul v-show="root ? true : isActiveMenu" class="layout-submenu">
<app-menu-item v-for="(child, i) in item.items" :key="child" :index="i" :item="child" :parentItemKey="itemKey" :root="false"></app-menu-item>
</ul>
</Transition>
</li>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,11 @@
<script setup>
import AppMenu from './AppMenu.vue';
</script>
<template>
<div class="layout-sidebar">
<app-menu></app-menu>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,96 @@
<script setup>
import { useLayout } from './composables/layout';
import { useAuthStore } from '../stores/auth';
import AppConfigurator from './AppConfigurator.vue';
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
const authStore = useAuthStore();
async function logout() {
await authStore.logout();
}
</script>
<template>
<div class="layout-topbar">
<div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
<i class="pi pi-bars"></i>
</button>
<router-link to="/" class="layout-topbar-logo">
<span class="text-2xl font-bold">myCRM</span>
</router-link>
</div>
<div class="layout-topbar-actions">
<div class="layout-config-menu">
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
<div class="relative">
<button
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
type="button"
class="layout-topbar-action layout-topbar-action-highlight"
>
<i class="pi pi-palette"></i>
</button>
<AppConfigurator />
</div>
</div>
<button
class="layout-topbar-menu-button layout-topbar-action lg:hidden"
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
>
<i class="pi pi-ellipsis-v"></i>
</button>
<div class="layout-topbar-menu hidden lg:flex">
<div class="relative">
<button
type="button"
class="layout-topbar-action"
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
>
<i class="pi pi-user"></i>
<span>{{ authStore.user?.email?.split('@')[0] }}</span>
</button>
<div class="hidden absolute right-0 top-full mt-2 bg-surface-0 dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg p-4 min-w-[280px] z-[1000]">
<div class="flex items-center gap-3 pb-3 border-b border-surface-200 dark:border-surface-700">
<div class="w-12 h-12 rounded-full bg-primary text-primary-contrast flex items-center justify-center text-xl font-bold">
{{ (authStore.user?.fullName || authStore.user?.email || 'U').charAt(0).toUpperCase() }}
</div>
<div class="flex-1">
<div class="font-semibold text-surface-900 dark:text-surface-0">
{{ authStore.user?.fullName || authStore.user?.email?.split('@')[0] }}
</div>
<div class="text-sm text-surface-600 dark:text-surface-400">
{{ authStore.user?.email }}
</div>
</div>
</div>
<div class="pt-3 space-y-2">
<div class="flex items-center gap-2 text-sm">
<i class="pi pi-shield text-surface-500"></i>
<span class="text-surface-700 dark:text-surface-300">
{{ authStore.user?.roles?.join(', ') || 'Keine Rolle' }}
</span>
</div>
<div class="flex items-center gap-2 text-sm" v-if="authStore.user?.createdAt">
<i class="pi pi-calendar text-surface-500"></i>
<span class="text-surface-700 dark:text-surface-300">
Mitglied seit {{ new Date(authStore.user.createdAt).toLocaleDateString('de-DE') }}
</span>
</div>
</div>
</div>
</div>
<button type="button" class="layout-topbar-action" @click="logout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,87 @@
import { computed, reactive } from 'vue';
// Load preferences from localStorage
const savedDarkMode = localStorage.getItem('darkMode') === 'true';
const savedPrimary = localStorage.getItem('primaryColor') || 'emerald';
const savedSurface = localStorage.getItem('surfaceColor') || null;
const savedPreset = localStorage.getItem('preset') || 'Aura';
const savedMenuMode = localStorage.getItem('menuMode') || 'static';
const layoutConfig = reactive({
preset: savedPreset,
primary: savedPrimary,
surface: savedSurface,
darkTheme: savedDarkMode,
menuMode: savedMenuMode
});
// Apply dark mode on initial load
if (savedDarkMode) {
document.documentElement.classList.add('app-dark');
}
const layoutState = reactive({
staticMenuDesktopInactive: false,
overlayMenuActive: false,
profileSidebarVisible: false,
configSidebarVisible: false,
staticMenuMobileActive: false,
menuHoverActive: false,
activeMenuItem: null
});
export function useLayout() {
const setActiveMenuItem = (item) => {
layoutState.activeMenuItem = item.value || item;
};
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle();
return;
}
document.startViewTransition(() => executeDarkModeToggle(event));
};
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme;
document.documentElement.classList.toggle('app-dark');
// Save preference to localStorage
localStorage.setItem('darkMode', layoutConfig.darkTheme.toString());
};
const toggleMenu = () => {
if (layoutConfig.menuMode === 'overlay') {
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
}
if (window.innerWidth > 991) {
layoutState.staticMenuDesktopInactive = !layoutState.staticMenuDesktopInactive;
} else {
layoutState.staticMenuMobileActive = !layoutState.staticMenuMobileActive;
}
};
const isSidebarActive = computed(() => layoutState.overlayMenuActive || layoutState.staticMenuMobileActive);
const isDarkTheme = computed(() => layoutConfig.darkTheme);
const getPrimary = computed(() => layoutConfig.primary);
const getSurface = computed(() => layoutConfig.surface);
return {
layoutConfig,
layoutState,
toggleMenu,
isSidebarActive,
isDarkTheme,
getPrimary,
getSurface,
setActiveMenuItem,
toggleDarkMode
};
}

View File

@ -1,17 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import Dashboard from './views/Dashboard.vue'; import Dashboard from './views/Dashboard.vue';
import ContactList from './views/ContactList.vue'; import ContactManagement from './views/ContactManagement.vue';
import CompanyList from './views/CompanyList.vue';
import DealList from './views/DealList.vue';
import UserManagement from './views/UserManagement.vue'; import UserManagement from './views/UserManagement.vue';
import RoleManagement from './views/RoleManagement.vue'; import RoleManagement from './views/RoleManagement.vue';
import SettingsManagement from './views/SettingsManagement.vue'; import SettingsManagement from './views/SettingsManagement.vue';
const routes = [ const routes = [
{ path: '/', name: 'dashboard', component: Dashboard }, { path: '/', name: 'dashboard', component: Dashboard },
{ path: '/contacts', name: 'contacts', component: ContactList }, { path: '/contacts', name: 'contacts', component: ContactManagement },
{ path: '/companies', name: 'companies', component: CompanyList },
{ path: '/deals', name: 'deals', component: DealList },
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } }, { path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } }, { path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } }, { path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },

View File

@ -0,0 +1,789 @@
<template>
<div class="contact-management">
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-3xl font-bold">Kontakte</h1>
<Button label="Neuer Kontakt" icon="pi pi-plus" @click="openNewContactDialog" />
</div>
<Card>
<template #content>
<DataTable
v-model:filters="globalFilter"
:value="contacts"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[10, 25, 50]"
:globalFilterFields="['companyName', 'city', 'email']"
sortField="companyName"
:sortOrder="1"
>
<template #header>
<div class="flex justify-content-between">
<IconField iconPosition="left">
<InputIcon>
<i class="pi pi-search" />
</InputIcon>
<InputText
v-model="globalFilter"
placeholder="Suchen..."
style="width: 300px"
/>
</IconField>
<div class="flex gap-2">
<Button
label="Alle"
:outlined="typeFilter !== 'all'"
@click="filterByType('all')"
size="small"
/>
<Button
label="Debitoren"
:outlined="typeFilter !== 'debtor'"
@click="filterByType('debtor')"
size="small"
/>
<Button
label="Kreditoren"
:outlined="typeFilter !== 'creditor'"
@click="filterByType('creditor')"
size="small"
/>
</div>
</div>
</template>
<Column field="companyName" header="Firma" sortable style="min-width: 200px">
<template #body="{ data }">
<div class="font-semibold">{{ data.companyName }}</div>
<div class="text-sm text-500" v-if="data.companyNumber">
Nr: {{ data.companyNumber }}
</div>
</template>
</Column>
<Column field="city" header="Ort" sortable style="min-width: 150px">
<template #body="{ data }">
<div v-if="data.zipCode || data.city">
{{ data.zipCode }} {{ data.city }}
</div>
<div class="text-sm text-500" v-if="data.country">
{{ data.country }}
</div>
</template>
</Column>
<Column header="Ansprechpartner" style="min-width: 200px">
<template #body="{ data }">
<div v-if="data.contactPersons && data.contactPersons.length > 0">
<div
v-for="person in data.contactPersons.slice(0, 2)"
:key="person.id"
class="mb-1"
>
<div class="font-medium">
{{ person.firstName }} {{ person.lastName }}
<Tag v-if="person.isPrimary" value="Primär" severity="info" class="ml-1" />
</div>
<div class="text-sm text-500" v-if="person.email">
{{ person.email }}
</div>
</div>
</div>
<span v-else class="text-500">Keine Ansprechpartner</span>
</template>
</Column>
<Column header="Typ" style="min-width: 130px">
<template #body="{ data }">
<div class="flex gap-1">
<Tag v-if="data.isDebtor" value="Debitor" severity="success" />
<Tag v-if="data.isCreditor" value="Kreditor" severity="warning" />
</div>
</template>
</Column>
<Column header="Status" style="min-width: 100px">
<template #body="{ data }">
<Tag
:value="data.isActive ? 'Aktiv' : 'Inaktiv'"
:severity="data.isActive ? 'success' : 'danger'"
/>
</template>
</Column>
<Column header="Kontakt" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-column gap-1">
<div v-if="data.phone" class="text-sm">
<i class="pi pi-phone mr-1"></i> {{ data.phone }}
</div>
<div v-if="data.email" class="text-sm">
<i class="pi pi-envelope mr-1"></i> {{ data.email }}
</div>
<div v-if="data.website" class="text-sm">
<i class="pi pi-globe mr-1"></i>
<a :href="data.website" target="_blank">{{ data.website }}</a>
</div>
</div>
</template>
</Column>
<Column :exportable="false" style="min-width: 120px">
<template #body="{ data }">
<div class="flex gap-2">
<Button
icon="pi pi-pencil"
outlined
rounded
@click="editContact(data)"
size="small"
/>
<Button
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="confirmDelete(data)"
size="small"
/>
</div>
</template>
</Column>
<template #empty>Keine Kontakte gefunden.</template>
</DataTable>
</template>
</Card>
<!-- Contact Dialog -->
<Dialog
v-model:visible="contactDialog"
:header="editingContact?.id ? 'Kontakt bearbeiten' : 'Neuer Kontakt'"
:modal="true"
:style="{ width: '1000px' }"
:closable="true"
class="contact-dialog"
>
<div v-if="editingContact">
<!-- Basisdaten -->
<Card class="mb-3">
<template #title>
<div class="flex align-items-center gap-2">
<i class="pi pi-building text-primary"></i>
<span>Basisdaten</span>
</div>
</template>
<template #content>
<div class="formgrid grid">
<div class="field col-12 md:col-8">
<label for="companyName" class="block mb-2 font-medium">
Firmenname <span class="text-red-500">*</span>
</label>
<InputText
id="companyName"
v-model="editingContact.companyName"
class="w-full"
:class="{ 'p-invalid': submitted && !editingContact.companyName }"
/>
<small v-if="submitted && !editingContact.companyName" class="p-error">
Firmenname ist erforderlich
</small>
</div>
<div class="field col-12 md:col-4">
<label for="companyNumber" class="block mb-2 font-medium">Kundennummer</label>
<InputText id="companyNumber" v-model="editingContact.companyNumber" class="w-full" />
</div>
<div class="field col-12">
<div class="flex gap-4">
<div class="field-checkbox mb-0">
<Checkbox
inputId="isDebtor"
v-model="editingContact.isDebtor"
:binary="true"
/>
<label for="isDebtor" class="ml-2">Debitor</label>
</div>
<div class="field-checkbox mb-0">
<Checkbox
inputId="isCreditor"
v-model="editingContact.isCreditor"
:binary="true"
/>
<label for="isCreditor" class="ml-2">Kreditor</label>
</div>
<div class="field-checkbox mb-0">
<Checkbox
inputId="isActive"
v-model="editingContact.isActive"
:binary="true"
/>
<label for="isActive" class="ml-2">Aktiv</label>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Adresse -->
<Card class="mb-3">
<template #title>
<div class="flex align-items-center gap-2">
<i class="pi pi-map-marker text-primary"></i>
<span>Adresse</span>
</div>
</template>
<template #content>
<div class="formgrid grid">
<div class="field col-12">
<label for="street" class="block mb-2 font-medium">Straße</label>
<InputText id="street" v-model="editingContact.street" class="w-full" />
</div>
<div class="field col-12 md:col-3">
<label for="zipCode" class="block mb-2 font-medium">PLZ</label>
<InputText id="zipCode" v-model="editingContact.zipCode" class="w-full" />
</div>
<div class="field col-12 md:col-5">
<label for="city" class="block mb-2 font-medium">Ort</label>
<InputText id="city" v-model="editingContact.city" class="w-full" />
</div>
<div class="field col-12 md:col-4">
<label for="country" class="block mb-2 font-medium">Land</label>
<InputText id="country" v-model="editingContact.country" class="w-full" />
</div>
</div>
</template>
</Card>
<!-- Kontaktdaten -->
<Card class="mb-3">
<template #title>
<div class="flex align-items-center gap-2">
<i class="pi pi-phone text-primary"></i>
<span>Kontaktdaten</span>
</div>
</template>
<template #content>
<div class="formgrid grid">
<div class="field col-12 md:col-6">
<label for="phone" class="block mb-2 font-medium">Telefon</label>
<InputText id="phone" v-model="editingContact.phone" class="w-full" />
</div>
<div class="field col-12 md:col-6">
<label for="fax" class="block mb-2 font-medium">Fax</label>
<InputText id="fax" v-model="editingContact.fax" class="w-full" />
</div>
<div class="field col-12 md:col-6">
<label for="email" class="block mb-2 font-medium">E-Mail</label>
<InputText id="email" v-model="editingContact.email" type="email" class="w-full" />
</div>
<div class="field col-12 md:col-6">
<label for="website" class="block mb-2 font-medium">Website</label>
<InputText id="website" v-model="editingContact.website" class="w-full" />
</div>
</div>
</template>
</Card>
<!-- Steuerdaten -->
<Card class="mb-3">
<template #title>
<div class="flex align-items-center gap-2">
<i class="pi pi-calculator text-primary"></i>
<span>Steuerdaten</span>
</div>
</template>
<template #content>
<div class="formgrid grid">
<div class="field col-12 md:col-6">
<label for="taxNumber" class="block mb-2 font-medium">Steuernummer</label>
<InputText id="taxNumber" v-model="editingContact.taxNumber" class="w-full" />
</div>
<div class="field col-12 md:col-6">
<label for="vatNumber" class="block mb-2 font-medium">USt-IdNr.</label>
<InputText id="vatNumber" v-model="editingContact.vatNumber" class="w-full" />
</div>
</div>
</template>
</Card>
<!-- Ansprechpartner -->
<Card class="mb-3">
<template #title>
<div class="flex align-items-center justify-content-between">
<div class="flex align-items-center gap-2">
<i class="pi pi-users text-primary"></i>
<span>Ansprechpartner</span>
</div>
<Button
label="Hinzufügen"
icon="pi pi-plus"
size="small"
outlined
@click="addContactPerson"
:disabled="editingContact.contactPersons.length >= 2"
/>
</div>
</template>
<template #content>
<div v-if="editingContact.contactPersons.length === 0" class="text-center py-4 text-500">
<i class="pi pi-user-plus text-4xl mb-3"></i>
<p>Noch keine Ansprechpartner hinzugefügt</p>
</div>
<div
v-for="(person, index) in editingContact.contactPersons"
:key="index"
class="mb-4"
>
<Divider v-if="index > 0" />
<div class="formgrid grid">
<div class="field col-12 flex justify-content-between align-items-center mb-3">
<h4 class="m-0 font-semibold">
<i class="pi pi-user mr-2"></i>
Ansprechpartner {{ index + 1 }}
<Tag v-if="person.isPrimary" value="Primär" severity="info" class="ml-2" />
</h4>
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
@click="removeContactPerson(index)"
size="small"
/>
</div>
<div class="field col-12 md:col-3">
<label :for="'salutation-' + index" class="block mb-2 font-medium">
Anrede
</label>
<Dropdown
:id="'salutation-' + index"
v-model="person.salutation"
:options="salutations"
placeholder="Wählen..."
class="w-full"
/>
</div>
<div class="field col-12 md:col-3">
<label :for="'title-' + index" class="block mb-2 font-medium">Titel</label>
<InputText :id="'title-' + index" v-model="person.title" class="w-full" />
</div>
<div class="field col-12 md:col-3">
<label :for="'firstName-' + index" class="block mb-2 font-medium">
Vorname <span class="text-red-500">*</span>
</label>
<InputText
:id="'firstName-' + index"
v-model="person.firstName"
class="w-full"
:class="{ 'p-invalid': submitted && !person.firstName }"
/>
</div>
<div class="field col-12 md:col-3">
<label :for="'lastName-' + index" class="block mb-2 font-medium">
Nachname <span class="text-red-500">*</span>
</label>
<InputText
:id="'lastName-' + index"
v-model="person.lastName"
class="w-full"
:class="{ 'p-invalid': submitted && !person.lastName }"
/>
</div>
<div class="field col-12 md:col-6">
<label :for="'position-' + index" class="block mb-2 font-medium">
Position
</label>
<InputText :id="'position-' + index" v-model="person.position" class="w-full" />
</div>
<div class="field col-12 md:col-6">
<label :for="'department-' + index" class="block mb-2 font-medium">
Abteilung
</label>
<InputText
:id="'department-' + index"
v-model="person.department"
class="w-full"
/>
</div>
<div class="field col-12 md:col-4">
<label :for="'personPhone-' + index" class="block mb-2 font-medium">
Telefon
</label>
<InputText
:id="'personPhone-' + index"
v-model="person.phone"
class="w-full"
/>
</div>
<div class="field col-12 md:col-4">
<label :for="'mobile-' + index" class="block mb-2 font-medium">Mobil</label>
<InputText :id="'mobile-' + index" v-model="person.mobile" class="w-full" />
</div>
<div class="field col-12 md:col-4">
<label :for="'personEmail-' + index" class="block mb-2 font-medium">
E-Mail
</label>
<InputText
:id="'personEmail-' + index"
v-model="person.email"
type="email"
class="w-full"
/>
</div>
<div class="field col-12">
<div class="field-checkbox mb-0">
<Checkbox
:inputId="'isPrimary-' + index"
v-model="person.isPrimary"
:binary="true"
/>
<label :for="'isPrimary-' + index" class="ml-2">
Hauptansprechpartner
</label>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Notizen -->
<Card>
<template #title>
<div class="flex align-items-center gap-2">
<i class="pi pi-comment text-primary"></i>
<span>Notizen</span>
</div>
</template>
<template #content>
<Textarea
id="notes"
v-model="editingContact.notes"
class="w-full"
:rows="4"
placeholder="Interne Notizen zu diesem Kontakt..."
/>
</template>
</Card>
</div>
<template #footer>
<Button label="Abbrechen" outlined @click="hideDialog" />
<Button label="Speichern" @click="saveContact" :loading="saving" />
</template>
</Dialog>
<!-- Delete Confirmation -->
<Dialog
v-model:visible="deleteDialog"
header="Bestätigung"
:modal="true"
:style="{ width: '450px' }"
>
<div class="flex align-items-center">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem; color: var(--red-500)"></i>
<span v-if="editingContact">
Möchten Sie den Kontakt <b>{{ editingContact.companyName }}</b> wirklich löschen?
</span>
</div>
<template #footer>
<Button label="Nein" outlined @click="deleteDialog = false" />
<Button label="Ja" severity="danger" @click="deleteContact" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Dialog from 'primevue/dialog'
import Card from 'primevue/card'
import Tag from 'primevue/tag'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'
import Divider from 'primevue/divider'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
const toast = useToast()
const contacts = ref([])
const loading = ref(false)
const contactDialog = ref(false)
const deleteDialog = ref(false)
const editingContact = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
const typeFilter = ref('all')
const globalFilter = ref('')
const salutations = ref(['Herr', 'Frau', 'Divers'])
const emptyContact = () => ({
companyName: '',
companyNumber: '',
street: '',
zipCode: '',
city: '',
country: 'Deutschland',
phone: '',
fax: '',
email: '',
website: '',
taxNumber: '',
vatNumber: '',
isDebtor: false,
isCreditor: false,
isActive: true,
notes: '',
contactPersons: []
})
const emptyContactPerson = () => ({
salutation: null,
title: '',
firstName: '',
lastName: '',
position: '',
department: '',
phone: '',
mobile: '',
email: '',
isPrimary: false
})
onMounted(() => {
loadContacts()
})
const loadContacts = async () => {
loading.value = true
try {
// API-Filter Parameter aufbauen
let url = '/api/contacts'
const params = new URLSearchParams()
if (typeFilter.value === 'debtor') {
params.append('isDebtor', 'true')
} else if (typeFilter.value === 'creditor') {
params.append('isCreditor', 'true')
}
if (params.toString()) {
url += '?' + params.toString()
}
const response = await fetch(url, {
credentials: 'include'
})
if (!response.ok) throw new Error('Fehler beim Laden der Kontakte')
const data = await response.json()
// API Platform gibt member zurück
contacts.value = data.member || []
console.log('Loaded contacts:', contacts.value.length, 'with filter:', typeFilter.value)
} catch (error) {
console.error('Error loading contacts:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Kontakte konnten nicht geladen werden',
life: 3000
})
} finally {
loading.value = false
}
}
const filterByType = (type) => {
typeFilter.value = type
loadContacts()
}
const openNewContactDialog = () => {
editingContact.value = emptyContact()
submitted.value = false
contactDialog.value = true
}
const editContact = (contact) => {
editingContact.value = { ...contact }
// Ensure contactPersons is initialized
if (!editingContact.value.contactPersons) {
editingContact.value.contactPersons = []
}
submitted.value = false
contactDialog.value = true
}
const hideDialog = () => {
contactDialog.value = false
submitted.value = false
editingContact.value = null
}
const addContactPerson = () => {
if (editingContact.value.contactPersons.length < 2) {
editingContact.value.contactPersons.push(emptyContactPerson())
}
}
const removeContactPerson = (index) => {
editingContact.value.contactPersons.splice(index, 1)
}
const validateContact = () => {
if (!editingContact.value.companyName) {
return false
}
// Validate contact persons
for (const person of editingContact.value.contactPersons) {
if (!person.firstName || !person.lastName) {
return false
}
}
return true
}
const saveContact = async () => {
submitted.value = true
if (!validateContact()) {
toast.add({
severity: 'warn',
summary: 'Validierung fehlgeschlagen',
detail: 'Bitte füllen Sie alle Pflichtfelder aus',
life: 3000
})
return
}
saving.value = true
try {
const url = editingContact.value.id
? `/api/contacts/${editingContact.value.id}`
: '/api/contacts'
const method = editingContact.value.id ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(editingContact.value)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Fehler beim Speichern')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Kontakt wurde gespeichert',
life: 3000
})
hideDialog()
loadContacts()
} catch (error) {
console.error('Error saving contact:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Kontakt konnte nicht gespeichert werden',
life: 3000
})
} finally {
saving.value = false
}
}
const confirmDelete = (contact) => {
editingContact.value = contact
deleteDialog.value = true
}
const deleteContact = async () => {
deleting.value = true
try {
const response = await fetch(`/api/contacts/${editingContact.value.id}`, {
method: 'DELETE',
credentials: 'include'
})
if (!response.ok) throw new Error('Fehler beim Löschen')
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Kontakt wurde gelöscht',
life: 3000
})
deleteDialog.value = false
editingContact.value = null
loadContacts()
} catch (error) {
console.error('Error deleting contact:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Kontakt konnte nicht gelöscht werden',
life: 3000
})
} finally {
deleting.value = false
}
}
</script>
<style scoped>
.contact-management {
padding: 1rem;
}
.p-invalid {
border-color: var(--red-500);
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
}
</style>

View File

@ -1,3 +0,0 @@
body {
background-color: skyblue;
}

View File

@ -1,145 +0,0 @@
/* Global styles for myCRM */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f9fafb;
color: #1f2937;
overflow-x: hidden;
}
#app {
width: 100%;
min-height: 100vh;
}
/* PrimeVue Component Styling Fixes */
.p-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.p-card-title {
color: #1f2937;
font-weight: 600;
font-size: 1rem;
@media (min-width: 768px) {
font-size: 1.125rem;
}
}
.p-card-content {
color: #4b5563;
font-size: 0.875rem;
@media (min-width: 768px) {
font-size: 1rem;
}
}
}
.p-datatable {
.p-datatable-header {
background: white;
border: 1px solid #e5e7eb;
}
.p-datatable-thead > tr > th {
background: #f9fafb;
color: #374151;
border: 1px solid #e5e7eb;
font-weight: 600;
}
.p-datatable-tbody > tr {
background: white;
color: #1f2937;
&:hover {
background: #f9fafb;
}
> td {
border: 1px solid #e5e7eb;
}
}
}
.p-button {
&.p-button-info {
background: #3b82f6;
border-color: #3b82f6;
&:hover {
background: #2563eb;
border-color: #2563eb;
}
}
&.p-button-danger {
background: #ef4444;
border-color: #ef4444;
&:hover {
background: #dc2626;
border-color: #dc2626;
}
}
}
.p-dialog {
.p-dialog-header {
background: white;
color: #1f2937;
}
.p-dialog-content {
background: white;
color: #1f2937;
}
}
.p-inputtext {
background: white;
color: #1f2937;
border: 1px solid #d1d5db;
&:enabled:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
}
.p-checkbox {
.p-checkbox-box {
background: white;
border: 1px solid #d1d5db;
&.p-highlight {
background: #3b82f6;
border-color: #3b82f6;
}
}
}
.p-tag {
&.p-tag-success {
background: #10b981;
}
&.p-tag-danger {
background: #ef4444;
}
&.p-tag-info {
background: #93c5fd;
color: #1e3a8a;
}
}

View File

@ -0,0 +1,29 @@
// Animation keyframes for StyleClass directive
@keyframes scalein {
0% {
opacity: 0;
transform: scaleY(0.8);
transition: transform 0.12s cubic-bezier(0, 0, 0.2, 1), opacity 0.12s cubic-bezier(0, 0, 0.2, 1);
}
100% {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-scalein {
animation: scalein 0.15s linear;
}
.animate-fadeout {
animation: fadeout 0.15s linear;
}

View File

@ -0,0 +1,24 @@
html {
height: 100%;
font-size: 14px;
line-height: 1.2;
}
body {
font-family: 'Lato', sans-serif;
color: var(--text-color);
background-color: var(--surface-ground);
margin: 0;
padding: 0;
min-height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
text-decoration: none;
}
.layout-wrapper {
min-height: 100vh;
}

View File

@ -0,0 +1,8 @@
.layout-footer {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0 1rem 0;
gap: 0.5rem;
border-top: 1px solid var(--surface-border);
}

View File

@ -0,0 +1,13 @@
.layout-main-container {
display: flex;
flex-direction: column;
min-height: 100vh;
justify-content: space-between;
padding: 6rem 2rem 0 2rem;
transition: margin-left var(--layout-section-transition-duration);
}
.layout-main {
flex: 1 1 auto;
padding-bottom: 2rem;
}

View File

@ -0,0 +1,160 @@
@use 'mixins' as *;
.layout-sidebar {
position: fixed;
width: 20rem;
height: calc(100vh - 8rem);
z-index: 999;
overflow-y: auto;
user-select: none;
top: 6rem;
left: 2rem;
transition:
transform var(--layout-section-transition-duration),
left var(--layout-section-transition-duration);
background-color: var(--surface-overlay);
border-radius: var(--content-border-radius);
padding: 0.5rem 1.5rem;
}
.layout-menu {
margin: 0;
padding: 0;
list-style-type: none;
.layout-root-menuitem {
> .layout-menuitem-root-text {
font-size: 0.857rem;
text-transform: uppercase;
font-weight: 700;
color: var(--text-color);
margin: 0.75rem 0;
}
> a {
display: none;
}
}
a {
user-select: none;
&.active-menuitem {
> .layout-submenu-toggler {
transform: rotate(-180deg);
}
}
}
li.active-menuitem {
> a {
.layout-submenu-toggler {
transform: rotate(-180deg);
}
}
}
ul {
margin: 0;
padding: 0;
list-style-type: none;
a {
display: flex;
align-items: center;
position: relative;
outline: 0 none;
color: var(--text-color);
cursor: pointer;
padding: 0.75rem 1rem;
border-radius: var(--content-border-radius);
transition:
background-color var(--element-transition-duration),
box-shadow var(--element-transition-duration);
.layout-menuitem-icon {
margin-right: 0.5rem;
}
.layout-submenu-toggler {
font-size: 75%;
margin-left: auto;
transition: transform var(--element-transition-duration);
}
&.active-route {
font-weight: 700;
color: var(--primary-color);
}
&:hover {
background-color: var(--surface-hover);
}
&:focus {
@include focused-inset();
}
}
ul {
overflow: hidden;
border-radius: var(--content-border-radius);
li {
a {
margin-left: 1rem;
}
li {
a {
margin-left: 2rem;
}
li {
a {
margin-left: 2.5rem;
}
li {
a {
margin-left: 3rem;
}
li {
a {
margin-left: 3.5rem;
}
li {
a {
margin-left: 4rem;
}
}
}
}
}
}
}
}
}
}
.layout-submenu-enter-from,
.layout-submenu-leave-to {
max-height: 0;
}
.layout-submenu-enter-to,
.layout-submenu-leave-from {
max-height: 1000px;
}
.layout-submenu-leave-active {
overflow: hidden;
transition: max-height 0.45s cubic-bezier(0, 1, 0, 1);
}
.layout-submenu-enter-active {
overflow: hidden;
transition: max-height 1s ease-in-out;
}

View File

@ -0,0 +1,15 @@
@mixin focused() {
outline-width: var(--focus-ring-width);
outline-style: var(--focus-ring-style);
outline-color: var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
box-shadow: var(--focus-ring-shadow);
transition:
box-shadow var(--transition-duration),
outline-color var(--transition-duration);
}
@mixin focused-inset() {
outline-offset: -1px;
box-shadow: inset var(--focus-ring-shadow);
}

View File

@ -0,0 +1,47 @@
.preloader {
position: fixed;
z-index: 999999;
background: #edf1f5;
width: 100%;
height: 100%;
}
.preloader-content {
border: 0 solid transparent;
border-radius: 50%;
width: 150px;
height: 150px;
position: absolute;
top: calc(50vh - 75px);
left: calc(50vw - 75px);
}
.preloader-content:before, .preloader-content:after{
content: '';
border: 1em solid var(--primary-color);
border-radius: 50%;
width: inherit;
height: inherit;
position: absolute;
top: 0;
left: 0;
animation: loader 2s linear infinite;
opacity: 0;
}
.preloader-content:before{
animation-delay: 0.5s;
}
@keyframes loader{
0%{
transform: scale(0);
opacity: 0;
}
50%{
opacity: 1;
}
100%{
transform: scale(1);
opacity: 0;
}
}

View File

@ -0,0 +1,110 @@
@media screen and (min-width: 1960px) {
.layout-main,
.landing-wrapper {
width: 1504px;
margin-left: auto !important;
margin-right: auto !important;
}
}
@media (min-width: 992px) {
.layout-wrapper {
&.layout-overlay {
.layout-main-container {
margin-left: 0;
padding-left: 2rem;
}
.layout-sidebar {
transform: translateX(-100%);
left: 0;
top: 0;
height: 100vh;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-right: 1px solid var(--surface-border);
transition:
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
box-shadow:
0px 3px 5px rgba(0, 0, 0, 0.02),
0px 0px 2px rgba(0, 0, 0, 0.05),
0px 1px 4px rgba(0, 0, 0, 0.08);
}
&.layout-overlay-active {
.layout-sidebar {
transform: translateX(0);
}
}
}
&.layout-static {
.layout-main-container {
margin-left: 22rem;
}
&.layout-static-inactive {
.layout-sidebar {
transform: translateX(-100%);
left: 0;
}
.layout-main-container {
margin-left: 0;
padding-left: 2rem;
}
}
}
.layout-mask {
display: none;
}
}
}
@media (max-width: 991px) {
.blocked-scroll {
overflow: hidden;
}
.layout-wrapper {
.layout-main-container {
margin-left: 0;
padding-left: 2rem;
}
.layout-sidebar {
transform: translateX(-100%);
left: 0;
top: 0;
height: 100vh;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
transition:
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
}
.layout-mask {
display: none;
position: fixed;
top: 0;
left: 0;
z-index: 998;
width: 100%;
height: 100%;
background-color: var(--maskbg);
}
&.layout-mobile-active {
.layout-sidebar {
transform: translateX(0);
}
.layout-mask {
display: block;
}
}
}
}

View File

@ -0,0 +1,201 @@
@use 'mixins' as *;
.layout-topbar {
position: fixed;
height: 4rem;
z-index: 997;
left: 0;
top: 0;
width: 100%;
padding: 0 2rem;
background-color: var(--surface-card);
transition: left var(--layout-section-transition-duration);
display: flex;
align-items: center;
.layout-topbar-logo-container {
width: 20rem;
display: flex;
align-items: center;
}
.layout-topbar-logo {
display: inline-flex;
align-items: center;
font-size: 1.5rem;
border-radius: var(--content-border-radius);
color: var(--text-color);
font-weight: 500;
gap: 0.5rem;
svg {
width: 3rem;
}
&:focus-visible {
@include focused();
}
}
.layout-topbar-action {
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
color: var(--text-color);
transition: background-color var(--element-transition-duration);
cursor: pointer;
&:hover {
background-color: var(--surface-hover);
}
&:focus-visible {
@include focused();
}
i {
font-size: 1.25rem;
}
span {
font-size: 1rem;
display: none;
}
&.layout-topbar-action-highlight {
background-color: var(--primary-color);
color: var(--primary-contrast-color);
}
}
.layout-menu-button {
margin-right: 0.5rem;
}
.layout-topbar-menu-button {
display: none;
}
.layout-topbar-actions {
margin-left: auto;
display: flex;
gap: 1rem;
}
.layout-topbar-menu-content {
display: flex;
gap: 1rem;
}
.layout-config-menu {
display: flex;
gap: 1rem;
}
}
@media (max-width: 991px) {
.layout-topbar {
padding: 0 2rem;
.layout-topbar-logo-container {
width: auto;
}
.layout-menu-button {
margin-left: 0;
margin-right: 0.5rem;
}
.layout-topbar-menu-button {
display: inline-flex;
}
.layout-topbar-menu {
position: absolute;
background-color: var(--surface-overlay);
transform-origin: top;
box-shadow:
0px 3px 5px rgba(0, 0, 0, 0.02),
0px 0px 2px rgba(0, 0, 0, 0.05),
0px 1px 4px rgba(0, 0, 0, 0.08);
border-radius: var(--content-border-radius);
padding: 1rem;
right: 2rem;
top: 4rem;
min-width: 15rem;
border: 1px solid var(--surface-border);
.layout-topbar-menu-content {
gap: 0.5rem;
}
.layout-topbar-action {
display: flex;
width: 100%;
height: auto;
justify-content: flex-start;
border-radius: var(--content-border-radius);
padding: 0.5rem 1rem;
i {
font-size: 1rem;
margin-right: 0.5rem;
}
span {
font-weight: medium;
display: block;
}
}
}
.layout-topbar-menu-content {
flex-direction: column;
}
}
}
.config-panel {
.config-panel-label {
font-size: 0.875rem;
color: var(--text-secondary-color);
font-weight: 600;
line-height: 1;
}
.config-panel-colors {
> div {
padding-top: 0.5rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: space-between;
button {
border: none;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
padding: 0;
cursor: pointer;
outline-color: transparent;
outline-width: 2px;
outline-style: solid;
outline-offset: 1px;
&.active-color {
outline-color: var(--primary-color);
}
}
}
}
.config-panel-settings {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}

View File

@ -0,0 +1,68 @@
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1.5rem 0 1rem 0;
font-family: inherit;
font-weight: 700;
line-height: 1.5;
color: var(--text-color);
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
mark {
background: #fff8e1;
padding: 0.25rem 0.4rem;
border-radius: var(--content-border-radius);
font-family: monospace;
}
blockquote {
margin: 1rem 0;
padding: 0 2rem;
border-left: 4px solid #90a4ae;
}
hr {
border-top: solid var(--surface-border);
border-width: 1px 0 0 0;
margin: 1rem 0;
}
p {
margin: 0 0 1rem 0;
line-height: 1.5;
&:last-child {
margin-bottom: 0;
}
}

View File

@ -0,0 +1,25 @@
/* Utils */
.clearfix:after {
content: ' ';
display: block;
clear: both;
}
.card {
background: var(--surface-card);
padding: 2rem;
margin-bottom: 2rem;
border-radius: var(--content-border-radius);
&:last-child {
margin-bottom: 0;
}
}
.p-toast {
&.p-toast-top-right,
&.p-toast-top-left,
&.p-toast-top-center {
top: 100px;
}
}

View File

@ -0,0 +1,14 @@
@use './variables/_common';
@use './variables/_light';
@use './variables/_dark';
@use './_mixins';
@use './_animations';
@use './_preloading';
@use './_core';
@use './_main';
@use './_topbar';
@use './_menu';
@use './_footer';
@use './_responsive';
@use './_utils';
@use './_typography';

View File

@ -0,0 +1,20 @@
:root {
--primary-color: var(--p-primary-color);
--primary-contrast-color: var(--p-primary-contrast-color);
--text-color: var(--p-text-color);
--text-color-secondary: var(--p-text-muted-color);
--surface-border: var(--p-content-border-color);
--surface-card: var(--p-content-background);
--surface-hover: var(--p-content-hover-background);
--surface-overlay: var(--p-overlay-popover-background);
--transition-duration: var(--p-transition-duration);
--maskbg: var(--p-mask-background);
--content-border-radius: var(--p-content-border-radius);
--layout-section-transition-duration: 0.2s;
--element-transition-duration: var(--p-transition-duration);
--focus-ring-width: var(--p-focus-ring-width);
--focus-ring-style: var(--p-focus-ring-style);
--focus-ring-color: var(--p-focus-ring-color);
--focus-ring-offset: var(--p-focus-ring-offset);
--focus-ring-shadow: var(--p-focus-ring-shadow);
}

View File

@ -0,0 +1,5 @@
:root[class*='app-dark'] {
--surface-ground: var(--p-surface-950);
--code-background: var(--p-surface-800);
--code-color: var(--p-surface-100);
}

View File

@ -0,0 +1,5 @@
:root {
--surface-ground: var(--p-surface-100);
--code-background: var(--p-surface-900);
--code-color: var(--p-surface-200);
}

3
assets/styles/sakai.scss Normal file
View File

@ -0,0 +1,3 @@
/* Sakai Layout Styles */
@import 'primeicons/primeicons.css';
@import './layout/layout.scss';

View File

@ -0,0 +1,32 @@
@import 'tailwindcss';
@plugin 'tailwindcss-primeui';
@custom-variant dark (&:where([class*="app-dark"], [class*="app-dark"] *));
@theme {
--breakpoint-*: initial;
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-2xl: 1920px;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@ -103,7 +103,7 @@
} }
}, },
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "*",
"phpunit/phpunit": "^12.4", "phpunit/phpunit": "^12.4",
"symfony/browser-kit": "7.1.*", "symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*", "symfony/css-selector": "7.1.*",

2
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "10b55ae5547ef383b9e8a94a7ca7402c", "content-hash": "af57c523401fba0e523501b76e0629f0",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251108175514 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE contact_persons (id INT AUTO_INCREMENT NOT NULL, contact_id INT NOT NULL, salutation VARCHAR(20) DEFAULT NULL, title VARCHAR(100) DEFAULT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, position VARCHAR(100) DEFAULT NULL, department VARCHAR(100) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, mobile VARCHAR(50) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, is_primary TINYINT(1) NOT NULL, INDEX IDX_3873E652E7A1254A (contact_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE contacts (id INT AUTO_INCREMENT NOT NULL, company_name VARCHAR(255) NOT NULL, company_number VARCHAR(50) DEFAULT NULL, street LONGTEXT DEFAULT NULL, zip_code VARCHAR(20) DEFAULT NULL, city VARCHAR(100) DEFAULT NULL, country VARCHAR(100) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, fax VARCHAR(50) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, tax_number VARCHAR(50) DEFAULT NULL, vat_number VARCHAR(50) DEFAULT NULL, is_debtor TINYINT(1) NOT NULL, is_creditor TINYINT(1) NOT NULL, is_active TINYINT(1) NOT NULL, notes LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE contact_persons ADD CONSTRAINT FK_3873E652E7A1254A FOREIGN KEY (contact_id) REFERENCES contacts (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contact_persons DROP FOREIGN KEY FK_3873E652E7A1254A');
$this->addSql('DROP TABLE contact_persons');
$this->addSql('DROP TABLE contacts');
}
}

1733
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,17 @@
"@babel/core": "^7.17.0", "@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^5.1.0", "@symfony/webpack-encore": "^5.1.0",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17",
"autoprefixer": "^10.4.21",
"core-js": "^3.38.0", "core-js": "^3.38.0",
"postcss": "^8.4.0", "postcss": "^8.5.6",
"postcss-loader": "^7.0.0", "postcss-loader": "^7.0.0",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"sass": "^1.70.0", "sass": "^1.70.0",
"sass-loader": "^16.0.0", "sass-loader": "^16.0.0",
"tailwindcss": "^4.1.17",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-loader": "^17.4.0", "vue-loader": "^17.4.0",
"webpack": "^5.74.0", "webpack": "^5.74.0",
@ -17,7 +22,6 @@
"dependencies": { "dependencies": {
"@primevue/themes": "^4.4.1", "@primevue/themes": "^4.4.1",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.0", "primevue": "^4.3.0",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,226 @@
<?php
namespace App\DataFixtures;
use App\Entity\Contact;
use App\Entity\ContactPerson;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class ContactFixtures extends Fixture
{
private array $germanCities = [
['Berlin', '10115', 'Berlin'],
['Hamburg', '20095', 'Hamburg'],
['München', '80331', 'Bayern'],
['Köln', '50667', 'Nordrhein-Westfalen'],
['Frankfurt', '60311', 'Hessen'],
['Stuttgart', '70173', 'Baden-Württemberg'],
['Düsseldorf', '40210', 'Nordrhein-Westfalen'],
['Dortmund', '44135', 'Nordrhein-Westfalen'],
['Essen', '45127', 'Nordrhein-Westfalen'],
['Leipzig', '04109', 'Sachsen'],
['Bremen', '28195', 'Bremen'],
['Dresden', '01067', 'Sachsen'],
['Hannover', '30159', 'Niedersachsen'],
['Nürnberg', '90402', 'Bayern'],
['Duisburg', '47051', 'Nordrhein-Westfalen'],
['Bochum', '44787', 'Nordrhein-Westfalen'],
['Wuppertal', '42103', 'Nordrhein-Westfalen'],
['Bielefeld', '33602', 'Nordrhein-Westfalen'],
['Bonn', '53111', 'Nordrhein-Westfalen'],
['Münster', '48143', 'Nordrhein-Westfalen'],
['Karlsruhe', '76133', 'Baden-Württemberg'],
['Mannheim', '68159', 'Baden-Württemberg'],
['Augsburg', '86150', 'Bayern'],
['Wiesbaden', '65183', 'Hessen'],
['Gelsenkirchen', '45879', 'Nordrhein-Westfalen'],
['Mönchengladbach', '41061', 'Nordrhein-Westfalen'],
['Braunschweig', '38100', 'Niedersachsen'],
['Chemnitz', '09111', 'Sachsen'],
['Kiel', '24103', 'Schleswig-Holstein'],
['Aachen', '52062', 'Nordrhein-Westfalen'],
];
private array $companyTypes = [
'GmbH', 'AG', 'GmbH & Co. KG', 'UG', 'e.K.', 'OHG', 'KG'
];
private array $industries = [
'IT-Dienstleistungen',
'Softwareentwicklung',
'Maschinenbau',
'Handel',
'Consulting',
'Marketing',
'Logistik',
'Produktion',
'Automotive',
'Elektrotechnik',
'Bauwesen',
'Immobilien',
'Finanzdienstleistungen',
'Versicherungen',
'Gesundheitswesen',
'Gastronomie',
'Einzelhandel',
'Großhandel',
'Transport',
'Medien',
];
private array $companyNames = [
'Innovate', 'TechCorp', 'Solutions', 'Systems', 'Digital', 'Smart',
'Global', 'Profi', 'Expert', 'Premium', 'Best', 'Top', 'Master',
'Alpha', 'Beta', 'Omega', 'Prime', 'Elite', 'Select', 'First',
'Advanced', 'Future', 'Next', 'Modern', 'Dynamic', 'Rapid', 'Swift',
'Quality', 'Perfect', 'Optimal', 'Excellent', 'Superior', 'Mega',
];
private array $firstNames = [
'Thomas', 'Michael', 'Andreas', 'Peter', 'Wolfgang', 'Klaus', 'Jürgen',
'Anna', 'Maria', 'Sandra', 'Julia', 'Petra', 'Sabine', 'Claudia',
'Christian', 'Stefan', 'Markus', 'Daniel', 'Martin', 'Frank',
'Nicole', 'Katrin', 'Susanne', 'Martina', 'Stefanie', 'Andrea',
'Matthias', 'Alexander', 'Sebastian', 'Tobias', 'Jan', 'Patrick',
];
private array $lastNames = [
'Müller', 'Schmidt', 'Schneider', 'Fischer', 'Weber', 'Meyer',
'Wagner', 'Becker', 'Schulz', 'Hoffmann', 'Schäfer', 'Koch',
'Bauer', 'Richter', 'Klein', 'Wolf', 'Schröder', 'Neumann',
'Schwarz', 'Zimmermann', 'Braun', 'Krüger', 'Hofmann', 'Hartmann',
'Lange', 'Schmitt', 'Werner', 'Schmitz', 'Krause', 'Meier',
];
private array $streets = [
'Hauptstraße', 'Bahnhofstraße', 'Gartenstraße', 'Bergstraße', 'Dorfstraße',
'Schulstraße', 'Kirchstraße', 'Waldstraße', 'Marktplatz', 'Ringstraße',
'Parkstraße', 'Lindenstraße', 'Rosenweg', 'Am Markt', 'Mühlenweg',
];
private array $positions = [
'Geschäftsführer', 'Geschäftsführerin', 'Vertriebsleiter', 'Vertriebsleiterin',
'Einkaufsleiter', 'Einkaufsleiterin', 'Prokurist', 'Prokuristin',
'Head of Sales', 'Verkaufsleiter', 'Verkaufsleiterin', 'Projektleiter',
'Projektleiterin', 'Abteilungsleiter', 'Abteilungsleiterin',
];
private array $departments = [
'Vertrieb', 'Einkauf', 'Marketing', 'IT', 'Personal', 'Buchhaltung',
'Controlling', 'Produktion', 'Qualitätssicherung', 'Logistik',
];
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 200; $i++) {
$contact = new Contact();
// Firmenname
$companyName = $this->companyNames[array_rand($this->companyNames)] . ' ' .
$this->industries[array_rand($this->industries)];
$companyType = $this->companyTypes[array_rand($this->companyTypes)];
$contact->setCompanyName($companyName . ' ' . $companyType);
// Kundennummer
$contact->setCompanyNumber('K' . str_pad($i, 5, '0', STR_PAD_LEFT));
// Adresse
$city = $this->germanCities[array_rand($this->germanCities)];
$street = $this->streets[array_rand($this->streets)];
$contact->setStreet($street . ' ' . rand(1, 150));
$contact->setZipCode($city[1]);
$contact->setCity($city[0]);
$contact->setCountry('Deutschland');
// Kontaktdaten
$contact->setPhone('0' . rand(100, 999) . ' ' . rand(10000, 99999));
if (rand(0, 3) > 0) {
$contact->setFax('0' . rand(100, 999) . ' ' . rand(10000, 99999));
}
$domain = strtolower(str_replace(' ', '', $companyName));
$domain = preg_replace('/[^a-z0-9]/', '', $domain);
$contact->setEmail('info@' . substr($domain, 0, 20) . '.de');
$contact->setWebsite('https://www.' . substr($domain, 0, 20) . '.de');
// Steuernummern
if (rand(0, 1)) {
$contact->setTaxNumber(rand(10, 99) . '/' . rand(100, 999) . '/' . rand(10000, 99999));
}
if (rand(0, 1)) {
$contact->setVatNumber('DE' . rand(100000000, 999999999));
}
// Typ
$type = rand(1, 10);
if ($type <= 6) {
$contact->setIsDebtor(true);
} elseif ($type <= 8) {
$contact->setIsCreditor(true);
} else {
$contact->setIsDebtor(true);
$contact->setIsCreditor(true);
}
// Status
$contact->setIsActive(rand(0, 10) > 0); // 90% aktiv
// Notizen
if (rand(0, 3) === 0) {
$notes = [
'Wichtiger Kunde, bevorzugte Behandlung',
'Zahlt pünktlich',
'Benötigt ausführliche Beratung',
'Großkunde mit Sonderkonditionen',
'Neukunde seit ' . date('Y'),
'Jahresvertrag läuft noch bis ' . date('m.Y', strtotime('+' . rand(1, 12) . ' months')),
];
$contact->setNotes($notes[array_rand($notes)]);
}
// Ansprechpartner (1-2 pro Firma)
$numPersons = rand(1, 2);
for ($p = 0; $p < $numPersons; $p++) {
$person = new ContactPerson();
$gender = rand(0, 1);
$person->setSalutation($gender ? 'Herr' : 'Frau');
if (rand(0, 5) === 0) {
$person->setTitle(rand(0, 1) ? 'Dr.' : 'Prof. Dr.');
}
$person->setFirstName($this->firstNames[array_rand($this->firstNames)]);
$person->setLastName($this->lastNames[array_rand($this->lastNames)]);
$person->setPosition($this->positions[array_rand($this->positions)]);
$person->setDepartment($this->departments[array_rand($this->departments)]);
$person->setPhone('0' . rand(100, 999) . ' ' . rand(10000, 99999) . '-' . rand(10, 99));
if (rand(0, 2) > 0) {
$person->setMobile('0' . rand(150, 179) . ' ' . rand(1000000, 9999999));
}
$firstName = strtolower($person->getFirstName());
$lastName = strtolower($person->getLastName());
$person->setEmail($firstName . '.' . $lastName . '@' . substr($domain, 0, 20) . '.de');
$person->setIsPrimary($p === 0);
$contact->addContactPerson($person);
$manager->persist($person);
}
$manager->persist($contact);
// Batch-Verarbeitung für bessere Performance
if (($i % 50) === 0) {
$manager->flush();
$manager->clear();
}
}
$manager->flush();
}
}

394
src/Entity/Contact.php Normal file
View File

@ -0,0 +1,394 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Repository\ContactRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ContactRepository::class)]
#[ORM\Table(name: 'contacts')]
#[ApiResource(
operations: [
new GetCollection(stateless: false),
new Get(stateless: false),
new Post(security: "is_granted('ROLE_USER')", stateless: false),
new Put(security: "is_granted('ROLE_USER')", stateless: false),
new Delete(security: "is_granted('ROLE_ADMIN')", stateless: false)
],
normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']],
order: ['companyName' => 'ASC']
)]
#[ApiFilter(BooleanFilter::class, properties: ['isDebtor', 'isCreditor', 'isActive'])]
#[ApiFilter(SearchFilter::class, properties: ['companyName' => 'partial', 'city' => 'partial', 'email' => 'partial'])]
class Contact
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contact:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\NotBlank(message: 'Der Firmenname darf nicht leer sein')]
#[Assert\Length(max: 255)]
private ?string $companyName = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $companyNumber = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
private ?string $street = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 20)]
private ?string $zipCode = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 100)]
private ?string $city = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 100)]
private ?string $country = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $phone = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $fax = null;
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Email(message: 'Bitte geben Sie eine gültige E-Mail-Adresse ein')]
#[Assert\Length(max: 180)]
private ?string $email = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Url(message: 'Bitte geben Sie eine gültige URL ein')]
#[Assert\Length(max: 255)]
private ?string $website = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $taxNumber = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $vatNumber = null;
#[ORM\Column]
#[Groups(['contact:read', 'contact:write'])]
private bool $isDebtor = false;
#[ORM\Column]
#[Groups(['contact:read', 'contact:write'])]
private bool $isCreditor = false;
#[ORM\Column]
#[Groups(['contact:read', 'contact:write'])]
private bool $isActive = true;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
private ?string $notes = null;
#[ORM\OneToMany(targetEntity: ContactPerson::class, mappedBy: 'contact', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['contact:read', 'contact:write'])]
private Collection $contactPersons;
#[ORM\Column]
#[Groups(['contact:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
#[Groups(['contact:read'])]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->contactPersons = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getCompanyName(): ?string
{
return $this->companyName;
}
public function setCompanyName(string $companyName): static
{
$this->companyName = $companyName;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getCompanyNumber(): ?string
{
return $this->companyNumber;
}
public function setCompanyNumber(?string $companyNumber): static
{
$this->companyNumber = $companyNumber;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getZipCode(): ?string
{
return $this->zipCode;
}
public function setZipCode(?string $zipCode): static
{
$this->zipCode = $zipCode;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): static
{
$this->country = $country;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getFax(): ?string
{
return $this->fax;
}
public function setFax(?string $fax): static
{
$this->fax = $fax;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getTaxNumber(): ?string
{
return $this->taxNumber;
}
public function setTaxNumber(?string $taxNumber): static
{
$this->taxNumber = $taxNumber;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getVatNumber(): ?string
{
return $this->vatNumber;
}
public function setVatNumber(?string $vatNumber): static
{
$this->vatNumber = $vatNumber;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function isDebtor(): bool
{
return $this->isDebtor;
}
public function setIsDebtor(bool $isDebtor): static
{
$this->isDebtor = $isDebtor;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function isCreditor(): bool
{
return $this->isCreditor;
}
public function setIsCreditor(bool $isCreditor): static
{
$this->isCreditor = $isCreditor;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getIsActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
/**
* @return Collection<int, ContactPerson>
*/
public function getContactPersons(): Collection
{
return $this->contactPersons;
}
public function addContactPerson(ContactPerson $contactPerson): static
{
if (!$this->contactPersons->contains($contactPerson)) {
$this->contactPersons->add($contactPerson);
$contactPerson->setContact($this);
}
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function removeContactPerson(ContactPerson $contactPerson): static
{
if ($this->contactPersons->removeElement($contactPerson)) {
if ($contactPerson->getContact() === $this) {
$contactPerson->setContact(null);
}
}
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function __toString(): string
{
return $this->companyName ?? '';
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ORM\Table(name: 'contact_persons')]
#[ApiResource(
operations: [],
normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']]
)]
class ContactPerson
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contact:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Contact::class, inversedBy: 'contactPersons')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Contact $contact = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 20)]
private ?string $salutation = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 100)]
private ?string $title = null;
#[ORM\Column(length: 100)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\NotBlank(message: 'Der Vorname darf nicht leer sein')]
#[Assert\Length(max: 100)]
private ?string $firstName = null;
#[ORM\Column(length: 100)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\NotBlank(message: 'Der Nachname darf nicht leer sein')]
#[Assert\Length(max: 100)]
private ?string $lastName = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 100)]
private ?string $position = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 100)]
private ?string $department = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $phone = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Length(max: 50)]
private ?string $mobile = null;
#[ORM\Column(length: 180, nullable: true)]
#[Groups(['contact:read', 'contact:write'])]
#[Assert\Email(message: 'Bitte geben Sie eine gültige E-Mail-Adresse ein')]
#[Assert\Length(max: 180)]
private ?string $email = null;
#[ORM\Column]
#[Groups(['contact:read', 'contact:write'])]
private bool $isPrimary = false;
public function getId(): ?int
{
return $this->id;
}
public function getContact(): ?Contact
{
return $this->contact;
}
public function setContact(?Contact $contact): static
{
$this->contact = $contact;
return $this;
}
public function getSalutation(): ?string
{
return $this->salutation;
}
public function setSalutation(?string $salutation): static
{
$this->salutation = $salutation;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getPosition(): ?string
{
return $this->position;
}
public function setPosition(?string $position): static
{
$this->position = $position;
return $this;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): static
{
$this->department = $department;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): static
{
$this->mobile = $mobile;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getIsPrimary(): bool
{
return $this->isPrimary;
}
public function setIsPrimary(bool $isPrimary): static
{
$this->isPrimary = $isPrimary;
return $this;
}
public function getFullName(): string
{
return trim(($this->title ? $this->title . ' ' : '') . $this->firstName . ' ' . $this->lastName);
}
public function __toString(): string
{
return $this->getFullName();
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Repository;
use App\Entity\Contact;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Contact>
*/
class ContactRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Contact::class);
}
/**
* Find contacts by type (debtor, creditor, or both)
*/
public function findByType(?bool $isDebtor = null, ?bool $isCreditor = null): array
{
$qb = $this->createQueryBuilder('c')
->where('c.isActive = :active')
->setParameter('active', true)
->orderBy('c.companyName', 'ASC');
if ($isDebtor !== null) {
$qb->andWhere('c.isDebtor = :isDebtor')
->setParameter('isDebtor', $isDebtor);
}
if ($isCreditor !== null) {
$qb->andWhere('c.isCreditor = :isCreditor')
->setParameter('isCreditor', $isCreditor);
}
return $qb->getQuery()->getResult();
}
/**
* Search contacts by company name, contact person, or email
*/
public function search(string $searchTerm): array
{
return $this->createQueryBuilder('c')
->leftJoin('c.contactPersons', 'cp')
->where('c.companyName LIKE :search')
->orWhere('c.email LIKE :search')
->orWhere('c.city LIKE :search')
->orWhere('cp.firstName LIKE :search')
->orWhere('cp.lastName LIKE :search')
->orWhere('cp.email LIKE :search')
->setParameter('search', '%' . $searchTerm . '%')
->orderBy('c.companyName', 'ASC')
->getQuery()
->getResult();
}
}

14
tailwind.config.js Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./assets/**/*.{js,vue}",
"./templates/**/*.html.twig",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [
require('tailwindcss-primeui')
],
}