Compare commits
2 Commits
cd3eb6afed
...
b84dc6c6e9
| Author | SHA1 | Date | |
|---|---|---|---|
| b84dc6c6e9 | |||
| 5d3bffbbad |
13
.env
13
.env
@ -44,7 +44,11 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=smtp://o.schwarten@osdata.org:pOlygon089@linus.osdata.org:587
|
||||
# Examples:
|
||||
# MAILER_DSN=smtp://user:pass@smtp.example.com:587
|
||||
# MAILER_DSN=gmail://username:password@default
|
||||
# MAILER_DSN=null://null (for testing without sending)
|
||||
MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
@ -53,8 +57,9 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
###> knpuniversity/oauth2-client-bundle ###
|
||||
# Pocket-ID OIDC Configuration
|
||||
OAUTH_POCKET_ID_URL=https://id.osdata-home.de
|
||||
OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340
|
||||
OAUTH_POCKET_ID_CLIENT_SECRET=K5N5qErjqMCM9zG7y0xPETt8FgidUN93
|
||||
# Get your credentials from your Pocket-ID instance
|
||||
OAUTH_POCKET_ID_URL=https://your-pocket-id-instance.com
|
||||
OAUTH_POCKET_ID_CLIENT_ID=your-client-id
|
||||
OAUTH_POCKET_ID_CLIENT_SECRET=your-client-secret
|
||||
OAUTH_POCKET_ID_REDIRECT_URI=http://localhost:8000/connect/pocketid/check
|
||||
###< knpuniversity/oauth2-client-bundle ###
|
||||
|
||||
219
CONTACTS.md
Normal file
219
CONTACTS.md
Normal 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)
|
||||
58
README.md
58
README.md
@ -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
|
||||
- **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
|
||||
- **MariaDB** - Zuverlässige relationale Datenbank
|
||||
- **Webpack Encore** - Asset-Management und Hot Module Replacement
|
||||
- **Modulares Berechtigungssystem** - Flexible Rollen mit Modul-basierter Rechteverwaltung
|
||||
- **User CRUD** - Vollständige Benutzerverwaltung via API Platform
|
||||
- **Login-System** - Form-basierte Authentifizierung mit Remember Me
|
||||
- **Kontaktmodul** - Firmenstammdaten mit Ansprechpersonen und Debitor/Kreditor-Klassifizierung
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
@ -101,18 +104,23 @@ npm run watch
|
||||
/assets
|
||||
/js
|
||||
/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
|
||||
/stores - Pinia State Management
|
||||
/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
|
||||
/src
|
||||
/Controller - HTTP Controllers
|
||||
/Entity - Doctrine Entities
|
||||
/Entity - Doctrine Entities (User, Role, Contact, ContactPerson)
|
||||
/Repository - Database Queries
|
||||
/Service - Business Logic
|
||||
/Security/Voter - Permission Logic
|
||||
/DataFixtures - Test-Daten (200 Kontakte mit realistischen deutschen Daten)
|
||||
/templates - Twig Templates
|
||||
/public - Public Assets & Entry Point
|
||||
/migrations - Doctrine Migrations
|
||||
@ -131,7 +139,9 @@ npm run watch
|
||||
- **Vue.js 3** - Progressive JavaScript Framework
|
||||
- **Vue Router** - SPA Navigation
|
||||
- **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
|
||||
|
||||
### Database
|
||||
@ -140,11 +150,18 @@ npm run watch
|
||||
## 📱 Module
|
||||
|
||||
- **Dashboard** - Übersicht und KPIs
|
||||
- **Kontakte** - Kontaktverwaltung mit Status-Tracking (in Entwicklung)
|
||||
- **Unternehmen** - Firmendatenbank (in Entwicklung)
|
||||
- **Kontakte** - Firmenstammdatenverwaltung mit Ansprechpersonen (✅ implementiert)
|
||||
- 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)
|
||||
- **Aktivitäten** - Interaktions-Historie (in Entwicklung)
|
||||
- **Benutzerverwaltung** - CRUD für User (✅ implementiert)
|
||||
- **Rollenverwaltung** - Modulare Berechtigungen (✅ implementiert)
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
@ -197,20 +214,31 @@ Dein Team
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Grundsystem implementiert - Ready for CRM-Module!
|
||||
**Status:** ✅ Grundsystem + Kontaktmodul implementiert - Ready for weitere CRM-Module!
|
||||
|
||||
**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)
|
||||
- ✅ Login-System mit Remember Me
|
||||
- ✅ 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
|
||||
- ✅ Password-Hashing via State Processor
|
||||
- ✅ Admin-Navigation und Schutz
|
||||
- ✅ Dark Mode Support
|
||||
- ✅ Responsive Design mit Sakai Layout
|
||||
|
||||
**Next Steps:**
|
||||
1. Contact-Entity erstellen: `php bin/console make:entity Contact`
|
||||
2. Company-Entity erstellen: `php bin/console make:entity Company`
|
||||
3. Deal-Entity mit Pipeline-Stages erstellen
|
||||
4. Activity-Entity für Interaktionshistorie
|
||||
5. Vue.js-Komponenten für Contact/Company/Deal-Management
|
||||
1. Deal-Entity mit Pipeline-Stages erstellen
|
||||
2. Activity-Entity für Interaktionshistorie
|
||||
3. Dashboard mit KPIs und Charts
|
||||
4. Reporting-Modul
|
||||
5. E-Mail-Integration
|
||||
6. Kalender/Termine
|
||||
|
||||
105
assets/app.js
105
assets/app.js
@ -5,14 +5,20 @@ import './bootstrap.js';
|
||||
* This file will be included onto the page via the importmap() Twig function,
|
||||
* 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 { createPinia } from 'pinia';
|
||||
import PrimeVue from 'primevue/config';
|
||||
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 Toast from 'primevue/toast';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
import StyleClass from 'primevue/styleclass';
|
||||
import router from './js/router';
|
||||
import App from './js/App.vue';
|
||||
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 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(router);
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
preset: presets[savedPreset],
|
||||
options: {
|
||||
darkModeSelector: false,
|
||||
darkModeSelector: '.app-dark',
|
||||
cssLayer: false
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
app.directive('tooltip', Tooltip);
|
||||
app.directive('styleclass', StyleClass);
|
||||
app.component('Toast', Toast);
|
||||
|
||||
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
|
||||
const authStore = useAuthStore();
|
||||
authStore.initializeFromElement();
|
||||
|
||||
@ -1,100 +1,16 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<div id="app-layout">
|
||||
<header class="app-header">
|
||||
<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>© {{ new Date().getFullYear() }} myCRM</p>
|
||||
</footer>
|
||||
</div>
|
||||
<AppLayout v-if="authStore.isAuthenticated" />
|
||||
<RouterView v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink, RouterView } from 'vue-router';
|
||||
import Toast from 'primevue/toast';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import AppLayout from './layout/AppLayout.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
261
assets/js/layout/AppConfigurator.vue
Normal file
261
assets/js/layout/AppConfigurator.vue
Normal 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>
|
||||
8
assets/js/layout/AppFooter.vue
Normal file
8
assets/js/layout/AppFooter.vue
Normal 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>
|
||||
71
assets/js/layout/AppLayout.vue
Normal file
71
assets/js/layout/AppLayout.vue
Normal 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>
|
||||
40
assets/js/layout/AppMenu.vue
Normal file
40
assets/js/layout/AppMenu.vue
Normal 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>
|
||||
92
assets/js/layout/AppMenuItem.vue
Normal file
92
assets/js/layout/AppMenuItem.vue
Normal 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>
|
||||
11
assets/js/layout/AppSidebar.vue
Normal file
11
assets/js/layout/AppSidebar.vue
Normal 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>
|
||||
96
assets/js/layout/AppTopbar.vue
Normal file
96
assets/js/layout/AppTopbar.vue
Normal 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>
|
||||
87
assets/js/layout/composables/layout.js
Normal file
87
assets/js/layout/composables/layout.js
Normal 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
|
||||
};
|
||||
}
|
||||
@ -1,17 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import ContactList from './views/ContactList.vue';
|
||||
import CompanyList from './views/CompanyList.vue';
|
||||
import DealList from './views/DealList.vue';
|
||||
import ContactManagement from './views/ContactManagement.vue';
|
||||
import UserManagement from './views/UserManagement.vue';
|
||||
import RoleManagement from './views/RoleManagement.vue';
|
||||
import SettingsManagement from './views/SettingsManagement.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/contacts', name: 'contacts', component: ContactList },
|
||||
{ path: '/companies', name: 'companies', component: CompanyList },
|
||||
{ path: '/deals', name: 'deals', component: DealList },
|
||||
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
||||
|
||||
789
assets/js/views/ContactManagement.vue
Normal file
789
assets/js/views/ContactManagement.vue
Normal 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>
|
||||
@ -1,3 +0,0 @@
|
||||
body {
|
||||
background-color: skyblue;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
29
assets/styles/layout/_animations.scss
Normal file
29
assets/styles/layout/_animations.scss
Normal 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;
|
||||
}
|
||||
24
assets/styles/layout/_core.scss
Normal file
24
assets/styles/layout/_core.scss
Normal 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;
|
||||
}
|
||||
8
assets/styles/layout/_footer.scss
Normal file
8
assets/styles/layout/_footer.scss
Normal 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);
|
||||
}
|
||||
13
assets/styles/layout/_main.scss
Normal file
13
assets/styles/layout/_main.scss
Normal 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;
|
||||
}
|
||||
160
assets/styles/layout/_menu.scss
Normal file
160
assets/styles/layout/_menu.scss
Normal 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;
|
||||
}
|
||||
15
assets/styles/layout/_mixins.scss
Normal file
15
assets/styles/layout/_mixins.scss
Normal 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);
|
||||
}
|
||||
47
assets/styles/layout/_preloading.scss
Normal file
47
assets/styles/layout/_preloading.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
110
assets/styles/layout/_responsive.scss
Normal file
110
assets/styles/layout/_responsive.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
201
assets/styles/layout/_topbar.scss
Normal file
201
assets/styles/layout/_topbar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
68
assets/styles/layout/_typography.scss
Normal file
68
assets/styles/layout/_typography.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
assets/styles/layout/_utils.scss
Normal file
25
assets/styles/layout/_utils.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
14
assets/styles/layout/layout.scss
Normal file
14
assets/styles/layout/layout.scss
Normal 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';
|
||||
20
assets/styles/layout/variables/_common.scss
Normal file
20
assets/styles/layout/variables/_common.scss
Normal 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);
|
||||
}
|
||||
5
assets/styles/layout/variables/_dark.scss
Normal file
5
assets/styles/layout/variables/_dark.scss
Normal 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);
|
||||
}
|
||||
5
assets/styles/layout/variables/_light.scss
Normal file
5
assets/styles/layout/variables/_light.scss
Normal 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
3
assets/styles/sakai.scss
Normal file
@ -0,0 +1,3 @@
|
||||
/* Sakai Layout Styles */
|
||||
@import 'primeicons/primeicons.css';
|
||||
@import './layout/layout.scss';
|
||||
32
assets/styles/tailwind.css
Normal file
32
assets/styles/tailwind.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -103,7 +103,7 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"doctrine/doctrine-fixtures-bundle": "*",
|
||||
"phpunit/phpunit": "^12.4",
|
||||
"symfony/browser-kit": "7.1.*",
|
||||
"symfony/css-selector": "7.1.*",
|
||||
|
||||
2
composer.lock
generated
2
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "10b55ae5547ef383b9e8a94a7ca7402c",
|
||||
"content-hash": "af57c523401fba0e523501b76e0629f0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
|
||||
35
migrations/Version20251108175514.php
Normal file
35
migrations/Version20251108175514.php
Normal 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
1733
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,12 +3,17 @@
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.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",
|
||||
"postcss": "^8.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-loader": "^7.0.0",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"sass": "^1.70.0",
|
||||
"sass-loader": "^16.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.0",
|
||||
"vue-loader": "^17.4.0",
|
||||
"webpack": "^5.74.0",
|
||||
@ -17,7 +22,6 @@
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.4.1",
|
||||
"pinia": "^2.2.0",
|
||||
"primeflex": "^3.3.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
226
src/DataFixtures/ContactFixtures.php
Normal file
226
src/DataFixtures/ContactFixtures.php
Normal 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
394
src/Entity/Contact.php
Normal 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 ?? '';
|
||||
}
|
||||
}
|
||||
216
src/Entity/ContactPerson.php
Normal file
216
src/Entity/ContactPerson.php
Normal 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();
|
||||
}
|
||||
}
|
||||
60
src/Repository/ContactRepository.php
Normal file
60
src/Repository/ContactRepository.php
Normal 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
14
tailwind.config.js
Normal 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')
|
||||
],
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user