feat: Implement ProjectTask module with full CRUD functionality

- Added ProjectTask entity with fields for name, description, budget, hour contingent, hourly rate, and total price.
- Created ProjectTaskRepository with methods for querying tasks by project and user access.
- Implemented ProjectTaskVoter for fine-grained access control based on user roles and project membership.
- Developed ProjectTaskSecurityListener to enforce permission checks during task creation.
- Introduced custom ProjectTaskProjectFilter for filtering tasks based on project existence.
- Integrated ProjectTask management in the frontend with Vue.js components, including CRUD operations and filtering capabilities.
- Added API endpoints for ProjectTask with appropriate security measures.
- Created migration for project_tasks table in the database.
- Updated documentation to reflect new module features and usage.
This commit is contained in:
olli 2025-11-14 17:12:40 +01:00
parent 5b030b84c9
commit 8a132d2fb9
28 changed files with 4319 additions and 81 deletions

268
.claude/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,268 @@
# myCRM Architecture Overview
## Quick Reference for Claude
### Security Architecture
This codebase uses a **multi-layered security system** combining:
1. **Symfony Authentication** - User login, password hashing via bcrypt
2. **Role-Based Access Control (RBAC)** - Users → Roles → Modules → Permissions
3. **Custom Voters** - Fine-grained entity-level authorization
4. **API Platform** - Per-operation security expressions
5. **Query Extensions** - Automatic filtering of accessible data
See `/SECURITY_ARCHITECTURE.md` for comprehensive details.
### Entity Relationships - Quick Diagram
```
User
├── Standard Roles: ROLE_ADMIN, ROLE_USER (in 'roles' JSON column)
├── Application Roles: Collection<Role> (ManyToMany)
│ └── Role
│ └── Permissions: Collection<RolePermission> (OneToMany)
│ └── RolePermission
│ └── Module (the module being granted permission for)
└── Can belong to Project teams: Collection<Project> (ManyToMany)
Project
├── owner: User (REQUIRED)
├── teamMembers: Collection<User> (ManyToMany)
├── customer: Contact
├── status: ProjectStatus
└── gitRepositories: Collection<GitRepository> (OneToMany)
ProjectTask
├── project: Project (nullable - can be project-independent)
└── Budget & hourly tracking
GitRepository
├── project: Project (REQUIRED)
└── URL, branch, provider info
Contact
├── contactPersons: Collection<ContactPerson> (OneToMany)
└── Company info: name, address, tax numbers, etc.
ContactPerson
└── contact: Contact (ManyToOne - no separate permissions)
```
### Permission Hierarchy
**Level 1: Symfony Standard Roles**
- Checked in `config/packages/security.yaml` access_control
- Used for basic authentication gates (API requires `IS_AUTHENTICATED_FULLY`)
**Level 2: Module-Based Permissions**
- `User.hasModulePermission(moduleCode, action)` checks:
- User's application roles
- Each role's RolePermission for the module
- The specific action flag (canView, canCreate, canEdit, etc.)
**Level 3: Object-Level Voters**
- `ModuleVoter` - Module-level permissions (string subject or ModuleAwareInterface)
- `ProjectVoter` - Ownership/team access to projects
- `ProjectTaskVoter` - Project-based + admin-only for standalone tasks
- `GitRepositoryVoter` - Project-based access
**Level 4: Event Listeners**
- `ProjectTaskSecurityListener` - Additional prePersist check
### Core Files to Know
**Security Components:**
- `src/Security/Voter/ModuleVoter.php` - Routes to `user.hasModulePermission()`
- `src/Security/Voter/ProjectVoter.php` - Checks `project.owner === user` or team
- `src/Security/Voter/ProjectTaskVoter.php` - Combines project + admin checks
- `src/Security/Voter/GitRepositoryVoter.php` - Tied to project access
**Entities:**
- `src/Entity/User.php` - hasModulePermission() method
- `src/Entity/Role.php` - permissions collection
- `src/Entity/RolePermission.php` - defines per-module flags
- `src/Entity/Module.php` - registry of modules
- `src/Entity/Project.php` - hasAccess() checks owner/team
- `src/Entity/Interface/ModuleAwareInterface.php` - getModuleName()
**API Configuration:**
- `config/packages/api_platform.yaml` - Global API defaults
- `config/packages/security.yaml` - Firewall, access_control
- Entity `#[ApiResource]` attributes - Per-operation security expressions
### API Filters
Most entities have API filters:
- `SearchFilter` - Partial text search
- `BooleanFilter` - Filter by boolean fields
- `DateFilter` - Filter by date ranges
- Custom `ProjectTaskProjectFilter` - hasProject filter for tasks
### Authentication Methods
1. **Form Login** - `LoginFormAuthenticator` - Email + password form
2. **OAuth** - `PocketIdAuthenticator` - OIDC provider integration
Both update `lastLoginAt` in LoginSuccessListener.
### Data Serialization
All APIs use serializer groups to control what's returned:
- `{entity}:read` - Fields returned in GET
- `{entity}:write` - Fields accepted in POST/PUT
Example: User with `#[Groups(['user:read', 'project:read'])]` appears in both user and project responses.
### Key Patterns
**Module-Based Permission Check:**
```php
is_granted('VIEW', 'contacts') // Collection operation
ModuleVoter.supports() → true
ModuleVoter.voteOnAttribute('VIEW', 'contacts', $token)
user.hasModulePermission('contacts', 'view')
```
**Object-Level Access Check:**
```php
is_granted('DELETE', $project) // Delete operation
ProjectVoter.supports() → true
ProjectVoter.voteOnAttribute('DELETE', $project, $token)
project.getOwner() === $user
```
**Project-Related Filtering:**
```
ProjectAccessExtension applies to Project GetCollection
Adds: WHERE owner = :user OR teamMembers = :user
User sees only their projects
```
### Adding New Features
**New Module-Based Entity:**
1. Implement `ModuleAwareInterface::getModuleName()`
2. Create `Module` record in database
3. Add API security: `new GetCollection(security: "is_granted('VIEW', 'module_code')")`
4. Create RolePermissions for existing roles
**New Project-Related Entity:**
1. Add `project: Project` relationship
2. Create custom Voter like `ProjectTaskVoter`
3. Use in API: `new Get(security: "is_granted('VIEW', object)")`
**New User Role:**
1. Create `Role` entity
2. Add `RolePermission` records for each module
3. Assign to users via `user.addUserRole(role)`
### Common Security Attributes
Used in voters and API operations:
- `VIEW` - Can read/list
- `CREATE` - Can create new
- `EDIT` - Can update
- `DELETE` - Can remove
- `EXPORT` - Can export data
- `MANAGE` - Admin functions
### Important Constants
**Voter Attributes:**
```php
ProjectVoter::VIEW
ProjectVoter::EDIT
ProjectVoter::DELETE
ProjectVoter::CREATE
// Similar for other voters
```
**API Platform Groups:**
```
{entity}:read → Normalization (GET)
{entity}:write → Denormalization (POST/PUT)
```
**Symfony Standard Roles:**
```
ROLE_USER → Authenticated user
ROLE_ADMIN → All permissions (bypasses voters)
```
---
## Quick Debugging Checklist
1. **User can't access API endpoint?**
- Check `access_control` in security.yaml
- Check API operation security expression
- Check voter implementation
2. **User has role but can't perform action?**
- Verify RolePermission exists for that role+module
- Check the specific permission flag (canView, etc.)
- Verify module code matches (case-sensitive!)
3. **Non-admin accessing admin resource?**
- Check ROLE_ADMIN bypass in voter
- Verify voter supports() returns true
- Look at voteOnAttribute() logic
4. **Permission check in code?**
```php
// Get current user
$user = $this->getUser(); // null if not authenticated
// Check module permission
if ($user->hasModulePermission('contacts', 'create')) {
// Allow
}
// In Twig
{% if app.user.hasModulePermission('contacts', 'view') %}...{% endif %}
// In security expressions
is_granted('CREATE', 'contacts')
is_granted('EDIT', $contact)
```
---
## Files Changed Recently
Check git status to see security-related changes:
- `src/Security/Voter/*.php` - Voter implementations
- `src/Entity/*.php` - Entity definitions
- `src/EventListener/*.php` - Security listeners
- `config/packages/security.yaml` - Auth configuration
---
## Testing Security
**Unit Testing:**
- Create user and project fixtures
- Test voter returns ALLOW/DENY/ABSTAIN correctly
- Test edge cases (null owner, empty team, etc.)
**Integration Testing:**
- Make API calls as different users
- Verify 403 Forbidden for unauthorized access
- Verify 200 OK for authorized access
- Check returned data respects serializer groups
---
## See Also
- `/SECURITY_ARCHITECTURE.md` - Complete security documentation
- `/docs/PERMISSIONS.md` - User management and permissions guide
- `config/packages/security.yaml` - Security configuration
- API Platform docs: https://api-platform.com/docs/core/security/

9
.env
View File

@ -63,3 +63,12 @@ OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340
OAUTH_POCKET_ID_CLIENT_SECRET=
OAUTH_POCKET_ID_REDIRECT_URI=https://mycrm.test/dashboard
###< knpuniversity/oauth2-client-bundle ###
###> app/git-services ###
# Optional: GitHub Personal Access Token for higher rate limits (5000 req/hour instead of 60)
# Create at: https://github.com/settings/tokens (needs 'public_repo' scope for public repos)
GITHUB_TOKEN=
# Optional: Gitea Access Token for private instances
GITEA_TOKEN=
###< app/git-services ###

397
CLAUDE.md Normal file
View File

@ -0,0 +1,397 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
myCRM is a modern, modular CRM application built with Symfony 7.1 LTS, Vue.js 3, and PrimeVue. It features a sophisticated permission system, API Platform for REST APIs, and a single-page application architecture.
## Common Development Commands
### Initial Setup
```bash
composer install
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
php bin/console doctrine:fixtures:load # Load test data (admin@mycrm.local/admin123)
npm install
npm run dev
```
### Development Workflow
```bash
# Terminal 1: Backend server
symfony serve -d # OR: php -S localhost:8000 -t public/
# Terminal 2: Frontend with hot reload
npm run watch
# Production build
npm run build
```
### Database Operations
```bash
# Create new migration after entity changes
php bin/console make:migration
php bin/console doctrine:migrations:migrate
# Validate schema matches entities
php bin/console doctrine:schema:validate
# Reset database (dev only)
php bin/console doctrine:database:drop --force
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
php bin/console doctrine:fixtures:load
```
### Testing
```bash
# Backend tests
php bin/phpunit
# Validate Doctrine schema
php bin/console doctrine:schema:validate
# Check user permissions (custom command)
php bin/console app:user:permissions <email>
```
### Code Generation
```bash
# Entity with API Platform support
php bin/console make:entity
# Migration after entity changes
php bin/console make:migration
# Security voter
php bin/console make:voter
# API Platform state processor/provider
php bin/console make:state-processor
php bin/console make:state-provider
```
### Cache Management
```bash
php bin/console cache:clear
APP_ENV=prod php bin/console cache:warmup
```
## Architecture Overview
### Security System (6 Layers)
The application uses a sophisticated multi-layer security architecture:
1. **Authentication Layer**: Symfony form login + OAuth (PocketId integration)
2. **Standard Roles**: `ROLE_ADMIN`, `ROLE_USER` (stored in User.roles as JSON array)
3. **Module Permission System**: Custom fine-grained permissions
- User → UserRoles (many-to-many) → Role → RolePermissions → Module
- 6 permission types: view, create, edit, delete, export, manage
- Checked via: `$user->hasModulePermission('contacts', 'view')`
4. **Custom Voters**: Entity-level access control (4 voters)
- `ModuleVoter`: Routes permission checks to module system
- `ProjectVoter`: Project ownership + team member checks
- `ProjectTaskVoter`: Inherits project access + admin bypass
- `GitRepositoryVoter`: Requires associated project access
5. **Event Listeners**: Pre-persist security validation
- `ProjectTaskSecurityListener`: Validates project tasks can only be created if user has project access
6. **Query Extensions**: Auto-filter collections
- `ProjectAccessExtension`: Automatically filters project collections based on user access
### Permission Check Flow
```php
// 1. Check via Security component
$this->denyAccessUnlessGranted('view', $contact); // Uses ContactVoter → ModuleVoter
// 2. Direct module check
if ($user->hasModulePermission('contacts', 'edit')) { }
// 3. API Platform security
#[ApiResource(
operations: [
new Get(security: "is_granted('VIEW', object)")
]
)]
```
### Entity Relationships
**Core Entities:**
- **User**: Authentication + role management (dual system: Symfony roles + custom UserRoles)
- **Role**: Groups of module permissions
- **Module**: CRM modules (contacts, projects, tasks, etc.)
- **RolePermission**: Joins Role ↔ Module with 6 boolean flags
- **Contact**: Business contacts (implements ModuleAwareInterface, module='contacts')
- **ContactPerson**: Linked to Contact, inherits permissions
- **Project**: Project management (owner + team members, module='projects')
- **ProjectTask**: Subtasks under projects (standalone tasks admin-only, module='tasks')
- **GitRepository**: Linked to projects, inherits project access
**Key Patterns:**
- **ModuleAwareInterface**: Entities that tie to permission modules
- **Project Access Model**: `$project->hasAccess($user)` returns true if owner OR team member
- **Permission Inheritance**: ContactPerson inherits Contact permissions, GitRepository inherits Project permissions
- **Dual Role System**: User.roles (Symfony standard array) + User.userRoles (ManyToMany with Role entity)
### API Platform Configuration
**Per-Entity Security:**
```php
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('VIEW', 'contacts')"),
new Get(security: "is_granted('VIEW', object)"),
new Post(security: "is_granted('CREATE', 'contacts')"),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)")
]
)]
```
**Serialization Groups:**
- `{entity}:read`: Fields exposed in GET responses
- `{entity}:write`: Fields accepted in POST/PUT
- Used to control field visibility based on context
**Common Filters:**
- `SearchFilter`: Text search on specific fields
- `BooleanFilter`: Filter by boolean fields (active, isDebtor, etc.)
- `DateFilter`: Filter by date ranges
- Custom filters for complex queries (e.g., ProjectTaskProjectFilter)
### Frontend Architecture (Vue.js 3)
**Directory Structure:**
```
/assets/js
/components - Reusable components (CrudDataTable, AppMenu, AppTopbar)
/views - Page-level components (ContactManagement, ProjectManagement, Dashboard)
/layout - Sakai layout components (AppLayout, AppSidebar)
/composables - Composition API functions (useAuth, usePermissions)
/stores - Pinia state management
/api - API client wrappers
/router.js - Vue Router SPA navigation
/assets/styles
/layout - Sakai SCSS (topbar, sidebar, menu, responsive)
tailwind.css - Tailwind CSS v4 with PrimeUI plugin
sakai.scss - Sakai layout imports
```
**Component Pattern:**
```vue
<script setup>
import { ref } from 'vue'
import CrudDataTable from '@/components/CrudDataTable.vue'
const items = ref([])
const apiEndpoint = '/api/contacts'
async function loadData() {
const response = await fetch(apiEndpoint)
items.value = await response.json()
}
</script>
```
**PrimeVue Usage:**
- `DataTable`: Main component for entity lists (server-side pagination, filtering, sorting)
- `Dialog`: Modal forms for create/edit
- `Button`, `InputText`, `Dropdown`: Form components
- `Chart`: Dashboard visualizations (Chart.js integration)
- Theme: Aura theme with dark mode support
**Webpack Encore:**
- Entry point: `assets/app.js`
- Aliases: `@``assets/js`, `@images``assets/images`
- Hot reload: `npm run watch`
- Production build: `npm run build`
## Development Conventions
### Backend (Symfony)
**Controllers**: Keep thin, delegate to services
```php
// Good
public function create(Request $request, ContactService $service): JsonResponse
{
$this->denyAccessUnlessGranted('CREATE', 'contacts');
return $this->json($service->createContact($request->toArray()));
}
```
**Services**: Constructor injection, use interfaces
```php
class ContactService
{
public function __construct(
private EntityManagerInterface $em,
private EventDispatcherInterface $dispatcher
) {}
}
```
**Entities**: Doctrine attributes, define relationships
```php
#[ORM\Entity]
#[ORM\Table(name: 'contacts')]
#[ApiResource(/* ... */)]
class Contact implements ModuleAwareInterface
{
public function getModuleName(): string { return 'contacts'; }
}
```
**Voters**: Implement granular permissions
```php
class ContactVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, ['VIEW', 'EDIT', 'DELETE'])
&& ($subject instanceof Contact || $subject === 'contacts');
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// Delegate to ModuleVoter via hasModulePermission()
}
}
```
### Frontend (Vue.js)
**Composables**: Extract reusable logic
```javascript
// composables/usePermissions.js
export function usePermissions() {
const hasPermission = (module, action) => {
// Check user permissions
}
return { hasPermission }
}
```
**Components**: Single File Components with Composition API
```vue
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
apiEndpoint: String
})
const emit = defineEmits(['save', 'cancel'])
// Component logic here
</script>
```
## Testing Strategy
- **PHPUnit**: Backend unit and functional tests
- **Doctrine Schema Validation**: Run in CI to catch schema drift
- **Voter Tests**: Explicitly test permission logic
- **Frontend**: Vitest/Jest for Vue components (when configured)
## Important Files
**Security:**
- `src/Security/Voter/` - All voter implementations
- `src/EventListener/ProjectTaskSecurityListener.php` - Pre-persist validation
- `src/Filter/ProjectAccessExtension.php` - Query auto-filtering
- `src/Entity/User.php` - hasModulePermission() method (lines ~200-220)
**API Configuration:**
- Entity `#[ApiResource]` attributes - Security, operations, filters
- `config/packages/api_platform.yaml` - Global API settings
**Frontend Entry:**
- `assets/app.js` - Vue app initialization
- `assets/js/router.js` - Route definitions and navigation guards
- `webpack.config.js` - Encore configuration
## Key Workflows
### Adding a New Module
1. Create entity with `ModuleAwareInterface`
2. Add to Module entity fixtures/database
3. Create voter (or rely on ModuleVoter)
4. Configure API Platform security
5. Add Vue.js view component
6. Add route to router.js
7. Add menu item to AppMenu.vue
### Adding a New Permission-Controlled Feature
1. Determine permission level (module vs. entity)
2. If entity-level: create/update Voter
3. Add security checks in controller/API
4. Update frontend to check permissions before showing UI
5. Test with different user roles
### Database Schema Changes
1. Modify entity attributes
2. `php bin/console make:migration`
3. Review generated migration
4. `php bin/console doctrine:migrations:migrate`
5. `php bin/console doctrine:schema:validate` to verify
6. Update fixtures if needed
## Common Patterns
**Check Module Permission (Backend):**
```php
if (!$user->hasModulePermission('contacts', 'view')) {
throw new AccessDeniedException();
}
```
**Check Entity Permission (Backend):**
```php
$this->denyAccessUnlessGranted('EDIT', $contact);
```
**API Platform Security:**
```php
#[ApiResource(
security: "is_granted('ROLE_USER')",
operations: [
new Get(security: "is_granted('VIEW', object)")
]
)]
```
**Project Access Check:**
```php
if (!$project->hasAccess($user)) {
throw new AccessDeniedException();
}
```
**Frontend Permission Check:**
```javascript
import { usePermissions } from '@/composables/usePermissions'
const { hasPermission } = usePermissions()
if (hasPermission('contacts', 'create')) {
// Show create button
}
```
## Additional Documentation
- `docs/LOGIN.md` - Authentication system details
- `docs/PERMISSIONS.md` - Modular permission system
- `docs/USER-CRUD.md` - User management with API Platform
- `docs/PROJECT_TASKS_MODULE.md` - Project tasks implementation
- `.github/copilot-instructions.md` - Detailed development guidelines

620
SECURITY_ARCHITECTURE.md Normal file
View File

@ -0,0 +1,620 @@
# Security Architecture - myCRM
## Overview
myCRM implements a **multi-layered security architecture** combining Symfony's authentication/authorization system with custom role-based access control (RBAC), project-based access control, and API Platform integration. The system uses:
1. **Symfony Security System** - Authentication, password hashing, user providers
2. **Custom Voters** - Fine-grained authorization for entities and modules
3. **API Platform Security** - Per-operation security expressions
4. **Event Listeners** - Security checks during entity lifecycle
5. **Query Extensions** - Automatic query filtering for accessible data
---
## Entity Relationship Model
### Core Security Entities
```
User (users table)
├── id, email, firstName, lastName, password, isActive
├── roles: list<string> (Symfony standard roles like ROLE_ADMIN, ROLE_USER)
├── userRoles: Collection<Role> (custom application roles - ManyToMany)
├── passwordSetupToken, passwordSetupTokenExpiresAt
├── createdAt, lastLoginAt
└── Methods:
└── hasModulePermission(moduleCode, action): bool
Role (roles table)
├── id, name, description, isSystem (cannot be deleted if true)
├── users: Collection<User> (ManyToMany inverse side)
├── permissions: Collection<RolePermission> (OneToMany)
├── createdAt, updatedAt
└── Constraint: SystemRoleProtection validator prevents system role modification
Module (modules table)
├── id, name, code (unique), description, icon, sortOrder, isActive
├── permissions: Collection<RolePermission> (OneToMany)
└── Purpose: Defines application modules (contacts, projects, project_tasks, etc.)
RolePermission (role_permissions table)
├── id, role_id, module_id (unique constraint: role_id + module_id)
├── canView, canCreate, canEdit, canDelete, canExport, canManage: bool
└── Unique Constraint: Ensures one permission per role-module pair
ModuleAwareInterface (interface)
└── getModuleName(): string (implemented by Contact, Project, ProjectTask)
```
### Project Management Entities
```
Project (projects table)
├── id, name, description, projectNumber, orderNumber
├── customer: Contact (ManyToOne)
├── status: ProjectStatus (ManyToOne)
├── owner: User (ManyToOne - REQUIRED)
├── teamMembers: Collection<User> (ManyToMany)
├── gitRepositories: Collection<GitRepository> (OneToMany)
├── startDate, endDate, orderDate, budget, hourContingent
├── isPrivate: bool
├── createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'projects')
└── Methods:
├── hasAccess(user): bool (owner or team member)
├── isTeamMember(user): bool
├── addTeamMember(user), removeTeamMember(user)
└── addGitRepository(repo), removeGitRepository(repo)
ProjectTask (project_tasks table)
├── id, name, description
├── project: Project (ManyToOne, nullable)
├── budget, hourContingent, hourlyRate, totalPrice
├── createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'project_tasks')
└── Security: Can be project-related OR project-independent (admin-only)
GitRepository (git_repositories table)
├── id, url, localPath, branch, provider, accessToken
├── project: Project (ManyToOne - REQUIRED)
├── name, description
├── lastSync, createdAt, updatedAt
└── Security: Access controlled via associated project
Contact (contacts table)
├── id, companyName, companyNumber, street, zipCode, city, country
├── phone, fax, email, website, taxNumber, vatNumber
├── isDebtor, isCreditor, isActive
├── contactPersons: Collection<ContactPerson> (OneToMany)
├── notes, createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'contacts')
ContactPerson (contact_persons table)
├── id, contact_id (ManyToOne, no separate permissions)
├── salutation, title, firstName, lastName, position, department
├── phone, mobile, email, isPrimary
└── Security: Inherits from parent Contact
```
---
## Security Flow Layers
### Layer 1: Authentication
**File**: `config/packages/security.yaml`, `src/Security/LoginFormAuthenticator.php`, `src/Security/PocketIdAuthenticator.php`
- **Provider**: User loaded by email from database
- **Authenticators**:
- LoginFormAuthenticator (form-based login)
- PocketIdAuthenticator (OAuth/PocketId integration)
- **Password Hashing**: Symfony's PasswordHasher (bcrypt)
- **Session**: Stateless=false (sessions enabled for web UI)
- **Remember Me**: 1-week cookie-based persistence
### Layer 2: Base Authorization (Standard Roles)
User has two types of roles:
#### A. Symfony Standard Roles (stored in `roles` JSON column)
- `ROLE_USER` - Default role for authenticated users
- `ROLE_ADMIN` - Full system access (all voters return true)
- Used in access_control patterns and voter logic
#### B. Application Roles (stored via user_roles ManyToMany)
- Custom business roles (e.g., "Vertriebsmitarbeiter", "Betrachter")
- Linked to modules via RolePermission
- Define granular per-module actions
### Layer 3: Voter-Based Authorization
**File**: `src/Security/Voter/`
#### ModuleVoter
- **Purpose**: Controls module-level access (contacts, projects, etc.)
- **Attributes**: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXPORT`, `MANAGE`
- **Subject Types**:
- String (module code) for collection operations: `is_granted('VIEW', 'contacts')`
- Object implementing ModuleAwareInterface for item operations: `is_granted('VIEW', $contact)`
- **Logic**:
```
1. If user not logged in → ABSTAIN
2. If user inactive → DENY
3. If ROLE_ADMIN → ALLOW (all attributes)
4. Otherwise → Check user.hasModulePermission(moduleCode, action)
- Iterates through user roles → role permissions
- Finds permission for module
- Checks corresponding canView/canCreate/canEdit/canDelete/canExport/canManage flag
```
#### ProjectVoter
- **Purpose**: Controls project-level access
- **Attributes**: `VIEW`, `EDIT`, `DELETE`, `CREATE`
- **Access Rules**:
- `CREATE`: Any authenticated user can create projects
- `VIEW`: Owner or team member
- `EDIT`: Owner or team member
- `DELETE`: Owner only
- **Used in**: Project API operations
#### ProjectTaskVoter
- **Purpose**: Controls project task access
- **Attributes**: `VIEW`, `EDIT`, `DELETE`, `CREATE`
- **Access Rules**:
- For tasks WITH project: Same as ProjectVoter
- For tasks WITHOUT project: ROLE_ADMIN only
- Admin bypass: ROLE_ADMIN can perform all actions
- **Dependency**: References associated project's access rules
#### GitRepositoryVoter
- **Purpose**: Controls git repository access
- **Attributes**: `VIEW`, `EDIT`, `DELETE`
- **Access Rules**: Tied to associated project
- `VIEW/EDIT`: Owner or team member of project
- `DELETE`: Project owner only
- No project → DENY all
### Layer 4: API Platform Security Expressions
**File**: Entity classes with `#[ApiResource]` attributes
API operations define inline security expressions:
```php
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('VIEW', 'contacts')"),
new Get(security: "is_granted('VIEW', object)"),
new Post(security: "is_granted('CREATE', 'contacts')"),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)")
]
)]
```
**Evaluation**:
1. API request arrives
2. Security expression evaluated using SecurityExpressionLanguage
3. If expression returns false → 403 Forbidden
4. If true → Request processed (entity-level validators still run)
### Layer 5: Event Listeners (Doctrine)
**File**: `src/EventListener/ProjectTaskSecurityListener.php`
Attribute: `#[AsEntityListener(event: Events::prePersist, entity: ProjectTask::class)]`
**Purpose**: Additional security check before persisting ProjectTask
**Logic**:
```php
if (task.project === null) {
// Project-independent task
require ROLE_ADMIN
} else {
// Project-related task
allow if user is ROLE_ADMIN or user.hasAccess(project)
}
```
**When it runs**: Before `flush()` during POST/PUT operations
### Layer 6: Query Extensions (Collection Filtering)
**File**: `src/Doctrine/Extension/ProjectAccessExtension.php`
Implements: `QueryCollectionExtensionInterface`
**Purpose**: Automatically filter Project collection queries to accessible projects
**Logic**:
```sql
WHERE project.owner = :current_user
OR teamMembers = :current_user
```
**When it runs**: Before any Project GetCollection operation
**Alternative Approach**:
`src/State/ProjectCollectionProvider.php` provides project collection via `findUserProjects(user)`
---
## Security Implementation Patterns
### Pattern 1: Module-Based Permissions
For entities implementing `ModuleAwareInterface` that track module-level permissions:
**File**: Contact, Project, ProjectTask entities
1. Entity implements `ModuleAwareInterface::getModuleName()` → returns module code
2. API security expression: `is_granted('VIEW', 'contacts')`
3. ModuleVoter checks user.hasModulePermission('contacts', 'view')
4. User.hasModulePermission() iterates through roles → permissions
**Advantage**: Centralized permission management in Role/RolePermission tables
### Pattern 2: Object-Level Ownership Permissions
For entities with specific owner relationships:
**File**: Project, ProjectTask, GitRepository
1. Entity has owner relationship: `User $owner`
2. API security expression: `is_granted('DELETE', object)`
3. Voter checks: `object.getOwner() === user`
**Advantage**: Granular control based on entity relationships
### Pattern 3: Project-Based Access Control
For entities related to projects:
**File**: ProjectTask, GitRepository
1. Access tied to parent project
2. Check: `project.hasAccess(user)` (owner or team member)
3. Combined with module voter for additional layer
**Advantage**: Role-based team access to projects and related data
### Pattern 4: Admin Bypass
Implemented consistently:
```php
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true; // All checks pass
}
```
**File**: ModuleVoter, ProjectTaskVoter
---
## API Platform Configuration
**File**: `config/packages/api_platform.yaml`
```yaml
api_platform:
title: myCRM API
version: 1.0.0
defaults:
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
formats:
jsonld: ['application/ld+json']
json: ['application/json']
html: ['text/html']
```
### Per-Entity API Resource Configuration
**Example**: Project Entity
```php
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('VIEW', 'projects')",
stateless: false
),
new Get(
security: "is_granted('VIEW', object)",
stateless: false
),
new Post(
security: "is_granted('CREATE', 'projects')",
stateless: false
),
new Put(
security: "is_granted('EDIT', object)",
stateless: false
),
new Delete(
security: "is_granted('DELETE', object)",
stateless: false
)
],
normalizationContext: ['groups' => ['project:read']],
denormalizationContext: ['groups' => ['project:write']],
order: ['startDate' => 'DESC']
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPrivate'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', ...])]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
class Project implements ModuleAwareInterface { ... }
```
### Serializer Groups
**Purpose**: Control what fields are returned in API responses based on operation
- `user:read` - Fields returned when reading users
- `user:write` - Fields accepted when creating/updating users
- `project:read` - Fields returned in project responses
- `project:write` - Fields accepted in project POST/PUT
- `role:read` - Role entity read fields
- `role_permission:read` - Permission entity read fields
**File**: Entity attributes `#[Groups(['group:read', 'group:write'])]`
---
## Security Checklist During Development
### Creating New Module-Based Entities
1. Implement `ModuleAwareInterface`
```php
public function getModuleName(): string { return 'module_code'; }
```
2. Add Module record to database
```php
$module = new Module();
$module->setName('Module Name');
$module->setCode('module_code');
```
3. Create API Resource with security
```php
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('VIEW', 'module_code')"),
new Get(security: "is_granted('VIEW', object)"),
new Post(security: "is_granted('CREATE', 'module_code')"),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)")
]
)]
```
4. Define RolePermissions for existing roles
### Adding Project-Based Access to New Entity
1. Add `project` relationship
```php
#[ORM\ManyToOne(targetEntity: Project::class)]
private ?Project $project = null;
```
2. Create custom Voter (similar to ProjectTaskVoter)
```php
private function canView(Entity $entity, User $user): bool {
if (null === $entity->getProject()) {
return in_array('ROLE_ADMIN', $user->getRoles());
}
return $entity->getProject()->hasAccess($user);
}
```
3. Add security expressions to API Resource
```php
new Get(security: "is_granted('VIEW', object)")
```
### Debugging Permission Issues
1. **Check user roles**:
```php
var_dump($this->getUser()->getRoles()); // Symfony standard roles
var_dump($this->getUser()->getUserRoles()); // Application roles
```
2. **Check module permissions**:
```php
var_dump($user->hasModulePermission('contacts', 'view'));
```
3. **Check voter decision**:
```php
var_dump($this->isGranted('VIEW', 'contacts'));
var_dump($this->isGranted('VIEW', $contact));
```
4. **Enable security debug** in dev toolbar
---
## Voter Decision Flow Diagram
```
Request arrives for [Attribute, Subject]
Symfony checks all registered voters in order
Each voter.supports(attribute, subject)?
├─ ModuleVoter
│ ├─ Attribute in [VIEW, CREATE, EDIT, DELETE, EXPORT, MANAGE]?
│ └─ Subject is string OR implements ModuleAwareInterface?
├─ ProjectVoter
│ ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]?
│ └─ Subject is 'projects' or Project instance?
├─ ProjectTaskVoter
│ ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]?
│ └─ Subject is 'project_tasks' or ProjectTask instance?
├─ GitRepositoryVoter
│ ├─ Attribute in [VIEW, EDIT, DELETE]?
│ └─ Subject is GitRepository instance?
└─ [Other voters...]
For each voter that supports:
voter.voteOnAttribute(attribute, subject, token)
Returns:
ALLOW (vote: YES) → Stop here, GRANT access
DENY (vote: NO) → Stop here, DENY access
ABSTAIN (vote: null) → Continue to next voter
Default if all ABSTAIN: DENY (secure by default)
```
---
## Files Organization
### Security Components
```
src/Security/
├── Voter/
│ ├── ModuleVoter.php (Module-level access)
│ ├── ProjectVoter.php (Project ownership/team)
│ ├── ProjectTaskVoter.php (Project-based + admin)
│ └── GitRepositoryVoter.php (Project-based)
├── LoginFormAuthenticator.php
└── PocketIdAuthenticator.php
```
### Entities
```
src/Entity/
├── User.php (Authentication + module permissions)
├── Role.php (Application role definition)
├── RolePermission.php (Role-module permission mapping)
├── Module.php (Application module registry)
├── Interface/
│ └── ModuleAwareInterface.php
├── Project.php (Owner + team members)
├── ProjectTask.php (Optional project association)
├── GitRepository.php (Project association)
├── Contact.php (Module-aware)
├── ContactPerson.php (No separate permissions)
└── ProjectStatus.php
```
### Event Listeners
```
src/EventListener/
├── ProjectTaskSecurityListener.php (prePersist validation)
├── LoginSuccessListener.php
├── GitRepositoryCacheListener.php
└── [Others...]
```
### Query Extensions
```
src/Doctrine/Extension/
└── ProjectAccessExtension.php (Auto-filter GetCollection)
```
### API Filters
```
src/Filter/
└── ProjectTaskProjectFilter.php (hasProject filter)
```
### Configuration
```
config/
├── packages/
│ ├── security.yaml (Firewall, authentication, access_control)
│ ├── api_platform.yaml (API Platform defaults)
│ └── [Others...]
└── services.yaml (Service wiring)
```
---
## Default Access Control Rules
From `config/packages/security.yaml`:
```yaml
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/password-reset, roles: PUBLIC_ACCESS }
- { path: ^/password-setup, roles: PUBLIC_ACCESS }
- { path: ^/connect/pocketid, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/, roles: ROLE_USER }
```
- `/api/*` requires full authentication (not remember_me)
- Other app routes require `ROLE_USER` or `ROLE_ADMIN`
- Public routes for auth bypass
---
## Testing Security
### Unit Testing Voters
```php
public function testProjectVoterDenyNonOwner(): void {
$user = new User();
$owner = new User();
$project = new Project();
$project->setOwner($owner);
$token = $this->createToken($user);
$voter = new ProjectVoter();
$this->assertEquals(
Voter::DENY,
$voter->vote($token, $project, [ProjectVoter::DELETE])
);
}
```
### API Integration Testing
```php
public function testDeleteProjectForbiddenForNonOwner(): void {
$this->client->request('DELETE', '/api/projects/1');
$this->assertResponseStatusCodeSame(403);
}
```
---
## Common Issues & Solutions
| Issue | Cause | Solution |
|-------|-------|----------|
| 403 on API call | Missing module permission | Check RolePermission for user's roles |
| Admin can't access entity | Voter logic error | Verify ROLE_ADMIN bypass in voter |
| Project team member denied | hasAccess() returns false | Check project.owner or project.teamMembers |
| Query returns all items | ProjectAccessExtension not applied | Verify entity is Project class |
| Permission check always fails | Wrong attribute/subject format | Use `is_granted('VIEW', 'module_code')` for strings, `is_granted('VIEW', $object)` for objects |
---
## References
- Symfony Security: https://symfony.com/doc/current/security.html
- API Platform Security: https://api-platform.com/docs/core/security/
- Voters: https://symfony.com/doc/current/security/voters.html
- Expression Language: https://symfony.com/doc/current/components/expression_language/

View File

@ -93,6 +93,7 @@
:showFilterOperator="column.showFilterOperator !== false"
:frozen="freezeFirstColumn && index === 0"
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
:filterField="column.filterField || column.field || column.key"
>
<!-- Custom Body Template (via Slot) -->
<template v-if="$slots[`body-${column.key}`]" #body="slotProps">
@ -319,7 +320,9 @@ const createLabel = computed(() => {
if (props.entityName) {
// If custom article is provided, use it
if (props.entityNameArticle) {
return `Neu${props.entityNameArticle === 'ein' ? 'es' : 'er'} ${props.entityName}`
const suffix = props.entityNameArticle === 'ein' ? 'es' :
props.entityNameArticle === 'eine' ? 'e' : 'er'
return `Neu${suffix} ${props.entityName}`
}
// Fallback: Simple heuristic
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
@ -768,10 +771,15 @@ onMounted(() => {
// Initialize column-specific filters with operator structure for menu mode
props.columns.forEach(col => {
if (col.filterable !== false && !internalFilters.value[col.key]) {
internalFilters.value[col.key] = {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
if (col.filterable !== false) {
// Use filterField if specified, otherwise use field or key
const filterKey = col.filterField || col.field || col.key
if (!internalFilters.value[filterKey]) {
internalFilters.value[filterKey] = {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
}
}
}
})

View File

@ -14,7 +14,8 @@ const model = ref([
label: 'CRM',
items: [
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' }
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
]
},
{

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import Dashboard from './views/Dashboard.vue';
import ContactManagement from './views/ContactManagement.vue';
import ProjectManagement from './views/ProjectManagement.vue';
import ProjectTaskManagement from './views/ProjectTaskManagement.vue';
import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
import UserManagement from './views/UserManagement.vue';
import RoleManagement from './views/RoleManagement.vue';
@ -11,6 +12,7 @@ const routes = [
{ path: '/', name: 'dashboard', component: Dashboard },
{ path: '/contacts', name: 'contacts', component: ContactManagement },
{ path: '/projects', name: 'projects', component: ProjectManagement },
{ path: '/project-tasks', name: 'project-tasks', component: ProjectTaskManagement },
{ path: '/project-statuses', name: 'project-statuses', component: ProjectStatusManagement, meta: { requiresAdmin: true } },
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },

View File

@ -1,78 +1,542 @@
<template>
<div class="dashboard">
<h2>Dashboard</h2>
<p>Willkommen im myCRM Dashboard!</p>
<div class="dashboard-grid">
<Card>
<template #title>Kontakte</template>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-900 mb-1">Dashboard</h1>
<p class="text-600">Willkommen zurück! Hier ist deine Übersicht.</p>
</div>
<div class="text-sm text-500">
{{ currentDate }}
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Kontakte Card -->
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/contacts')">
<template #content>
<p>Gesamt: <strong>0</strong></p>
<div class="flex justify-between items-start">
<div>
<div class="text-500 text-sm mb-2">Kontakte</div>
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalContacts }}</div>
<div class="text-sm text-green-600 flex items-center gap-1">
<i class="pi pi-arrow-up text-xs"></i>
<span>Aktiv</span>
</div>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-users text-2xl text-blue-600"></i>
</div>
</div>
</template>
</Card>
<Card>
<template #title>Unternehmen</template>
<!-- Projekte Card -->
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/projects')">
<template #content>
<p>Gesamt: <strong>0</strong></p>
<div class="flex justify-between items-start">
<div>
<div class="text-500 text-sm mb-2">Projekte</div>
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalProjects }}</div>
<div class="text-sm text-600 flex items-center gap-1">
<span>{{ stats.activeProjects }} aktiv</span>
</div>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-briefcase text-2xl text-purple-600"></i>
</div>
</div>
</template>
</Card>
<Card>
<template #title>Offene Deals</template>
<!-- Tätigkeiten Card -->
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/project-tasks')">
<template #content>
<p>Gesamt: <strong>0</strong></p>
<div class="flex justify-between items-start">
<div>
<div class="text-500 text-sm mb-2">Tätigkeiten</div>
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalTasks }}</div>
<div class="text-sm text-600 flex items-center gap-1">
<span>{{ stats.tasksWithBudget }} mit Budget</span>
</div>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-list-check text-2xl text-green-600"></i>
</div>
</div>
</template>
</Card>
<Card>
<template #title>Umsatz (MTD)</template>
<!-- Budget Card -->
<Card class="hover:shadow-lg transition-shadow">
<template #content>
<p><strong>0 </strong></p>
<div class="flex justify-between items-start">
<div>
<div class="text-500 text-sm mb-2">Gesamt-Budget</div>
<div class="text-3xl font-bold text-900 mb-1">{{ formatCurrency(stats.totalBudget) }}</div>
<div class="text-sm text-600 flex items-center gap-1">
<span>{{ formatCurrency(stats.totalTaskBudget) }} Tasks</span>
</div>
</div>
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-euro text-2xl text-orange-600"></i>
</div>
</div>
</template>
</Card>
</div>
<!-- Quick Actions -->
<Card class="mb-6">
<template #content>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-900">Schnellzugriff</h3>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<Button
label="Neuer Kontakt"
icon="pi pi-user-plus"
outlined
@click="$router.push('/contacts')"
class="w-full"
/>
<Button
label="Neues Projekt"
icon="pi pi-plus-circle"
outlined
@click="$router.push('/projects')"
class="w-full"
/>
<Button
label="Neue Tätigkeit"
icon="pi pi-plus"
outlined
@click="$router.push('/project-tasks')"
class="w-full"
/>
<Button
label="Alle Projekte"
icon="pi pi-th-large"
outlined
@click="$router.push('/projects')"
class="w-full"
/>
</div>
</template>
</Card>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Active Projects -->
<Card>
<template #content>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-900">Aktive Projekte</h3>
<Button
label="Alle anzeigen"
text
size="small"
@click="$router.push('/projects')"
/>
</div>
<div v-if="loading.projects" class="flex justify-center py-8">
<ProgressSpinner style="width: 50px; height: 50px" />
</div>
<div v-else-if="activeProjects.length === 0" class="text-center py-8 text-500">
<i class="pi pi-briefcase text-4xl mb-3"></i>
<p>Keine aktiven Projekte</p>
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="project in activeProjects"
:key="project.id"
class="p-4 border-round border-1 surface-border hover:surface-hover transition-colors cursor-pointer"
@click="$router.push('/projects')"
>
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-semibold text-900 mb-1">{{ project.name }}</div>
<div class="text-sm text-500">{{ project.customer?.companyName || 'Kein Kunde' }}</div>
</div>
<Tag
v-if="project.status"
:value="project.status.name"
:style="{ backgroundColor: project.status.color }"
class="text-xs"
/>
</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div v-if="project.budget">
<div class="text-500 text-xs">Budget</div>
<div class="font-semibold">{{ formatCurrency(project.budget) }}</div>
</div>
<div v-if="project.hourContingent">
<div class="text-500 text-xs">Stunden</div>
<div class="font-semibold">{{ project.hourContingent }} h</div>
</div>
</div>
<div v-if="project.endDate" class="mt-3 text-xs text-500">
<i class="pi pi-calendar mr-1"></i>
Enddatum: {{ formatDate(project.endDate) }}
</div>
</div>
</div>
</template>
</Card>
<!-- Recent Tasks -->
<Card>
<template #content>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-900">Neueste Tätigkeiten</h3>
<Button
label="Alle anzeigen"
text
size="small"
@click="$router.push('/project-tasks')"
/>
</div>
<div v-if="loading.tasks" class="flex justify-center py-8">
<ProgressSpinner style="width: 50px; height: 50px" />
</div>
<div v-else-if="recentTasks.length === 0" class="text-center py-8 text-500">
<i class="pi pi-list-check text-4xl mb-3"></i>
<p>Keine Tätigkeiten vorhanden</p>
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="task in recentTasks"
:key="task.id"
class="p-4 border-round border-1 surface-border hover:surface-hover transition-colors cursor-pointer"
@click="$router.push('/project-tasks')"
>
<div class="flex justify-between items-start mb-2">
<div class="font-semibold text-900">{{ task.name }}</div>
<Tag
v-if="task.project"
:value="task.project.name"
severity="info"
class="text-xs"
/>
<Tag
v-else
value="Projektunabhängig"
severity="secondary"
class="text-xs"
/>
</div>
<div v-if="task.description" class="text-sm text-600 mb-3 line-clamp-2">
{{ task.description }}
</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div v-if="task.hourlyRate">
<div class="text-500 text-xs">Stundensatz</div>
<div class="font-semibold">{{ formatCurrency(task.hourlyRate) }}/h</div>
</div>
<div v-if="task.totalPrice">
<div class="text-500 text-xs">Gesamtpreis</div>
<div class="font-semibold text-green-600">{{ formatCurrency(task.totalPrice) }}</div>
</div>
</div>
<div v-if="task.hourContingent" class="mt-2 flex items-center gap-2">
<ProgressBar
:value="100"
:show-value="false"
style="height: 0.5rem"
class="flex-1"
/>
<span class="text-xs text-500">{{ task.hourContingent }} h</span>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Budget Overview -->
<Card class="mt-6">
<template #content>
<h3 class="text-xl font-semibold text-900 mb-4">Budget-Übersicht</h3>
<div v-if="loading.budgetStats" class="flex justify-center py-8">
<ProgressSpinner style="width: 50px; height: 50px" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Projects Budget -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-600">Projekte Budget</span>
<span class="text-sm font-bold text-900">{{ formatCurrency(budgetStats.projectsBudget) }}</span>
</div>
<ProgressBar
:value="budgetStats.projectsBudgetPercentage"
:show-value="false"
class="mb-1"
:pt="{ value: { class: 'bg-blue-500' } }"
/>
<div class="text-xs text-500">{{ budgetStats.projectsWithBudget }} von {{ stats.totalProjects }} Projekten</div>
</div>
<!-- Tasks Budget -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-600">Tätigkeiten Budget</span>
<span class="text-sm font-bold text-900">{{ formatCurrency(budgetStats.tasksBudget) }}</span>
</div>
<ProgressBar
:value="budgetStats.tasksBudgetPercentage"
:show-value="false"
class="mb-1"
:pt="{ value: { class: 'bg-green-500' } }"
/>
<div class="text-xs text-500">{{ budgetStats.tasksWithBudget }} von {{ stats.totalTasks }} Tätigkeiten</div>
</div>
<!-- Hours Contingent -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-600">Stundenkontingent</span>
<span class="text-sm font-bold text-900">{{ budgetStats.totalHours }} h</span>
</div>
<ProgressBar
:value="100"
:show-value="false"
class="mb-1"
:pt="{ value: { class: 'bg-purple-500' } }"
/>
<div class="text-xs text-500">{{ budgetStats.projectHours }} h Projekte, {{ budgetStats.taskHours }} h Tätigkeiten</div>
</div>
</div>
</template>
</Card>
</div>
</template>
<script setup>
import Card from 'primevue/card';
import { ref, onMounted, computed } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
import ProgressSpinner from 'primevue/progressspinner'
// Reactive data
const stats = ref({
totalContacts: 0,
totalProjects: 0,
activeProjects: 0,
totalTasks: 0,
tasksWithBudget: 0,
totalBudget: 0,
totalTaskBudget: 0
})
const activeProjects = ref([])
const recentTasks = ref([])
const budgetStats = ref({
projectsBudget: 0,
projectsBudgetPercentage: 0,
projectsWithBudget: 0,
tasksBudget: 0,
tasksBudgetPercentage: 0,
tasksWithBudget: 0,
totalHours: 0,
projectHours: 0,
taskHours: 0
})
const loading = ref({
projects: true,
tasks: true,
budgetStats: true
})
// Computed
const currentDate = computed(() => {
const now = new Date()
return now.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
// Methods
function formatCurrency(value) {
if (!value && value !== 0) return '0 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
async function loadDashboardData() {
await Promise.all([
loadContacts(),
loadProjects(),
loadTasks()
])
calculateBudgetStats()
}
async function loadContacts() {
try {
const response = await fetch('/api/contacts?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Kontakte')
const data = await response.json()
const contacts = data['hydra:member'] || data.member || data || []
stats.value.totalContacts = contacts.length
} catch (error) {
console.error('Error loading contacts:', error)
stats.value.totalContacts = 0
}
}
async function loadProjects() {
loading.value.projects = true
try {
const response = await fetch('/api/projects?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Projekte')
const data = await response.json()
const projects = data['hydra:member'] || data.member || data || []
stats.value.totalProjects = projects.length
// Filter active projects (no end date or end date in future)
const now = new Date()
const active = projects.filter(p => {
if (!p.endDate) return true
const endDate = new Date(p.endDate)
return endDate >= now
})
stats.value.activeProjects = active.length
activeProjects.value = active.slice(0, 5) // Top 5
// Calculate total budget
stats.value.totalBudget = projects.reduce((sum, p) => {
return sum + (p.budget ? parseFloat(p.budget) : 0)
}, 0)
} catch (error) {
console.error('Error loading projects:', error)
stats.value.totalProjects = 0
stats.value.activeProjects = 0
activeProjects.value = []
} finally {
loading.value.projects = false
}
}
async function loadTasks() {
loading.value.tasks = true
try {
const response = await fetch('/api/project_tasks?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
const data = await response.json()
const tasks = data['hydra:member'] || data.member || data || []
stats.value.totalTasks = tasks.length
stats.value.tasksWithBudget = tasks.filter(t => t.budget).length
// Calculate total task budget
stats.value.totalTaskBudget = tasks.reduce((sum, t) => {
return sum + (t.budget ? parseFloat(t.budget) : 0)
}, 0)
// Get recent tasks (last 5)
recentTasks.value = tasks.slice(0, 5)
} catch (error) {
console.error('Error loading tasks:', error)
stats.value.totalTasks = 0
stats.value.tasksWithBudget = 0
recentTasks.value = []
} finally {
loading.value.tasks = false
}
}
function calculateBudgetStats() {
loading.value.budgetStats = true
try {
// Projects budget stats
const projectsWithBudget = activeProjects.value.filter(p => p.budget).length
budgetStats.value.projectsWithBudget = projectsWithBudget
budgetStats.value.projectsBudget = activeProjects.value.reduce((sum, p) => {
return sum + (p.budget ? parseFloat(p.budget) : 0)
}, 0)
budgetStats.value.projectsBudgetPercentage = stats.value.totalProjects > 0
? (projectsWithBudget / stats.value.totalProjects) * 100
: 0
// Tasks budget stats
const tasksWithBudget = recentTasks.value.filter(t => t.budget).length
budgetStats.value.tasksWithBudget = tasksWithBudget
budgetStats.value.tasksBudget = recentTasks.value.reduce((sum, t) => {
return sum + (t.budget ? parseFloat(t.budget) : 0)
}, 0)
budgetStats.value.tasksBudgetPercentage = stats.value.totalTasks > 0
? (stats.value.tasksWithBudget / stats.value.totalTasks) * 100
: 0
// Hours stats
budgetStats.value.projectHours = activeProjects.value.reduce((sum, p) => {
return sum + (p.hourContingent ? parseFloat(p.hourContingent) : 0)
}, 0)
// Only count hours from tasks that are NOT assigned to a project
budgetStats.value.taskHours = recentTasks.value
.filter(t => !t.project)
.reduce((sum, t) => {
return sum + (t.hourContingent ? parseFloat(t.hourContingent) : 0)
}, 0)
budgetStats.value.totalHours = budgetStats.value.projectHours + budgetStats.value.taskHours
} catch (error) {
console.error('Error calculating budget stats:', error)
} finally {
loading.value.budgetStats = false
}
}
onMounted(() => {
loadDashboardData()
})
</script>
<style scoped lang="scss">
.dashboard {
h2 {
margin-bottom: 1rem;
font-size: 1.5rem;
@media (min-width: 768px) {
font-size: 2rem;
}
}
p {
font-size: 0.95rem;
@media (min-width: 768px) {
font-size: 1rem;
}
}
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1.5rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-top: 2rem;
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>

View File

@ -620,7 +620,8 @@
<Tabs value="0">
<TabList>
<Tab value="0">Projektdaten</Tab>
<Tab value="1">Git Repositories</Tab>
<Tab value="1">Tätigkeiten</Tab>
<Tab value="2">Git Repositories</Tab>
</TabList>
<TabPanels>
<!-- Project Data Tab -->
@ -771,8 +772,87 @@
</div>
</TabPanel>
<!-- Git Repository Tab -->
<!-- Tasks Tab -->
<TabPanel value="1">
<div v-if="projectTasks.length === 0" class="text-center py-8 text-500">
<i class="pi pi-briefcase text-6xl mb-3"></i>
<p>Keine Tätigkeiten vorhanden</p>
<p class="text-sm">Füge Tätigkeiten hinzu, um Budget und Stunden zu tracken.</p>
</div>
<div v-else>
<!-- Tasks Summary -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="p-4 border-round border-1 surface-border bg-blue-50 dark:bg-blue-900/20">
<div class="text-sm text-500 mb-1">Gesamt-Budget</div>
<div class="text-2xl font-semibold text-blue-600">{{ formatCurrency(tasksSummary.totalBudget) }}</div>
</div>
<div class="p-4 border-round border-1 surface-border bg-green-50 dark:bg-green-900/20">
<div class="text-sm text-500 mb-1">Gesamt-Stunden</div>
<div class="text-2xl font-semibold text-green-600">{{ tasksSummary.totalHours }} h</div>
</div>
<div class="p-4 border-round border-1 surface-border bg-purple-50 dark:bg-purple-900/20">
<div class="text-sm text-500 mb-1">Gesamt-Preis</div>
<div class="text-2xl font-semibold text-purple-600">{{ formatCurrency(tasksSummary.totalPrice) }}</div>
</div>
</div>
<!-- Tasks Table -->
<DataTable
:value="projectTasks"
striped-rows
paginator
:rows="10"
>
<Column field="name" header="Name" style="width: 30%">
<template #body="{ data }">
<div class="font-medium">{{ data.name }}</div>
<div v-if="data.description" class="text-sm text-500 mt-1">{{ data.description }}</div>
</template>
</Column>
<Column field="budget" header="Budget" style="width: 15%">
<template #body="{ data }">
<div v-if="data.budget">{{ formatCurrency(data.budget) }}</div>
<span v-else class="text-500">-</span>
</template>
</Column>
<Column field="hourContingent" header="Stunden" style="width: 15%">
<template #body="{ data }">
<div v-if="data.hourContingent">{{ data.hourContingent }} h</div>
<span v-else class="text-500">-</span>
</template>
</Column>
<Column field="hourlyRate" header="Stundensatz" style="width: 15%">
<template #body="{ data }">
<div v-if="data.hourlyRate">{{ formatCurrency(data.hourlyRate) }}/h</div>
<span v-else class="text-500">-</span>
</template>
</Column>
<Column field="totalPrice" header="Gesamtpreis" style="width: 15%">
<template #body="{ data }">
<div v-if="data.totalPrice" class="font-semibold">{{ formatCurrency(data.totalPrice) }}</div>
<span v-else class="text-500">-</span>
</template>
</Column>
<Column header="Abrechnungsart" style="width: 10%">
<template #body="{ data }">
<Tag v-if="data.hourlyRate && data.totalPrice" value="Beide" severity="info" size="small" />
<Tag v-else-if="data.hourlyRate" value="Stundensatz" severity="success" size="small" />
<Tag v-else-if="data.totalPrice" value="Festpreis" severity="warning" size="small" />
<span v-else class="text-500">-</span>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Git Repository Tab -->
<TabPanel value="2">
<div v-if="gitRepositories.length === 0" class="text-center py-8 text-500">
<i class="pi pi-github text-6xl mb-3"></i>
<p>Keine Git-Repositories verknüpft</p>
@ -909,6 +989,28 @@ const deletingGitRepo = ref(false)
const testingConnection = ref(false)
const connectionTestResult = ref(null)
// Project Tasks
const projectTasks = ref([])
const tasksSummary = computed(() => {
const totalBudget = projectTasks.value.reduce((sum, task) => {
return sum + (task.budget ? parseFloat(task.budget) : 0)
}, 0)
const totalHours = projectTasks.value.reduce((sum, task) => {
return sum + (task.hourContingent ? parseFloat(task.hourContingent) : 0)
}, 0)
const totalPrice = projectTasks.value.reduce((sum, task) => {
return sum + (task.totalPrice ? parseFloat(task.totalPrice) : 0)
}, 0)
return {
totalBudget,
totalHours,
totalPrice
}
})
const gitProviders = [
{ label: 'GitHub', value: 'github' },
{ label: 'Gitea', value: 'gitea' },
@ -1039,9 +1141,12 @@ function onDataLoaded(data) {
async function viewProject(project) {
viewingProject.value = { ...project }
viewDialog.value = true
// Load Git repositories for this project
await loadGitRepositories(project.id)
// Load Git repositories and tasks for this project
await Promise.all([
loadGitRepositories(project.id),
loadProjectTasks(project.id)
])
}
async function loadGitRepositories(projectId) {
@ -1067,6 +1172,25 @@ async function loadGitRepositories(projectId) {
}
}
async function loadProjectTasks(projectId) {
try {
const response = await fetch(`/api/project_tasks?project=${projectId}`)
if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
const data = await response.json()
projectTasks.value = data['hydra:member'] || data.member || data || []
} catch (error) {
console.error('Error loading project tasks:', error)
projectTasks.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Tätigkeiten konnten nicht geladen werden',
life: 3000
})
}
}
function editFromView() {
viewDialog.value = false
editProject(viewingProject.value)

View File

@ -0,0 +1,627 @@
<template>
<div class="project-task-management">
<CrudDataTable
ref="tableRef"
title="Tätigkeiten"
entity-name="Tätigkeit"
entity-name-article="eine"
:columns="taskColumns"
data-source="/api/project_tasks"
storage-key="projectTaskTableColumns"
:show-view-button="canView"
:show-create-button="canCreate"
:show-edit-button="canEdit"
:show-delete-button="canDelete"
:show-export-button="canExport"
@view="viewTask"
@create="openNewTaskDialog"
@edit="editTask"
@delete="confirmDelete"
@data-loaded="onDataLoaded"
>
<!-- Custom Filter Buttons -->
<template #filter-buttons="{ loadData }">
<div class="flex gap-2 mb-4">
<Button
label="Alle"
:outlined="projectFilter !== 'all'"
@click="filterByProject('all', loadData)"
size="small"
/>
<Button
label="Mit Projekt"
:outlined="projectFilter !== 'with-project'"
@click="filterByProject('with-project', loadData)"
size="small"
/>
<Button
label="Ohne Projekt"
:outlined="projectFilter !== 'without-project'"
@click="filterByProject('without-project', loadData)"
size="small"
/>
</div>
</template>
<!-- Custom Column Templates -->
<template #body-name="{ data }">
<div class="font-semibold">{{ data.name }}</div>
</template>
<template #body-project="{ data }">
<div v-if="data.project">{{ data.project.name }}</div>
<span v-else class="text-500">Projektunabhängig</span>
</template>
<template #body-budget="{ data }">
<div v-if="data.budget" class="text-right">{{ formatCurrency(data.budget) }}</div>
</template>
<template #body-hourContingent="{ data }">
<div v-if="data.hourContingent" class="text-right">{{ data.hourContingent }} h</div>
</template>
<template #body-hourlyRate="{ data }">
<div v-if="data.hourlyRate" class="text-right">{{ formatCurrency(data.hourlyRate) }}/h</div>
</template>
<template #body-totalPrice="{ data }">
<div v-if="data.totalPrice" class="text-right font-semibold">{{ formatCurrency(data.totalPrice) }}</div>
</template>
<template #body-pricingModel="{ data }">
<Tag v-if="data.hourlyRate && data.totalPrice" value="Beide" severity="info" />
<Tag v-else-if="data.hourlyRate" value="Stundensatz" severity="success" />
<Tag v-else-if="data.totalPrice" value="Festpreis" severity="warning" />
<span v-else class="text-500">Nicht definiert</span>
</template>
<template #body-createdAt="{ data }">
{{ formatDate(data.createdAt) }}
</template>
<template #body-updatedAt="{ data }">
{{ formatDate(data.updatedAt) }}
</template>
</CrudDataTable>
<!-- Task Dialog -->
<Dialog
v-model:visible="taskDialog"
:header="editingTask?.id ? 'Tätigkeit bearbeiten' : 'Neue Tätigkeit'"
:modal="true"
:style="{ width: '800px' }"
:closable="!saving"
>
<div class="flex flex-col gap-4">
<!-- Basic Information -->
<div class="font-semibold text-lg">Grunddaten</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 col-span-2">
<label for="name">Name der Tätigkeit *</label>
<InputText
id="name"
v-model="editingTask.name"
:class="{ 'p-invalid': submitted && !editingTask.name }"
:disabled="saving"
/>
<small v-if="submitted && !editingTask.name" class="p-error">Name ist erforderlich</small>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label for="description">Beschreibung</label>
<Textarea
id="description"
v-model="editingTask.description"
rows="3"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label for="project">Projekt</label>
<Select
id="project"
v-model="editingTask.project"
:options="projects"
option-label="name"
placeholder="Projekt auswählen (optional)"
filter
:disabled="saving"
show-clear
/>
<small class="text-500">Projektunabhängige Tätigkeiten können nur von Admins erstellt werden</small>
</div>
</div>
<!-- Budget & Contingent -->
<div class="font-semibold text-lg mt-4">Budget & Kontingent</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="budget">Budget ()</label>
<InputNumber
id="budget"
v-model="editingTask.budget"
mode="currency"
currency="EUR"
locale="de-DE"
:min="0"
:disabled="saving"
/>
<small class="text-500">Gesamtbudget für diese Tätigkeit</small>
</div>
<div class="flex flex-col gap-2">
<label for="hourContingent">Stundenkontingent</label>
<InputNumber
id="hourContingent"
v-model="editingTask.hourContingent"
suffix=" h"
:min-fraction-digits="0"
:max-fraction-digits="2"
:min="0"
:disabled="saving"
/>
<small class="text-500">Verfügbare Stunden für diese Tätigkeit</small>
</div>
</div>
<!-- Pricing -->
<div class="font-semibold text-lg mt-4">Preismodell</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="hourlyRate">Stundensatz ()</label>
<InputNumber
id="hourlyRate"
v-model="editingTask.hourlyRate"
mode="currency"
currency="EUR"
locale="de-DE"
:min="0"
:disabled="saving"
@input="updateTotalPriceFromHourly"
/>
<small class="text-500">Preis pro Stunde</small>
</div>
<div class="flex flex-col gap-2">
<label for="totalPrice">Gesamtpreis ()</label>
<InputNumber
id="totalPrice"
v-model="editingTask.totalPrice"
mode="currency"
currency="EUR"
locale="de-DE"
:min="0"
:disabled="saving"
/>
<small class="text-500">Festpreis oder berechneter Gesamtpreis</small>
</div>
</div>
<!-- Pricing Info -->
<div v-if="pricingInfo" class="p-3 border-round border-1 surface-border bg-primary-50 dark:bg-primary-900/20">
<div class="flex items-center gap-2 text-sm">
<i class="pi pi-info-circle text-primary-600"></i>
<span>{{ pricingInfo }}</span>
</div>
</div>
</div>
<template #footer>
<Button label="Abbrechen" @click="taskDialog = false" text :disabled="saving" />
<Button label="Speichern" @click="saveTask" :loading="saving" />
</template>
</Dialog>
<!-- View Task Dialog -->
<Dialog
v-model:visible="viewDialog"
header="Tätigkeit anzeigen"
:modal="true"
:style="{ width: '800px' }"
>
<div class="flex flex-col gap-4">
<!-- Basic Information -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Grunddaten</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Name</label>
<div class="text-900 font-semibold">{{ viewingTask.name || '-' }}</div>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Beschreibung</label>
<div class="text-900 whitespace-pre-wrap">{{ viewingTask.description || '-' }}</div>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Projekt</label>
<div v-if="viewingTask.project" class="text-900">{{ viewingTask.project.name }}</div>
<Tag v-else value="Projektunabhängig" severity="info" />
</div>
</div>
</div>
<!-- Budget & Contingent -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Budget & Kontingent</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Budget</label>
<div class="text-900">{{ viewingTask.budget ? formatCurrency(viewingTask.budget) : '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Stundenkontingent</label>
<div class="text-900">{{ viewingTask.hourContingent ? `${viewingTask.hourContingent} h` : '-' }}</div>
</div>
</div>
</div>
<!-- Pricing -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Preismodell</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Stundensatz</label>
<div class="text-900">{{ viewingTask.hourlyRate ? `${formatCurrency(viewingTask.hourlyRate)}/h` : '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Gesamtpreis</label>
<div class="text-900 font-semibold text-lg">{{ viewingTask.totalPrice ? formatCurrency(viewingTask.totalPrice) : '-' }}</div>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Abrechnungsart</label>
<div>
<Tag v-if="viewingTask.hourlyRate && viewingTask.totalPrice" value="Stundensatz + Gesamtpreis" severity="info" />
<Tag v-else-if="viewingTask.hourlyRate" value="Stundensatz" severity="success" />
<Tag v-else-if="viewingTask.totalPrice" value="Festpreis" severity="warning" />
<span v-else class="text-500">Nicht definiert</span>
</div>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Metadaten</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Erstellt am</label>
<div class="text-900">{{ formatDate(viewingTask.createdAt) || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Zuletzt geändert</label>
<div class="text-900">{{ formatDate(viewingTask.updatedAt) || '-' }}</div>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Schließen" @click="viewDialog = false" />
<Button v-if="canEdit" label="Bearbeiten" @click="editFromView" icon="pi pi-pencil" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="deleteDialog"
header="Tätigkeit löschen"
:modal="true"
:style="{ width: '450px' }"
>
<div class="flex align-items-center gap-3">
<i class="pi pi-exclamation-triangle text-4xl text-red-500" />
<span>Möchten Sie die Tätigkeit <b>{{ taskToDelete?.name }}</b> wirklich löschen?</span>
</div>
<template #footer>
<Button label="Abbrechen" @click="deleteDialog = false" text :disabled="deleting" />
<Button label="Löschen" @click="deleteTask" severity="danger" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { usePermissionStore } from '../stores/permissions'
import CrudDataTable from '../components/CrudDataTable.vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import InputNumber from 'primevue/inputnumber'
import Tag from 'primevue/tag'
const toast = useToast()
const permissionStore = usePermissionStore()
const tableRef = ref(null)
const taskDialog = ref(false)
const viewDialog = ref(false)
const deleteDialog = ref(false)
const editingTask = ref({})
const viewingTask = ref({})
const taskToDelete = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
const projectFilter = ref('all')
const projects = ref([])
// Permission checks
const canView = computed(() => permissionStore.canView('project_tasks'))
const canCreate = computed(() => permissionStore.canCreate('project_tasks'))
const canEdit = computed(() => permissionStore.canEdit('project_tasks'))
const canDelete = computed(() => permissionStore.canDelete('project_tasks'))
const canExport = computed(() => permissionStore.canExport('project_tasks'))
// Pricing info computed
const pricingInfo = computed(() => {
if (!editingTask.value.hourlyRate || !editingTask.value.hourContingent) {
return null
}
const calculatedTotal = editingTask.value.hourlyRate * editingTask.value.hourContingent
if (editingTask.value.totalPrice && Math.abs(calculatedTotal - editingTask.value.totalPrice) > 0.01) {
return `Hinweis: Bei ${editingTask.value.hourContingent}h × ${formatCurrency(editingTask.value.hourlyRate)}/h würde der Gesamtpreis ${formatCurrency(calculatedTotal)} betragen.`
}
return `${editingTask.value.hourContingent}h × ${formatCurrency(editingTask.value.hourlyRate)}/h = ${formatCurrency(calculatedTotal)}`
})
// Column definitions
const taskColumns = ref([
{ key: 'name', label: 'Name', field: 'name', sortable: true, visible: true },
{
key: 'project',
label: 'Projekt',
field: 'project.name',
sortable: false,
visible: true,
dataType: 'text',
showFilterMatchModes: true
},
{ key: 'budget', label: 'Budget', field: 'budget', sortable: true, visible: true },
{ key: 'hourContingent', label: 'Stunden', field: 'hourContingent', sortable: true, visible: true },
{ key: 'hourlyRate', label: 'Stundensatz', field: 'hourlyRate', sortable: true, visible: true },
{ key: 'totalPrice', label: 'Gesamtpreis', field: 'totalPrice', sortable: true, visible: true },
{ key: 'pricingModel', label: 'Abrechnungsart', field: 'pricingModel', sortable: false, visible: true },
{ key: 'createdAt', label: 'Erstellt am', field: 'createdAt', sortable: true, visible: false },
{ key: 'updatedAt', label: 'Geändert am', field: 'updatedAt', sortable: true, visible: false }
])
onMounted(async () => {
await loadProjects()
})
async function loadProjects() {
try {
const response = await fetch('/api/projects?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Projekte')
const data = await response.json()
const projectsList = data['hydra:member'] || data.member || data
projects.value = Array.isArray(projectsList) ? projectsList : []
} catch (error) {
console.error('Error loading projects:', error)
projects.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Projekte konnten nicht geladen werden',
life: 3000
})
}
}
function filterByProject(type, loadData) {
projectFilter.value = type
let filters = {}
if (type === 'with-project') {
filters['hasProject'] = 'true'
} else if (type === 'without-project') {
filters['hasProject'] = 'false'
}
// For 'all', no filter is applied
loadData(filters)
}
function onDataLoaded(data) {
console.log('Tasks loaded:', data.length)
}
async function viewTask(task) {
viewingTask.value = { ...task }
viewDialog.value = true
}
function editFromView() {
viewDialog.value = false
editTask(viewingTask.value)
}
function openNewTaskDialog() {
editingTask.value = {
name: '',
description: '',
project: null,
budget: null,
hourContingent: null,
hourlyRate: null,
totalPrice: null
}
submitted.value = false
taskDialog.value = true
}
function editTask(task) {
// Find project object from projects array
let projectObject = null
if (task.project) {
if (typeof task.project === 'object' && task.project.id) {
projectObject = projects.value.find(p => p.id === task.project.id) || task.project
} else if (typeof task.project === 'string') {
// Extract ID from IRI like "/api/projects/1"
const projectId = parseInt(task.project.split('/').pop())
projectObject = projects.value.find(p => p.id === projectId)
}
}
editingTask.value = {
...task,
project: projectObject,
budget: task.budget ? parseFloat(task.budget) : null,
hourContingent: task.hourContingent ? parseFloat(task.hourContingent) : null,
hourlyRate: task.hourlyRate ? parseFloat(task.hourlyRate) : null,
totalPrice: task.totalPrice ? parseFloat(task.totalPrice) : null
}
submitted.value = false
taskDialog.value = true
}
function updateTotalPriceFromHourly() {
if (editingTask.value.hourlyRate && editingTask.value.hourContingent) {
const calculated = editingTask.value.hourlyRate * editingTask.value.hourContingent
// Only auto-update if totalPrice is not set or is zero
if (!editingTask.value.totalPrice || editingTask.value.totalPrice === 0) {
editingTask.value.totalPrice = calculated
}
}
}
async function saveTask() {
submitted.value = true
if (!editingTask.value.name) {
return
}
saving.value = true
try {
const taskData = {
name: editingTask.value.name,
description: editingTask.value.description || null,
project: editingTask.value.project ? `/api/projects/${editingTask.value.project.id}` : null,
budget: editingTask.value.budget ? editingTask.value.budget.toString() : null,
hourContingent: editingTask.value.hourContingent ? editingTask.value.hourContingent.toString() : null,
hourlyRate: editingTask.value.hourlyRate ? editingTask.value.hourlyRate.toString() : null,
totalPrice: editingTask.value.totalPrice ? editingTask.value.totalPrice.toString() : null
}
const isNew = !editingTask.value.id
const url = isNew ? '/api/project_tasks' : `/api/project_tasks/${editingTask.value.id}`
const method = isNew ? 'POST' : 'PUT'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/ld+json',
'Accept': 'application/ld+json'
},
body: JSON.stringify(taskData)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error['hydra:description'] || error.message || 'Fehler beim Speichern')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: `Tätigkeit wurde ${isNew ? 'erstellt' : 'aktualisiert'}`,
life: 3000
})
taskDialog.value = false
tableRef.value?.loadData()
} catch (error) {
console.error('Error saving task:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Tätigkeit konnte nicht gespeichert werden',
life: 3000
})
} finally {
saving.value = false
}
}
function confirmDelete(task) {
taskToDelete.value = task
deleteDialog.value = true
}
async function deleteTask() {
deleting.value = true
try {
const response = await fetch(`/api/project_tasks/${taskToDelete.value.id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Fehler beim Löschen')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Tätigkeit wurde gelöscht',
life: 3000
})
deleteDialog.value = false
taskToDelete.value = null
tableRef.value?.loadData()
} catch (error) {
console.error('Error deleting task:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Tätigkeit konnte nicht gelöscht werden',
life: 3000
})
} finally {
deleting.value = false
}
}
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function formatCurrency(value) {
if (!value && value !== 0) return ''
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
</script>
<style scoped>
.project-task-management {
height: 100%;
}
</style>

View File

@ -0,0 +1,31 @@
framework:
http_client:
default_options:
# Timeout for external API calls (prevents hanging requests)
timeout: 10
max_duration: 30
# Retry failed requests (network issues, temporary rate limits)
retry_failed:
max_retries: 2
delay: 1000 # 1 second
multiplier: 2 # Exponential backoff
scoped_clients:
# GitHub API client with specific configuration
github.client:
base_uri: 'https://api.github.com'
timeout: 15
max_duration: 45
headers:
Accept: 'application/vnd.github.v3+json'
User-Agent: 'myCRM-App'
# Gitea API client (base_uri set dynamically per request)
gitea.client:
scope: 'https?://.*' # Match any HTTPS URL for Gitea instances
timeout: 15
max_duration: 45
headers:
Accept: 'application/json'
User-Agent: 'myCRM-App'

View File

@ -44,9 +44,11 @@ services:
# GitHub Service with optional token
App\Service\GitHubService:
arguments:
$httpClient: '@github.client'
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
# Gitea Service with optional token
App\Service\GiteaService:
arguments:
$httpClient: '@gitea.client'
$giteaToken: '%env(string:default::GITEA_TOKEN)%'

View File

@ -0,0 +1,180 @@
# Git Repository API Performance Optimization
## Problem
The `/api/git-repos/{id}/commits` endpoint was taking **66+ seconds** (66678ms) to fetch 50 commits from GitHub, making the application unusable.
## Root Causes Identified
1. **No GitHub Authentication Token**
- Unauthenticated GitHub API requests: **60 requests/hour** rate limit
- Authenticated requests: **5000 requests/hour**
- Without a token, GitHub severely throttles requests causing massive delays
2. **No HTTP Timeout Configuration**
- HTTP client waited indefinitely when GitHub throttled requests
- No retry logic for failed or rate-limited requests
- No maximum duration limits
3. **Cache Working But Not Enough**
- 15-minute cache was configured but doesn't help first-time requests
- GitHub API calls were still the bottleneck
## Solutions Implemented
### 1. HTTP Client Configuration (`config/packages/http_client.yaml`)
**Default timeout and retry logic:**
```yaml
default_options:
timeout: 10 # Connection timeout: 10 seconds
max_duration: 30 # Maximum request duration: 30 seconds
retry_failed:
max_retries: 2 # Retry failed requests twice
delay: 1000 # Initial delay: 1 second
multiplier: 2 # Exponential backoff
```
**Scoped clients for GitHub and Gitea:**
```yaml
scoped_clients:
github.client:
base_uri: 'https://api.github.com'
timeout: 15
max_duration: 45
headers:
Accept: 'application/vnd.github.v3+json'
User-Agent: 'myCRM-App'
gitea.client:
scope: 'https?://.*'
timeout: 15
max_duration: 45
headers:
Accept: 'application/json'
User-Agent: 'myCRM-App'
```
### 2. GitHub Token Configuration
**Added to `.env`:**
```env
###> app/git-services ###
# Optional: GitHub Personal Access Token for higher rate limits
# Create at: https://github.com/settings/tokens (needs 'public_repo' scope)
GITHUB_TOKEN=
# Optional: Gitea Access Token for private instances
GITEA_TOKEN=
###< app/git-services ###
```
**How to create a GitHub token:**
1. Go to: https://github.com/settings/tokens
2. Click "Generate new token (classic)"
3. Select scopes:
- `public_repo` - For accessing public repositories
- `repo` - For accessing private repositories (if needed)
4. Copy token and add to `.env.local`:
```
GITHUB_TOKEN=ghp_your_token_here
```
### 3. Service Configuration Update
**Updated `config/services.yaml`:**
```yaml
App\Service\GitHubService:
arguments:
$httpClient: '@github.client' # Use scoped client
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
App\Service\GiteaService:
arguments:
$httpClient: '@gitea.client' # Use scoped client
$giteaToken: '%env(string:default::GITEA_TOKEN)%'
```
## Expected Performance Improvements
| Scenario | Before | After | Improvement |
|----------|--------|-------|-------------|
| **First request (no cache)** | 66+ seconds | 2-5 seconds | **92-97% faster** |
| **Cached requests** | 66+ seconds | <50ms | **99.9% faster** |
| **With GitHub token** | 66+ seconds | 1-2 seconds | **98% faster** |
| **Timeout prevents hanging** | ∞ (infinite wait) | Max 45 seconds | Guaranteed response |
## Testing Performance
### Test without token:
```bash
# First request (cache miss)
curl "https://mycrm.test/api/git-repos/4/commits?branch=main&limit=50" -w "\nTime: %{time_total}s\n"
# Second request (cache hit - should be <50ms)
curl "https://mycrm.test/api/git-repos/4/commits?branch=main&limit=50" -w "\nTime: %{time_total}s\n"
```
### Test with token:
1. Add `GITHUB_TOKEN=ghp_xxx` to `.env.local`
2. Clear cache: `php bin/console cache:clear`
3. Clear API cache via UI or: `curl -X POST https://mycrm.test/api/git-repos/4/refresh-cache`
4. Test again - should be 1-2 seconds
### Monitor rate limits:
```bash
curl -I https://api.github.com/rate_limit
# Without token: X-RateLimit-Limit: 60
# With token: X-RateLimit-Limit: 5000
```
## Additional Optimizations
### 1. Reduce Cache Duration (if needed)
Current: 15 minutes (`900` seconds)
```php
// In GitRepositoryController.php line 39
$item->expiresAfter(900); // Reduce to 300 (5 min) for more frequent updates
```
### 2. Background Jobs (future enhancement)
For repositories with frequent updates, consider:
- Symfony Messenger to fetch commits in background
- Pre-warm cache on schedule (every 10 minutes)
- Webhook integration with GitHub/Gitea for instant updates
### 3. Partial Response Loading
Instead of loading all 50 commits at once:
- Load 10 commits initially (fast)
- Lazy-load more on scroll (pagination)
## Monitoring
### Check cache effectiveness:
```bash
# Monitor cache hit/miss ratio
php bin/console cache:pool:list
php bin/console cache:pool:prune # Clean up old cache entries
```
### Check HTTP client metrics:
```bash
# Enable Symfony profiler and check HTTP client panel
# Shows: request duration, retry attempts, cache hits
```
## Rollback Plan
If issues occur, revert by:
1. Remove `config/packages/http_client.yaml`
2. Restore original `config/services.yaml` (remove `$httpClient` arguments)
3. Clear cache: `php bin/console cache:clear`
## Related Files
- `config/packages/http_client.yaml` - HTTP client configuration
- `config/services.yaml` - Service injection configuration
- `.env` - Token configuration (template)
- `.env.local` - Actual tokens (gitignored)
- `src/Controller/GitRepositoryController.php` - Caching logic
- `src/Service/GitHubService.php` - GitHub API integration
- `src/Service/GiteaService.php` - Gitea API integration

200
docs/INSTRUCTIONS.md Normal file
View File

@ -0,0 +1,200 @@
# myCRM - AI Agent Instructions
## Project Overview
Modern, modular CRM system built with Symfony LTS. Focus on security, UX, and extensibility with a native-app-like feel through heavy AJAX usage.
## Tech Stack
- **Backend**: Symfony (current LTS version) - PHP framework following best practices
- **Database**: MariaDB (unless specific reasons dictate otherwise)
- **Frontend**: Vue.js 3 with Composition API, bundled via Symfony Webpack Encore
- **UI Components**: PrimeVue (DataTable, Charts, Forms, Dialogs for professional CRM UI)
- **Authentication**: Symfony Security component with modern permission system (RBAC/Voter pattern)
- **API**: API Platform for RESTful APIs with auto-generated OpenAPI docs
- **Admin UI**: Custom Vue.js components (no EasyAdmin) for maximum flexibility
## Development Workflow
```bash
# Initial setup
composer install
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
npm install && npm run dev
# Run development (parallel terminals recommended)
symfony serve -d # Backend server on :8000
npm run watch # Encore: Hot reload for Vue.js changes
# Production build
npm run build # Minified assets for deployment
# Run tests
php bin/phpunit # Backend tests
npm run test:unit # Vue component tests (Vitest/Jest)
php bin/console doctrine:schema:validate
# Cache management
php bin/console cache:clear
APP_ENV=prod php bin/console cache:warmup
```
## Symfony-Specific Conventions
### Directory Structure (Symfony Standard)
```
/config - YAML/PHP configuration files, routes
/src
/Controller - HTTP controllers (keep thin, delegate to services)
/Entity - Doctrine entities (CRM: Contact, Company, Deal, Activity, User)
/Repository - Database queries
/Service - Business logic (pipeline calculations, lifecycle management)
/Security/Voter - Permission logic per entity
/Form - Form types for entities
/EventListener - Doctrine events, kernel events
/templates - Twig templates (base layout, embed Vue app)
/assets
/js - Vue.js components, composables, stores (Pinia)
/components - Reusable Vue components (ContactCard, DealPipeline)
/views - Page-level Vue components
/api - API client wrappers for API Platform endpoints
/styles - SCSS/CSS (scoped styles in Vue SFCs)
/migrations - Doctrine migrations (version controlled)
/tests - PHPUnit tests (backend), Vitest/Jest (frontend)
```
### Key Architectural Patterns
**Controllers**: Keep lean - validate input, call services, return JSON/HTML
```php
// Good: Delegate to service
return $this->json($contactService->createContact($request->toArray()));
```
**Services**: Inject dependencies via constructor, use interfaces for flexibility
```php
class ContactLifecycleService {
public function __construct(
private EntityManagerInterface $em,
private EventDispatcherInterface $dispatcher
) {}
}
```
**Entities**: Use Doctrine annotations/attributes, define relationships carefully
- Contact ↔ Company (ManyToOne/OneToMany)
- Contact ↔ Activities (OneToMany)
- Deal ↔ Contact (ManyToOne with Deal ownership)
**Security Voters**: Implement granular permissions per entity action
```php
// Example: ContactVoter checks if user can VIEW/EDIT/DELETE specific contact
protected function supports(string $attribute, mixed $subject): bool
```
**Vue.js Integration**: Symfony renders base Twig template, Vue takes over
- Twig template loads Vue app entry point via Encore
- API Platform provides REST endpoints, Vue consumes them
- State management: Pinia stores for global state (current user, permissions)
- Routing: Vue Router for SPA navigation within CRM modules
**API Pattern**: API Platform handles CRUD, custom endpoints for business logic
```php
// Custom API endpoint example
#[Route('/api/deals/{id}/advance-stage', methods: ['POST'])]
public function advanceStage(Deal $deal): JsonResponse
{
$this->denyAccessUnlessGranted('EDIT', $deal);
return $this->json($this->dealService->advanceToNextStage($deal));
}
```
**Vue Component Pattern**: Composables for API calls, components for UI
```javascript
// composables/useContacts.js
export function useContacts() {
const contacts = ref([])
const loading = ref(false)
async function fetchContacts() {
loading.value = true
const response = await fetch('/api/contacts')
contacts.value = await response.json()
loading.value = false
}
return { contacts, loading, fetchContacts }
}
```
### CRM Domain Logic
**Core Entities**:
- `Contact`: Person with lifecycle state (Lead → Qualified → Customer)
- `Company`: Organization linked to contacts
- `Deal`: Sales opportunity with pipeline stage, value, probability
- `Activity`: Interaction record (call, email, meeting, note)
- `User`: System user with role-based permissions
**Permission System**: Use Symfony Voters for fine-grained access
- Entity-level: Can user view/edit this specific contact?
- Module-level: Can user access Reports module?
- Action-level: Can user export data?
**API Modules**: Expose selected functionality via RESTful endpoints
- Authentication: JWT tokens or API keys
- Rate limiting: Consider API Platform's built-in support
- Documentation: OpenAPI/Swagger auto-generated
## Code Quality Standards
- Follow Symfony best practices and PSR-12
- Type hints everywhere (PHP 8.x features)
- Doctrine migrations for all schema changes (never alter DB manually)
- Services autowired and autoconfigured in `services.yaml`
- Environment variables for configuration (`.env`, `.env.local`)
## Testing Strategy
- Unit tests for services (PHPUnit)
- Functional tests for controllers (WebTestCase)
- Doctrine schema validation in CI
- Security: Test voter logic explicitly
## Frontend Architecture Details
**Encore Configuration**: `webpack.config.js` compiles Vue SFCs
```javascript
Encore
.addEntry('app', './assets/js/app.js') // Main Vue app
.enableVueLoader()
.enableSassLoader()
.enablePostCssLoader()
```
**PrimeVue Integration**:
- Install: `npm install primevue primeicons`
- Use DataTable for contact/deal lists with filtering, sorting, pagination
- Use Dialog/Sidebar for forms (better UX than full page forms)
- Use Chart components for pipeline analytics, revenue forecasts
- Theme: Customize PrimeVue theme to match brand (Sass variables)
**Vue Component Organization**:
- `ContactList.vue` - PrimeVue DataTable with filters, export (talks to `/api/contacts`)
- `ContactDetail.vue` - TabView with form, activity timeline, related deals
- `DealPipeline.vue` - Custom Kanban or PrimeVue OrderList (update via API Platform)
- `ActivityFeed.vue` - Timeline component with real-time updates
- `Dashboard.vue` - Chart.js via PrimeVue Chart for KPIs
**Authentication in Vue**: Pass Symfony user data to Vue via Twig
```twig
<div id="app"
data-user="{{ app.user|json_encode }}"
data-permissions="{{ user_permissions|json_encode }}">
</div>
```
## Next Steps for AI Agents
As code develops, update this file with:
1. PrimeVue theme customization (specific color palette, component overrides)
2. Custom Doctrine types or extensions in use
3. Mercure integration for real-time updates (if implemented)
4. Event-driven patterns (custom events for CRM workflows)
5. Background job processing with Symfony Messenger
6. Deployment strategy (Docker, traditional hosting)

View File

@ -0,0 +1,651 @@
# ProjectTask Modul - Dokumentation
**Erstellt am:** 14. November 2025
**Status:** Produktionsbereit ✅
## Überblick
Das ProjectTask-Modul ermöglicht die Verwaltung von Tätigkeiten (Arbeitspakete) im myCRM-System. Tätigkeiten können projektbezogen oder projektunabhängig sein und verfügen über Budget-Tracking, Stundenkontingente und flexible Preismodelle.
---
## Features
### ✅ Kerngfunktionen
- **Projektbezogene & projektunabhängige Tätigkeiten**
- Tätigkeiten können einem Projekt zugeordnet werden (optional)
- Projektunabhängige Tätigkeiten nur für Admins
- **Budget & Stundenkontingent**
- Budget-Tracking pro Tätigkeit
- Stundenkontingent definieren
- Aggregierte Übersichten auf Projekt-Ebene
- **Flexibles Preismodell**
- Stundensatz (€/h)
- Gesamtpreis (Festpreis)
- Beide Varianten kombinierbar
- Automatische Berechnung im Frontend
- **Berechtigungssystem**
- Granulare Zugriffskontrolle via Symfony Security Voters
- Admin- und Team-basierte Berechtigungen
- Projektteammitglieder können Tätigkeiten verwalten
- **Frontend-Integration**
- Moderne Vue.js-Komponente mit PrimeVue
- CrudDataTable mit Filter, Export und Spaltenkonfiguration
- Integration im Dashboard und Projekt-Detailansicht
- Responsive Design
---
## Backend-Architektur
### 1. Entity: `ProjectTask`
**Pfad:** `src/Entity/ProjectTask.php`
#### Felder
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | int | Primärschlüssel |
| `name` | string(255) | Name der Tätigkeit (Pflichtfeld) |
| `description` | text | Beschreibung (optional) |
| `project` | ManyToOne | Verknüpfung zu Project (optional, CASCADE delete) |
| `budget` | decimal(10,2) | Budget in Euro |
| `hourContingent` | decimal(8,2) | Verfügbare Stunden |
| `hourlyRate` | decimal(8,2) | Stundensatz in Euro |
| `totalPrice` | decimal(10,2) | Gesamtpreis in Euro |
| `createdAt` | DateTimeImmutable | Erstellungsdatum |
| `updatedAt` | DateTimeImmutable | Letzte Änderung |
#### API Platform Konfiguration
```php
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('VIEW', 'project_tasks')"),
new Get(security: "is_granted('VIEW', object)"),
new Post(security: "is_granted('CREATE', 'project_tasks')"),
new Put(security: "is_granted('EDIT', object)"),
new Delete(security: "is_granted('DELETE', object)")
],
paginationClientItemsPerPage: true,
paginationItemsPerPage: 30,
normalizationContext: ['groups' => ['project_task:read']],
denormalizationContext: ['groups' => ['project_task:write']],
order: ['createdAt' => 'DESC']
)]
```
#### API-Filter
- **SearchFilter:** `name`, `project.name` (partial search)
- **DateFilter:** `createdAt`, `updatedAt`
- **ProjectTaskProjectFilter:** Custom Filter für Projekt-Existenz (`hasProject=true/false`)
---
### 2. Repository: `ProjectTaskRepository`
**Pfad:** `src/Repository/ProjectTaskRepository.php`
#### Methoden
| Methode | Beschreibung |
|---------|--------------|
| `findByProject(Project $project)` | Tasks eines bestimmten Projekts |
| `findWithoutProject()` | Projektunabhängige Tasks |
| `findUserTasks(User $user)` | Tasks mit Benutzerzugriff |
| `getTotalBudgetByProject(Project $project)` | Gesamtbudget-Berechnung |
| `getTotalHourContingentByProject(Project $project)` | Gesamtstunden-Berechnung |
**Beispiel:**
```php
// Alle Tasks eines Projekts laden
$tasks = $projectTaskRepository->findByProject($project);
// Gesamtbudget berechnen
$totalBudget = $projectTaskRepository->getTotalBudgetByProject($project);
```
---
### 3. Security: `ProjectTaskVoter`
**Pfad:** `src/Security/Voter/ProjectTaskVoter.php`
#### Berechtigungslogik
| Aktion | Berechtigung |
|--------|--------------|
| **VIEW** | Admin ODER Projektteammitglied |
| **EDIT** | Admin ODER Projektteammitglied |
| **DELETE** | Admin ODER Projektbesitzer |
| **CREATE** | Alle authentifizierten User (mit Prüfung) |
#### Besonderheiten
- **Projektunabhängige Tasks:** Nur Admins haben Zugriff
- **Projektbezogene Tasks:** Zugriff über Projektmitgliedschaft
- Prüfung erfolgt über `Project::hasAccess(User $user)` Methode
**Beispiel:**
```php
// Im Controller
$this->denyAccessUnlessGranted('VIEW', $projectTask);
$this->denyAccessUnlessGranted('EDIT', $projectTask);
$this->denyAccessUnlessGranted('DELETE', $projectTask);
```
---
### 4. Event Listener: `ProjectTaskSecurityListener`
**Pfad:** `src/EventListener/ProjectTaskSecurityListener.php`
#### Funktion
Validiert Berechtigungen beim **Erstellen** neuer Tasks (Doctrine `prePersist` Event):
- **Ohne Projekt:** Nur Admins dürfen erstellen
- **Mit Projekt:** Admin oder Projektteammitglied
**Beispiel-Exception:**
```
AccessDeniedException: "Nur Administratoren können projektunabhängige Tätigkeiten erstellen."
```
---
### 5. Custom Filter: `ProjectTaskProjectFilter`
**Pfad:** `src/Filter/ProjectTaskProjectFilter.php`
#### Beschreibung
API Platform Filter zur Filterung nach Projekt-Existenz.
#### Query Parameter
```
GET /api/project_tasks?hasProject=true → Nur Tasks mit Projekt
GET /api/project_tasks?hasProject=false → Nur Tasks ohne Projekt
```
#### Implementation
```php
if ($hasProject) {
$queryBuilder->andWhere('task.project IS NOT NULL');
} else {
$queryBuilder->andWhere('task.project IS NULL');
}
```
---
## Frontend-Architektur
### 1. Hauptkomponente: `ProjectTaskManagement.vue`
**Pfad:** `assets/js/views/ProjectTaskManagement.vue`
#### Features
- **CrudDataTable** für Übersicht
- **Filterfunktionen:**
- Alle / Mit Projekt / Ohne Projekt
- Spaltenfilter (Name, Projekt, etc.)
- Globale Suche
- **CRUD-Operationen:**
- Create-Dialog mit Formular
- Edit-Dialog (vorausgefülltes Formular)
- View-Dialog (Readonly-Ansicht)
- Delete-Bestätigung
- **Spalten:**
- Name
- Projekt (mit verschachteltem Feld-Support)
- Budget
- Stundenkontingent
- Stundensatz
- Gesamtpreis
- Abrechnungsart (Tag)
#### Key Functions
```javascript
// Filter nach Projekt-Existenz
function filterByProject(type, loadData) {
let filters = {}
if (type === 'with-project') {
filters['hasProject'] = 'true'
} else if (type === 'without-project') {
filters['hasProject'] = 'false'
}
loadData(filters)
}
// Preisberechnung
function updateTotalPriceFromHourly() {
if (editingTask.value.hourlyRate && editingTask.value.hourContingent) {
const calculated = editingTask.value.hourlyRate * editingTask.value.hourContingent
if (!editingTask.value.totalPrice || editingTask.value.totalPrice === 0) {
editingTask.value.totalPrice = calculated
}
}
}
```
---
### 2. Dashboard-Integration
**Pfad:** `assets/js/views/Dashboard.vue`
#### Features
- **KPI-Card:** Zeigt Gesamtzahl der Tätigkeiten
- **Widget "Neueste Tätigkeiten":**
- Top 5 neueste Tasks
- Projekt-Tag oder "Projektunabhängig"
- Stundensatz & Gesamtpreis
- Progress-Bar für Stundenkontingent
- **Budget-Übersicht:**
- Progress Bar für Task-Budgets
- Anteil der Tasks mit Budget
---
### 3. Projekt-Detailansicht Integration
**Pfad:** `assets/js/views/ProjectManagement.vue`
#### Features
- **Neuer Tab "Tätigkeiten"** im Projekt-View-Dialog
- **Zusammenfassung:**
- Gesamt-Budget aller Tasks
- Gesamt-Stunden
- Gesamt-Preis
- **DataTable** mit allen Tasks des Projekts
- **Filterung:** Tasks werden automatisch nach Projekt gefiltert
```javascript
async function loadProjectTasks(projectId) {
const response = await fetch(`/api/project_tasks?project=${projectId}`)
const data = await response.json()
projectTasks.value = data['hydra:member'] || []
}
```
---
### 4. Router-Konfiguration
**Pfad:** `assets/js/router.js`
```javascript
{
path: '/project-tasks',
name: 'project-tasks',
component: ProjectTaskManagement
}
```
---
### 5. Menü-Integration
**Pfad:** `assets/js/layout/AppMenu.vue`
```javascript
{
label: 'CRM',
items: [
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
]
}
```
---
## API Endpoints
### REST-Endpoints
| Methode | Endpoint | Beschreibung |
|---------|----------|--------------|
| `GET` | `/api/project_tasks` | Liste aller Tasks (mit Paginierung) |
| `GET` | `/api/project_tasks/{id}` | Einzelne Task abrufen |
| `POST` | `/api/project_tasks` | Neue Task erstellen |
| `PUT` | `/api/project_tasks/{id}` | Task aktualisieren |
| `DELETE` | `/api/project_tasks/{id}` | Task löschen |
### Filter-Parameter
```
GET /api/project_tasks?hasProject=true
GET /api/project_tasks?hasProject=false
GET /api/project_tasks?project=5
GET /api/project_tasks?name=entwicklung
GET /api/project_tasks?project.name=myproject
```
### Request Body (POST/PUT)
```json
{
"name": "Frontend-Entwicklung",
"description": "Implementierung der Vue.js Komponenten",
"project": "/api/projects/5",
"budget": "5000.00",
"hourContingent": "50.00",
"hourlyRate": "100.00",
"totalPrice": "5000.00"
}
```
### Response (GET)
```json
{
"@context": "/api/contexts/ProjectTask",
"@id": "/api/project_tasks/1",
"@type": "ProjectTask",
"id": 1,
"name": "Frontend-Entwicklung",
"description": "Implementierung der Vue.js Komponenten",
"project": {
"@id": "/api/projects/5",
"id": 5,
"name": "myCRM Projekt"
},
"budget": "5000.00",
"hourContingent": "50.00",
"hourlyRate": "100.00",
"totalPrice": "5000.00",
"createdAt": "2025-11-14T14:32:27+00:00",
"updatedAt": "2025-11-14T15:45:12+00:00"
}
```
---
## Datenbank-Schema
### Tabelle: `project_tasks`
```sql
CREATE TABLE project_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description LONGTEXT DEFAULT NULL,
project_id INT DEFAULT NULL,
budget NUMERIC(10, 2) DEFAULT NULL,
hour_contingent NUMERIC(8, 2) DEFAULT NULL,
hourly_rate NUMERIC(8, 2) DEFAULT NULL,
total_price NUMERIC(10, 2) DEFAULT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX IDX_project_id (project_id),
CONSTRAINT FK_project_tasks_project FOREIGN KEY (project_id)
REFERENCES projects (id) ON DELETE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB;
```
### Migration
**Datei:** `migrations/Version20251114143227.php`
```bash
php bin/console doctrine:migrations:migrate
```
---
## Berechtigungen & Sicherheit
### Berechtigungs-Matrix
| Rolle | VIEW | CREATE | EDIT | DELETE | Projektunabh. erstellen |
|-------|------|--------|------|--------|-------------------------|
| **Admin** | ✅ Alle | ✅ Alle | ✅ Alle | ✅ Alle | ✅ |
| **Projektbesitzer** | ✅ Projekt-Tasks | ✅ Projekt-Tasks | ✅ Projekt-Tasks | ✅ Projekt-Tasks | ❌ |
| **Teammitglied** | ✅ Projekt-Tasks | ✅ Projekt-Tasks | ✅ Projekt-Tasks | ❌ | ❌ |
| **Anderer User** | ❌ | ❌ | ❌ | ❌ | ❌ |
### Validierung
#### Backend-Validierung
```php
#[Assert\NotBlank(message: 'Der Name der Tätigkeit darf nicht leer sein')]
#[Assert\Length(max: 255)]
private ?string $name = null;
#[Assert\PositiveOrZero(message: 'Das Budget muss positiv sein')]
private ?string $budget = null;
#[Assert\PositiveOrZero(message: 'Das Stundenkontingent muss positiv sein')]
private ?string $hourContingent = null;
#[Assert\PositiveOrZero(message: 'Der Stundensatz muss positiv sein')]
private ?string $hourlyRate = null;
#[Assert\PositiveOrZero(message: 'Der Gesamtpreis muss positiv sein')]
private ?string $totalPrice = null;
```
#### Frontend-Validierung
```javascript
async function saveTask() {
submitted.value = true
if (!editingTask.value.name) {
return // Name ist Pflichtfeld
}
// ... Speichern
}
```
---
## Troubleshooting
### Problem: Filter für Projekt-Spalte funktioniert nicht
**Symptom:** Spaltenfilter in DataTable öffnet sich nicht oder zeigt Fehler.
**Lösung:**
- Sicherstellen, dass `filterField` korrekt konfiguriert ist
- Filter müssen in `CrudDataTable` für verschachtelte Felder initialisiert werden
```javascript
// In ProjectTaskManagement.vue
{
key: 'project',
label: 'Projekt',
field: 'project.name', // Verschachteltes Feld
dataType: 'text',
showFilterMatchModes: true
}
// In CrudDataTable.vue (onMounted)
const filterKey = col.filterField || col.field || col.key
internalFilters.value[filterKey] = {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
}
```
### Problem: Projekt wird nicht in Übersicht angezeigt
**Symptom:** Projekt-Spalte zeigt immer "Projektunabhängig".
**Lösung:**
- Serialization Groups in Project Entity anpassen:
```php
#[Groups(['project:read', 'project_task:read'])] // project_task:read hinzufügen!
private ?int $id = null;
#[Groups(['project:read', 'project:write', 'project_task:read'])]
private ?string $name = null;
```
### Problem: Projektauswahl wird nicht gespeichert
**Symptom:** Beim Bearbeiten wird das Projekt nicht im Select angezeigt.
**Lösung:**
- Projekt-Objekt aus IRI extrahieren:
```javascript
function editTask(task) {
let projectObject = null
if (task.project) {
if (typeof task.project === 'object' && task.project.id) {
projectObject = projects.value.find(p => p.id === task.project.id) || task.project
} else if (typeof task.project === 'string') {
const projectId = parseInt(task.project.split('/').pop())
projectObject = projects.value.find(p => p.id === projectId)
}
}
editingTask.value = {
...task,
project: projectObject // Objekt statt IRI
}
}
```
---
## Testing
### Unit Tests (PHPUnit)
```bash
# Entity Tests
php bin/phpunit tests/Entity/ProjectTaskTest.php
# Repository Tests
php bin/phpunit tests/Repository/ProjectTaskRepositoryTest.php
# Voter Tests
php bin/phpunit tests/Security/Voter/ProjectTaskVoterTest.php
```
### Frontend Tests (Vitest)
```bash
# Component Tests
npm run test -- ProjectTaskManagement.spec.js
# E2E Tests
npm run test:e2e -- project-tasks.spec.js
```
---
## Performance-Optimierung
### Backend
- **Eager Loading:** Projekte werden mit Tasks geladen, um N+1 Queries zu vermeiden
- **Indexierung:** Index auf `project_id` für schnelle Filterung
- **Pagination:** Client-side Pagination für große Datenmengen
### Frontend
- **Lazy Loading:** Vue.js Route-based Code Splitting
- **Computed Properties:** Für reaktive Berechnungen (Budget-Summen)
- **Debouncing:** Für Suche/Filter-Eingaben
---
## Zukünftige Erweiterungen
### 🔮 Geplante Features
1. **Zeiterfassung**
- Tatsächlich gebuchte Stunden tracken
- Vergleich: Geplant vs. Tatsächlich
- Zeiterfassungs-Widget
2. **Rechnungsstellung**
- Tasks zu Rechnungen zuordnen
- Automatische Rechnungsgenerierung aus Tasks
- Status: Abgerechnet/Offen
3. **Dashboard-Widget**
- Übersicht über laufende Tätigkeiten
- Budget-Status (Verbrauch)
- Warnung bei Überschreitung
4. **Berechtigungen verfeinern**
- Modul-spezifische Rollen
- Feinere Kontrolle über Task-Berechtigungen
- Team-basierte Zugriffskontrolle
5. **Reporting**
- Budget-Reports pro Projekt
- Stundenübersichten
- Export als PDF/Excel
6. **Abhängigkeiten**
- Task-Abhängigkeiten definieren
- Gantt-Chart-Ansicht
- Kritischer Pfad
---
## Changelog
### Version 1.0.0 (14.11.2025)
**Neu:**
- ✅ ProjectTask Entity mit allen Feldern
- ✅ ProjectTaskRepository mit Aggregations-Methoden
- ✅ ProjectTaskVoter für Berechtigungen
- ✅ ProjectTaskSecurityListener für Validierung
- ✅ Custom ProjectTaskProjectFilter
- ✅ Frontend: ProjectTaskManagement.vue
- ✅ Dashboard-Integration
- ✅ Projekt-Detailansicht Integration
- ✅ CrudDataTable mit verschachtelten Feld-Support
- ✅ Router & Menü-Konfiguration
- ✅ Doctrine Migration
- ✅ API Platform Konfiguration
**Fixes:**
- ✅ Artikel "Neue Tätigkeit" (statt "Neuer")
- ✅ Projekt-Anzeige in Übersicht (Serialization Groups)
- ✅ Projektauswahl beim Bearbeiten
- ✅ Filter "Mit Projekt" / "Ohne Projekt"
- ✅ Spaltenfilter für verschachtelte Felder
---
## Kontakt & Support
Bei Fragen oder Problemen:
- **Dokumentation:** `docs/INSTRUCTIONS.md`
- **GitHub Issues:** [GitHub Repository]
- **Code Review:** Siehe Code-Kommentare in den jeweiligen Dateien
---
**Ende der Dokumentation**

View File

@ -0,0 +1,37 @@
<?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 Version20251114143227 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create project_tasks table for project-related and independent tasks';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE project_tasks (id INT AUTO_INCREMENT NOT NULL, project_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, budget NUMERIC(10, 2) DEFAULT NULL, hour_contingent NUMERIC(8, 2) DEFAULT NULL, hourly_rate NUMERIC(8, 2) DEFAULT NULL, total_price NUMERIC(10, 2) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_430D6C09166D1F9C (project_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE project_tasks ADD CONSTRAINT FK_430D6C09166D1F9C FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_team_members RENAME INDEX idx_8a3c5f7d166d1f9c TO IDX_907E47AB166D1F9C');
$this->addSql('ALTER TABLE project_team_members RENAME INDEX idx_8a3c5f7da76ed395 TO IDX_907E47ABA76ED395');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project_tasks DROP FOREIGN KEY FK_430D6C09166D1F9C');
$this->addSql('DROP TABLE project_tasks');
$this->addSql('ALTER TABLE project_team_members RENAME INDEX idx_907e47ab166d1f9c TO IDX_8A3C5F7D166D1F9C');
$this->addSql('ALTER TABLE project_team_members RENAME INDEX idx_907e47aba76ed395 TO IDX_8A3C5F7DA76ED395');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@ -11,35 +12,82 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
class PermissionController extends AbstractController
{
public function __construct(
private UserRepository $userRepository
) {}
#[Route('/api/permissions', name: 'api_permissions', methods: ['GET'])]
public function getPermissions(): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['error' => 'Not authenticated'], 401);
}
$permissions = [];
// Eager load user with all permissions to avoid N+1 queries
$userWithPermissions = $this->userRepository->findWithPermissions($user->getId());
if (!$userWithPermissions) {
return new JsonResponse(['error' => 'User not found'], 404);
}
// Build permission map once instead of calling hasModulePermission 42 times
$permissionMap = $this->buildPermissionMap($userWithPermissions);
// Liste aller Module die geprüft werden sollen
$modules = ['contacts', 'projects', 'project_statuses', 'documents', 'users', 'roles', 'settings'];
$permissions = [];
foreach ($modules as $module) {
$permissions[$module] = [
'view' => $user->hasModulePermission($module, 'view'),
'create' => $user->hasModulePermission($module, 'create'),
'edit' => $user->hasModulePermission($module, 'edit'),
'delete' => $user->hasModulePermission($module, 'delete'),
'export' => $user->hasModulePermission($module, 'export'),
'manage' => $user->hasModulePermission($module, 'manage'),
$permissions[$module] = $permissionMap[$module] ?? [
'view' => false,
'create' => false,
'edit' => false,
'delete' => false,
'export' => false,
'manage' => false,
];
}
return new JsonResponse([
'permissions' => $permissions,
'isAdmin' => in_array('ROLE_ADMIN', $user->getRoles())
'isAdmin' => in_array('ROLE_ADMIN', $userWithPermissions->getRoles())
]);
}
private function buildPermissionMap(User $user): array
{
$permissionMap = [];
// Iterate once through all roles and permissions
foreach ($user->getUserRoles() as $role) {
foreach ($role->getPermissions() as $permission) {
$moduleCode = $permission->getModule()->getCode();
// Initialize module permissions if not exists
if (!isset($permissionMap[$moduleCode])) {
$permissionMap[$moduleCode] = [
'view' => false,
'create' => false,
'edit' => false,
'delete' => false,
'export' => false,
'manage' => false,
];
}
// Merge permissions (OR logic - if any role has permission, user has it)
$permissionMap[$moduleCode]['view'] = $permissionMap[$moduleCode]['view'] || $permission->canView();
$permissionMap[$moduleCode]['create'] = $permissionMap[$moduleCode]['create'] || $permission->canCreate();
$permissionMap[$moduleCode]['edit'] = $permissionMap[$moduleCode]['edit'] || $permission->canEdit();
$permissionMap[$moduleCode]['delete'] = $permissionMap[$moduleCode]['delete'] || $permission->canDelete();
$permissionMap[$moduleCode]['export'] = $permissionMap[$moduleCode]['export'] || $permission->canExport();
$permissionMap[$moduleCode]['manage'] = $permissionMap[$moduleCode]['manage'] || $permission->canManage();
}
}
return $permissionMap;
}
}

View File

@ -24,6 +24,11 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
?Operation $operation = null,
array $context = []
): void {
// This extension is disabled because we use a custom ProjectCollectionProvider
// that handles access filtering with optimized eager loading
return;
/*
if (Project::class !== $resourceClass) {
return;
}
@ -40,5 +45,6 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias)
->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias))
->setParameter('current_user', $user);
*/
}
}

View File

@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Entity\Interface\ModuleAwareInterface;
use App\Repository\ProjectRepository;
use App\State\ProjectCollectionProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@ -27,7 +28,8 @@ use Symfony\Component\Validator\Constraints as Assert;
operations: [
new GetCollection(
security: "is_granted('VIEW', 'projects')",
stateless: false
stateless: false,
provider: ProjectCollectionProvider::class
),
new Get(
security: "is_granted('VIEW', object)",
@ -61,11 +63,11 @@ class Project implements ModuleAwareInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read'])]
#[Groups(['project:read', 'project_task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write'])]
#[Groups(['project:read', 'project:write', 'project_task:read'])]
#[Assert\NotBlank(message: 'Der Projektname darf nicht leer sein')]
#[Assert\Length(max: 255)]
private ?string $name = null;

237
src/Entity/ProjectTask.php Normal file
View File

@ -0,0 +1,237 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use App\Filter\ProjectTaskProjectFilter;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Entity\Interface\ModuleAwareInterface;
use App\Repository\ProjectTaskRepository;
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: ProjectTaskRepository::class)]
#[ORM\Table(name: 'project_tasks')]
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('VIEW', 'project_tasks')",
stateless: false
),
new Get(
security: "is_granted('VIEW', object)",
stateless: false
),
new Post(
security: "is_granted('CREATE', 'project_tasks')",
stateless: false
),
new Put(
security: "is_granted('EDIT', object)",
stateless: false
),
new Delete(
security: "is_granted('DELETE', object)",
stateless: false
)
],
paginationClientItemsPerPage: true,
paginationItemsPerPage: 30,
paginationMaximumItemsPerPage: 5000,
normalizationContext: ['groups' => ['project_task:read']],
denormalizationContext: ['groups' => ['project_task:write']],
order: ['createdAt' => 'DESC']
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'project.name' => 'partial'])]
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
#[ApiFilter(ProjectTaskProjectFilter::class)]
class ProjectTask implements ModuleAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project_task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['project_task:read', 'project_task:write'])]
#[Assert\NotBlank(message: 'Der Name der Tätigkeit darf nicht leer sein')]
#[Assert\Length(max: 255)]
private ?string $name = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['project_task:read', 'project_task:write'])]
private ?string $description = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups(['project_task:read', 'project_task:write'])]
private ?Project $project = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['project_task:read', 'project_task:write'])]
#[Assert\PositiveOrZero(message: 'Das Budget muss positiv sein')]
private ?string $budget = null;
#[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 2, nullable: true)]
#[Groups(['project_task:read', 'project_task:write'])]
#[Assert\PositiveOrZero(message: 'Das Stundenkontingent muss positiv sein')]
private ?string $hourContingent = null;
#[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 2, nullable: true)]
#[Groups(['project_task:read', 'project_task:write'])]
#[Assert\PositiveOrZero(message: 'Der Stundensatz muss positiv sein')]
private ?string $hourlyRate = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['project_task:read', 'project_task:write'])]
#[Assert\PositiveOrZero(message: 'Der Gesamtpreis muss positiv sein')]
private ?string $totalPrice = null;
#[ORM\Column]
#[Groups(['project_task:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
#[Groups(['project_task:read'])]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getBudget(): ?string
{
return $this->budget;
}
public function setBudget(?string $budget): static
{
$this->budget = $budget;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getHourContingent(): ?string
{
return $this->hourContingent;
}
public function setHourContingent(?string $hourContingent): static
{
$this->hourContingent = $hourContingent;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getHourlyRate(): ?string
{
return $this->hourlyRate;
}
public function setHourlyRate(?string $hourlyRate): static
{
$this->hourlyRate = $hourlyRate;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getTotalPrice(): ?string
{
return $this->totalPrice;
}
public function setTotalPrice(?string $totalPrice): static
{
$this->totalPrice = $totalPrice;
$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->name ?? '';
}
/**
* Returns the module code this entity belongs to.
* Required by ModuleVoter for permission checks.
*/
public function getModuleName(): string
{
return 'project_tasks';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\EventListener;
use App\Entity\ProjectTask;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[AsEntityListener(event: Events::prePersist, entity: ProjectTask::class)]
class ProjectTaskSecurityListener
{
public function __construct(
private Security $security
) {
}
/**
* Check if user has permission to create project-independent tasks
*/
public function prePersist(ProjectTask $task, PrePersistEventArgs $event): void
{
// If task has no project, only admins can create it
if ($task->getProject() === null) {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException(
'Nur Administratoren können projektunabhängige Tätigkeiten erstellen.'
);
}
}
// If task has a project, check if user is project owner or team member
if ($task->getProject() !== null) {
$user = $this->security->getUser();
if (!$this->security->isGranted('ROLE_ADMIN')
&& !$task->getProject()->hasAccess($user)) {
throw new AccessDeniedException(
'Sie haben keine Berechtigung, Tätigkeiten für dieses Projekt zu erstellen.'
);
}
}
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyInfo\Type;
final class ProjectTaskProjectFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if ($property !== 'hasProject') {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
// Convert string 'true'/'false' to boolean
$hasProject = filter_var($value, FILTER_VALIDATE_BOOLEAN);
if ($hasProject) {
// Filter for tasks WITH project
$queryBuilder->andWhere(sprintf('%s.project IS NOT NULL', $alias));
} else {
// Filter for tasks WITHOUT project
$queryBuilder->andWhere(sprintf('%s.project IS NULL', $alias));
}
}
public function getDescription(string $resourceClass): array
{
return [
'hasProject' => [
'property' => 'hasProject',
'type' => Type::BUILTIN_TYPE_BOOL,
'required' => false,
'description' => 'Filter tasks by project existence (true = with project, false = without project)',
'openapi' => [
'example' => 'true',
'allowReserved' => false,
'allowEmptyValue' => true,
'explode' => false,
],
],
];
}
}

View File

@ -57,11 +57,15 @@ class ProjectRepository extends ServiceEntityRepository
/**
* Find all projects where user is owner or team member
* Optimized with eager loading to prevent N+1 queries
*/
public function findUserProjects(\App\Entity\User $user): array
{
return $this->createQueryBuilder('p')
->leftJoin('p.teamMembers', 'tm')
->leftJoin('p.customer', 'c')->addSelect('c')
->leftJoin('p.status', 's')->addSelect('s')
->leftJoin('p.owner', 'o')->addSelect('o')
->leftJoin('p.teamMembers', 'tm')->addSelect('tm')
->where('p.owner = :user')
->orWhere('tm = :user')
->setParameter('user', $user)

View File

@ -0,0 +1,94 @@
<?php
namespace App\Repository;
use App\Entity\ProjectTask;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProjectTask>
*/
class ProjectTaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProjectTask::class);
}
/**
* Find tasks by project
*/
public function findByProject(Project $project): array
{
return $this->createQueryBuilder('pt')
->andWhere('pt.project = :project')
->setParameter('project', $project)
->orderBy('pt.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find tasks without project (project-independent tasks)
*/
public function findWithoutProject(): array
{
return $this->createQueryBuilder('pt')
->andWhere('pt.project IS NULL')
->orderBy('pt.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find all tasks where user has access (via project membership)
*/
public function findUserTasks(User $user): array
{
return $this->createQueryBuilder('pt')
->leftJoin('pt.project', 'p')
->leftJoin('p.teamMembers', 'tm')
->where('pt.project IS NULL') // Project-independent tasks (visible to all)
->orWhere('p.owner = :user') // User is project owner
->orWhere('tm = :user') // User is team member
->setParameter('user', $user)
->orderBy('pt.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Calculate total budget for a project
*/
public function getTotalBudgetByProject(Project $project): float
{
$result = $this->createQueryBuilder('pt')
->select('SUM(pt.budget)')
->andWhere('pt.project = :project')
->andWhere('pt.budget IS NOT NULL')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult();
return (float) ($result ?? 0);
}
/**
* Calculate total hour contingent for a project
*/
public function getTotalHourContingentByProject(Project $project): float
{
$result = $this->createQueryBuilder('pt')
->select('SUM(pt.hourContingent)')
->andWhere('pt.project = :project')
->andWhere('pt.hourContingent IS NOT NULL')
->setParameter('project', $project)
->getQuery()
->getSingleScalarResult();
return (float) ($result ?? 0);
}
}

View File

@ -33,6 +33,25 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->getEntityManager()->flush();
}
/**
* Find user with all permissions eager loaded to avoid N+1 queries
* This is critical for the /api/permissions endpoint performance
*/
public function findWithPermissions(int $userId): ?User
{
return $this->createQueryBuilder('u')
->leftJoin('u.userRoles', 'r')
->addSelect('r')
->leftJoin('r.permissions', 'p')
->addSelect('p')
->leftJoin('p.module', 'm')
->addSelect('m')
->where('u.id = :userId')
->setParameter('userId', $userId)
->getQuery()
->getOneOrNullResult();
}
// /**
// * @return User[] Returns an array of User objects
// */

View File

@ -0,0 +1,104 @@
<?php
namespace App\Security\Voter;
use App\Entity\ProjectTask;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProjectTaskVoter extends Voter
{
public const VIEW = 'VIEW';
public const EDIT = 'EDIT';
public const DELETE = 'DELETE';
public const CREATE = 'CREATE';
protected function supports(string $attribute, mixed $subject): bool
{
// Support CREATE on class name string
if ($attribute === self::CREATE && $subject === 'project_tasks') {
return true;
}
// Support VIEW/EDIT/DELETE on ProjectTask instances
if (!in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])) {
return false;
}
return $subject instanceof ProjectTask;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// CREATE permission - any authenticated user can attempt to create
// (actual permission will be checked based on whether project is set)
if ($attribute === self::CREATE) {
return true;
}
/** @var ProjectTask $task */
$task = $subject;
return match ($attribute) {
self::VIEW => $this->canView($task, $user),
self::EDIT => $this->canEdit($task, $user),
self::DELETE => $this->canDelete($task, $user),
default => false,
};
}
private function canView(ProjectTask $task, User $user): bool
{
// Admins can view all tasks
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true;
}
// If task is not project-related, only admins can view (checked above)
if ($task->getProject() === null) {
return false;
}
// For project-related tasks: check if user has access to the project
return $task->getProject()->hasAccess($user);
}
private function canEdit(ProjectTask $task, User $user): bool
{
// Admins can edit all tasks
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true;
}
// If task is not project-related, only admins can edit (checked above)
if ($task->getProject() === null) {
return false;
}
// For project-related tasks: check if user has access to the project
return $task->getProject()->hasAccess($user);
}
private function canDelete(ProjectTask $task, User $user): bool
{
// Admins can delete all tasks
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true;
}
// If task is not project-related, only admins can delete (checked above)
if ($task->getProject() === null) {
return false;
}
// For project-related tasks: only project owner can delete
return $task->getProject()->getOwner() === $user;
}
}

View File

@ -10,7 +10,7 @@ class GitHubService
public function __construct(
private HttpClientInterface $httpClient,
private ?string $githubToken = null // Optional: aus .env laden für höhere Rate Limits
private ?string $githubToken = null
) {
}

View File

@ -8,7 +8,7 @@ class GiteaService
{
public function __construct(
private HttpClientInterface $httpClient,
private ?string $giteaToken = null // Optional: aus .env laden
private ?string $giteaToken = null
) {
}