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:
parent
5b030b84c9
commit
8a132d2fb9
268
.claude/ARCHITECTURE.md
Normal file
268
.claude/ARCHITECTURE.md
Normal 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
9
.env
@ -63,3 +63,12 @@ OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340
|
|||||||
OAUTH_POCKET_ID_CLIENT_SECRET=
|
OAUTH_POCKET_ID_CLIENT_SECRET=
|
||||||
OAUTH_POCKET_ID_REDIRECT_URI=https://mycrm.test/dashboard
|
OAUTH_POCKET_ID_REDIRECT_URI=https://mycrm.test/dashboard
|
||||||
###< knpuniversity/oauth2-client-bundle ###
|
###< 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
397
CLAUDE.md
Normal 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
620
SECURITY_ARCHITECTURE.md
Normal 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/
|
||||||
@ -93,6 +93,7 @@
|
|||||||
:showFilterOperator="column.showFilterOperator !== false"
|
:showFilterOperator="column.showFilterOperator !== false"
|
||||||
:frozen="freezeFirstColumn && index === 0"
|
:frozen="freezeFirstColumn && index === 0"
|
||||||
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
|
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
|
||||||
|
:filterField="column.filterField || column.field || column.key"
|
||||||
>
|
>
|
||||||
<!-- Custom Body Template (via Slot) -->
|
<!-- Custom Body Template (via Slot) -->
|
||||||
<template v-if="$slots[`body-${column.key}`]" #body="slotProps">
|
<template v-if="$slots[`body-${column.key}`]" #body="slotProps">
|
||||||
@ -319,7 +320,9 @@ const createLabel = computed(() => {
|
|||||||
if (props.entityName) {
|
if (props.entityName) {
|
||||||
// If custom article is provided, use it
|
// If custom article is provided, use it
|
||||||
if (props.entityNameArticle) {
|
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
|
// Fallback: Simple heuristic
|
||||||
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
|
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
|
// Initialize column-specific filters with operator structure for menu mode
|
||||||
props.columns.forEach(col => {
|
props.columns.forEach(col => {
|
||||||
if (col.filterable !== false && !internalFilters.value[col.key]) {
|
if (col.filterable !== false) {
|
||||||
internalFilters.value[col.key] = {
|
// Use filterField if specified, otherwise use field or key
|
||||||
operator: FilterOperator.AND,
|
const filterKey = col.filterField || col.field || col.key
|
||||||
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
|
|
||||||
|
if (!internalFilters.value[filterKey]) {
|
||||||
|
internalFilters.value[filterKey] = {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -14,7 +14,8 @@ const model = ref([
|
|||||||
label: 'CRM',
|
label: 'CRM',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
|
{ 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' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import Dashboard from './views/Dashboard.vue';
|
import Dashboard from './views/Dashboard.vue';
|
||||||
import ContactManagement from './views/ContactManagement.vue';
|
import ContactManagement from './views/ContactManagement.vue';
|
||||||
import ProjectManagement from './views/ProjectManagement.vue';
|
import ProjectManagement from './views/ProjectManagement.vue';
|
||||||
|
import ProjectTaskManagement from './views/ProjectTaskManagement.vue';
|
||||||
import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
||||||
import UserManagement from './views/UserManagement.vue';
|
import UserManagement from './views/UserManagement.vue';
|
||||||
import RoleManagement from './views/RoleManagement.vue';
|
import RoleManagement from './views/RoleManagement.vue';
|
||||||
@ -11,6 +12,7 @@ const routes = [
|
|||||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
||||||
{ path: '/projects', name: 'projects', component: ProjectManagement },
|
{ 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: '/project-statuses', name: 'project-statuses', component: ProjectStatusManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||||
|
|||||||
@ -1,78 +1,542 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h2>Dashboard</h2>
|
<!-- Header -->
|
||||||
<p>Willkommen im myCRM Dashboard!</p>
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
<div class="dashboard-grid">
|
<h1 class="text-3xl font-bold text-900 mb-1">Dashboard</h1>
|
||||||
<Card>
|
<p class="text-600">Willkommen zurück! Hier ist deine Übersicht.</p>
|
||||||
<template #title>Kontakte</template>
|
</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>
|
<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>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<!-- Projekte Card -->
|
||||||
<template #title>Unternehmen</template>
|
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/projects')">
|
||||||
<template #content>
|
<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>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<!-- Tätigkeiten Card -->
|
||||||
<template #title>Offene Deals</template>
|
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/project-tasks')">
|
||||||
<template #content>
|
<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>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<!-- Budget Card -->
|
||||||
<template #title>Umsatz (MTD)</template>
|
<Card class="hover:shadow-lg transition-shadow">
|
||||||
<template #content>
|
<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>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dashboard {
|
.dashboard {
|
||||||
h2 {
|
.line-clamp-2 {
|
||||||
margin-bottom: 1rem;
|
display: -webkit-box;
|
||||||
font-size: 1.5rem;
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
@media (min-width: 768px) {
|
overflow: hidden;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -620,7 +620,8 @@
|
|||||||
<Tabs value="0">
|
<Tabs value="0">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">Projektdaten</Tab>
|
<Tab value="0">Projektdaten</Tab>
|
||||||
<Tab value="1">Git Repositories</Tab>
|
<Tab value="1">Tätigkeiten</Tab>
|
||||||
|
<Tab value="2">Git Repositories</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- Project Data Tab -->
|
<!-- Project Data Tab -->
|
||||||
@ -771,8 +772,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Git Repository Tab -->
|
<!-- Tasks Tab -->
|
||||||
<TabPanel value="1">
|
<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">
|
<div v-if="gitRepositories.length === 0" class="text-center py-8 text-500">
|
||||||
<i class="pi pi-github text-6xl mb-3"></i>
|
<i class="pi pi-github text-6xl mb-3"></i>
|
||||||
<p>Keine Git-Repositories verknüpft</p>
|
<p>Keine Git-Repositories verknüpft</p>
|
||||||
@ -909,6 +989,28 @@ const deletingGitRepo = ref(false)
|
|||||||
const testingConnection = ref(false)
|
const testingConnection = ref(false)
|
||||||
const connectionTestResult = ref(null)
|
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 = [
|
const gitProviders = [
|
||||||
{ label: 'GitHub', value: 'github' },
|
{ label: 'GitHub', value: 'github' },
|
||||||
{ label: 'Gitea', value: 'gitea' },
|
{ label: 'Gitea', value: 'gitea' },
|
||||||
@ -1039,9 +1141,12 @@ function onDataLoaded(data) {
|
|||||||
async function viewProject(project) {
|
async function viewProject(project) {
|
||||||
viewingProject.value = { ...project }
|
viewingProject.value = { ...project }
|
||||||
viewDialog.value = true
|
viewDialog.value = true
|
||||||
|
|
||||||
// Load Git repositories for this project
|
// Load Git repositories and tasks for this project
|
||||||
await loadGitRepositories(project.id)
|
await Promise.all([
|
||||||
|
loadGitRepositories(project.id),
|
||||||
|
loadProjectTasks(project.id)
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGitRepositories(projectId) {
|
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() {
|
function editFromView() {
|
||||||
viewDialog.value = false
|
viewDialog.value = false
|
||||||
editProject(viewingProject.value)
|
editProject(viewingProject.value)
|
||||||
|
|||||||
627
assets/js/views/ProjectTaskManagement.vue
Normal file
627
assets/js/views/ProjectTaskManagement.vue
Normal 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>
|
||||||
31
config/packages/http_client.yaml
Normal file
31
config/packages/http_client.yaml
Normal 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'
|
||||||
@ -44,9 +44,11 @@ services:
|
|||||||
# GitHub Service with optional token
|
# GitHub Service with optional token
|
||||||
App\Service\GitHubService:
|
App\Service\GitHubService:
|
||||||
arguments:
|
arguments:
|
||||||
|
$httpClient: '@github.client'
|
||||||
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
|
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
|
||||||
|
|
||||||
# Gitea Service with optional token
|
# Gitea Service with optional token
|
||||||
App\Service\GiteaService:
|
App\Service\GiteaService:
|
||||||
arguments:
|
arguments:
|
||||||
|
$httpClient: '@gitea.client'
|
||||||
$giteaToken: '%env(string:default::GITEA_TOKEN)%'
|
$giteaToken: '%env(string:default::GITEA_TOKEN)%'
|
||||||
|
|||||||
180
docs/GIT_PERFORMANCE_OPTIMIZATION.md
Normal file
180
docs/GIT_PERFORMANCE_OPTIMIZATION.md
Normal 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
200
docs/INSTRUCTIONS.md
Normal 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)
|
||||||
651
docs/PROJECT_TASKS_MODULE.md
Normal file
651
docs/PROJECT_TASKS_MODULE.md
Normal 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**
|
||||||
37
migrations/Version20251114143227.php
Normal file
37
migrations/Version20251114143227.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@ -11,35 +12,82 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
class PermissionController extends AbstractController
|
class PermissionController extends AbstractController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
#[Route('/api/permissions', name: 'api_permissions', methods: ['GET'])]
|
#[Route('/api/permissions', name: 'api_permissions', methods: ['GET'])]
|
||||||
public function getPermissions(): JsonResponse
|
public function getPermissions(): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return new JsonResponse(['error' => 'Not authenticated'], 401);
|
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
|
// Liste aller Module die geprüft werden sollen
|
||||||
$modules = ['contacts', 'projects', 'project_statuses', 'documents', 'users', 'roles', 'settings'];
|
$modules = ['contacts', 'projects', 'project_statuses', 'documents', 'users', 'roles', 'settings'];
|
||||||
|
|
||||||
|
$permissions = [];
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
$permissions[$module] = [
|
$permissions[$module] = $permissionMap[$module] ?? [
|
||||||
'view' => $user->hasModulePermission($module, 'view'),
|
'view' => false,
|
||||||
'create' => $user->hasModulePermission($module, 'create'),
|
'create' => false,
|
||||||
'edit' => $user->hasModulePermission($module, 'edit'),
|
'edit' => false,
|
||||||
'delete' => $user->hasModulePermission($module, 'delete'),
|
'delete' => false,
|
||||||
'export' => $user->hasModulePermission($module, 'export'),
|
'export' => false,
|
||||||
'manage' => $user->hasModulePermission($module, 'manage'),
|
'manage' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'permissions' => $permissions,
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,11 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
|
|||||||
?Operation $operation = null,
|
?Operation $operation = null,
|
||||||
array $context = []
|
array $context = []
|
||||||
): void {
|
): void {
|
||||||
|
// This extension is disabled because we use a custom ProjectCollectionProvider
|
||||||
|
// that handles access filtering with optimized eager loading
|
||||||
|
return;
|
||||||
|
|
||||||
|
/*
|
||||||
if (Project::class !== $resourceClass) {
|
if (Project::class !== $resourceClass) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -40,5 +45,6 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
|
|||||||
->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias)
|
->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias)
|
||||||
->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias))
|
->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias))
|
||||||
->setParameter('current_user', $user);
|
->setParameter('current_user', $user);
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Put;
|
|||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use App\Entity\Interface\ModuleAwareInterface;
|
use App\Entity\Interface\ModuleAwareInterface;
|
||||||
use App\Repository\ProjectRepository;
|
use App\Repository\ProjectRepository;
|
||||||
|
use App\State\ProjectCollectionProvider;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
@ -27,7 +28,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
operations: [
|
operations: [
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
security: "is_granted('VIEW', 'projects')",
|
security: "is_granted('VIEW', 'projects')",
|
||||||
stateless: false
|
stateless: false,
|
||||||
|
provider: ProjectCollectionProvider::class
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('VIEW', object)",
|
security: "is_granted('VIEW', object)",
|
||||||
@ -61,11 +63,11 @@ class Project implements ModuleAwareInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['project:read'])]
|
#[Groups(['project:read', 'project_task:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[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\NotBlank(message: 'Der Projektname darf nicht leer sein')]
|
||||||
#[Assert\Length(max: 255)]
|
#[Assert\Length(max: 255)]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|||||||
237
src/Entity/ProjectTask.php
Normal file
237
src/Entity/ProjectTask.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/EventListener/ProjectTaskSecurityListener.php
Normal file
46
src/EventListener/ProjectTaskSecurityListener.php
Normal 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Filter/ProjectTaskProjectFilter.php
Normal file
57
src/Filter/ProjectTaskProjectFilter.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,11 +57,15 @@ class ProjectRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all projects where user is owner or team member
|
* 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
|
public function findUserProjects(\App\Entity\User $user): array
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('p')
|
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')
|
->where('p.owner = :user')
|
||||||
->orWhere('tm = :user')
|
->orWhere('tm = :user')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
|
|||||||
94
src/Repository/ProjectTaskRepository.php
Normal file
94
src/Repository/ProjectTaskRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,25 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
|||||||
$this->getEntityManager()->flush();
|
$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
|
// * @return User[] Returns an array of User objects
|
||||||
// */
|
// */
|
||||||
|
|||||||
104
src/Security/Voter/ProjectTaskVoter.php
Normal file
104
src/Security/Voter/ProjectTaskVoter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ class GitHubService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HttpClientInterface $httpClient,
|
private HttpClientInterface $httpClient,
|
||||||
private ?string $githubToken = null // Optional: aus .env laden für höhere Rate Limits
|
private ?string $githubToken = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ class GiteaService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HttpClientInterface $httpClient,
|
private HttpClientInterface $httpClient,
|
||||||
private ?string $giteaToken = null // Optional: aus .env laden
|
private ?string $giteaToken = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user