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_REDIRECT_URI=https://mycrm.test/dashboard
|
||||
###< knpuniversity/oauth2-client-bundle ###
|
||||
|
||||
###> app/git-services ###
|
||||
# Optional: GitHub Personal Access Token for higher rate limits (5000 req/hour instead of 60)
|
||||
# Create at: https://github.com/settings/tokens (needs 'public_repo' scope for public repos)
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Optional: Gitea Access Token for private instances
|
||||
GITEA_TOKEN=
|
||||
###< app/git-services ###
|
||||
|
||||
397
CLAUDE.md
Normal file
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"
|
||||
:frozen="freezeFirstColumn && index === 0"
|
||||
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
|
||||
:filterField="column.filterField || column.field || column.key"
|
||||
>
|
||||
<!-- Custom Body Template (via Slot) -->
|
||||
<template v-if="$slots[`body-${column.key}`]" #body="slotProps">
|
||||
@ -319,7 +320,9 @@ const createLabel = computed(() => {
|
||||
if (props.entityName) {
|
||||
// If custom article is provided, use it
|
||||
if (props.entityNameArticle) {
|
||||
return `Neu${props.entityNameArticle === 'ein' ? 'es' : 'er'} ${props.entityName}`
|
||||
const suffix = props.entityNameArticle === 'ein' ? 'es' :
|
||||
props.entityNameArticle === 'eine' ? 'e' : 'er'
|
||||
return `Neu${suffix} ${props.entityName}`
|
||||
}
|
||||
// Fallback: Simple heuristic
|
||||
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
|
||||
@ -768,10 +771,15 @@ onMounted(() => {
|
||||
|
||||
// Initialize column-specific filters with operator structure for menu mode
|
||||
props.columns.forEach(col => {
|
||||
if (col.filterable !== false && !internalFilters.value[col.key]) {
|
||||
internalFilters.value[col.key] = {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
|
||||
if (col.filterable !== false) {
|
||||
// Use filterField if specified, otherwise use field or key
|
||||
const filterKey = col.filterField || col.field || col.key
|
||||
|
||||
if (!internalFilters.value[filterKey]) {
|
||||
internalFilters.value[filterKey] = {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -14,7 +14,8 @@ const model = ref([
|
||||
label: 'CRM',
|
||||
items: [
|
||||
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
|
||||
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' }
|
||||
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
|
||||
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import ContactManagement from './views/ContactManagement.vue';
|
||||
import ProjectManagement from './views/ProjectManagement.vue';
|
||||
import ProjectTaskManagement from './views/ProjectTaskManagement.vue';
|
||||
import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
||||
import UserManagement from './views/UserManagement.vue';
|
||||
import RoleManagement from './views/RoleManagement.vue';
|
||||
@ -11,6 +12,7 @@ const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
||||
{ path: '/projects', name: 'projects', component: ProjectManagement },
|
||||
{ path: '/project-tasks', name: 'project-tasks', component: ProjectTaskManagement },
|
||||
{ path: '/project-statuses', name: 'project-statuses', component: ProjectStatusManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||
|
||||
@ -1,78 +1,542 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h2>Dashboard</h2>
|
||||
<p>Willkommen im myCRM Dashboard!</p>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<Card>
|
||||
<template #title>Kontakte</template>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-900 mb-1">Dashboard</h1>
|
||||
<p class="text-600">Willkommen zurück! Hier ist deine Übersicht.</p>
|
||||
</div>
|
||||
<div class="text-sm text-500">
|
||||
{{ currentDate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Kontakte Card -->
|
||||
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/contacts')">
|
||||
<template #content>
|
||||
<p>Gesamt: <strong>0</strong></p>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="text-500 text-sm mb-2">Kontakte</div>
|
||||
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalContacts }}</div>
|
||||
<div class="text-sm text-green-600 flex items-center gap-1">
|
||||
<i class="pi pi-arrow-up text-xs"></i>
|
||||
<span>Aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-users text-2xl text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>Unternehmen</template>
|
||||
|
||||
<!-- Projekte Card -->
|
||||
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/projects')">
|
||||
<template #content>
|
||||
<p>Gesamt: <strong>0</strong></p>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="text-500 text-sm mb-2">Projekte</div>
|
||||
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalProjects }}</div>
|
||||
<div class="text-sm text-600 flex items-center gap-1">
|
||||
<span>{{ stats.activeProjects }} aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-briefcase text-2xl text-purple-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>Offene Deals</template>
|
||||
|
||||
<!-- Tätigkeiten Card -->
|
||||
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/project-tasks')">
|
||||
<template #content>
|
||||
<p>Gesamt: <strong>0</strong></p>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="text-500 text-sm mb-2">Tätigkeiten</div>
|
||||
<div class="text-3xl font-bold text-900 mb-1">{{ stats.totalTasks }}</div>
|
||||
<div class="text-sm text-600 flex items-center gap-1">
|
||||
<span>{{ stats.tasksWithBudget }} mit Budget</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-list-check text-2xl text-green-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>Umsatz (MTD)</template>
|
||||
|
||||
<!-- Budget Card -->
|
||||
<Card class="hover:shadow-lg transition-shadow">
|
||||
<template #content>
|
||||
<p><strong>0 €</strong></p>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="text-500 text-sm mb-2">Gesamt-Budget</div>
|
||||
<div class="text-3xl font-bold text-900 mb-1">{{ formatCurrency(stats.totalBudget) }}</div>
|
||||
<div class="text-sm text-600 flex items-center gap-1">
|
||||
<span>{{ formatCurrency(stats.totalTaskBudget) }} Tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900/20 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-euro text-2xl text-orange-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<Card class="mb-6">
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-900">Schnellzugriff</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Button
|
||||
label="Neuer Kontakt"
|
||||
icon="pi pi-user-plus"
|
||||
outlined
|
||||
@click="$router.push('/contacts')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button
|
||||
label="Neues Projekt"
|
||||
icon="pi pi-plus-circle"
|
||||
outlined
|
||||
@click="$router.push('/projects')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button
|
||||
label="Neue Tätigkeit"
|
||||
icon="pi pi-plus"
|
||||
outlined
|
||||
@click="$router.push('/project-tasks')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button
|
||||
label="Alle Projekte"
|
||||
icon="pi pi-th-large"
|
||||
outlined
|
||||
@click="$router.push('/projects')"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Active Projects -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-900">Aktive Projekte</h3>
|
||||
<Button
|
||||
label="Alle anzeigen"
|
||||
text
|
||||
size="small"
|
||||
@click="$router.push('/projects')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading.projects" class="flex justify-center py-8">
|
||||
<ProgressSpinner style="width: 50px; height: 50px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeProjects.length === 0" class="text-center py-8 text-500">
|
||||
<i class="pi pi-briefcase text-4xl mb-3"></i>
|
||||
<p>Keine aktiven Projekte</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="project in activeProjects"
|
||||
:key="project.id"
|
||||
class="p-4 border-round border-1 surface-border hover:surface-hover transition-colors cursor-pointer"
|
||||
@click="$router.push('/projects')"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-semibold text-900 mb-1">{{ project.name }}</div>
|
||||
<div class="text-sm text-500">{{ project.customer?.companyName || 'Kein Kunde' }}</div>
|
||||
</div>
|
||||
<Tag
|
||||
v-if="project.status"
|
||||
:value="project.status.name"
|
||||
:style="{ backgroundColor: project.status.color }"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div v-if="project.budget">
|
||||
<div class="text-500 text-xs">Budget</div>
|
||||
<div class="font-semibold">{{ formatCurrency(project.budget) }}</div>
|
||||
</div>
|
||||
<div v-if="project.hourContingent">
|
||||
<div class="text-500 text-xs">Stunden</div>
|
||||
<div class="font-semibold">{{ project.hourContingent }} h</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="project.endDate" class="mt-3 text-xs text-500">
|
||||
<i class="pi pi-calendar mr-1"></i>
|
||||
Enddatum: {{ formatDate(project.endDate) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Recent Tasks -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-900">Neueste Tätigkeiten</h3>
|
||||
<Button
|
||||
label="Alle anzeigen"
|
||||
text
|
||||
size="small"
|
||||
@click="$router.push('/project-tasks')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading.tasks" class="flex justify-center py-8">
|
||||
<ProgressSpinner style="width: 50px; height: 50px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentTasks.length === 0" class="text-center py-8 text-500">
|
||||
<i class="pi pi-list-check text-4xl mb-3"></i>
|
||||
<p>Keine Tätigkeiten vorhanden</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="task in recentTasks"
|
||||
:key="task.id"
|
||||
class="p-4 border-round border-1 surface-border hover:surface-hover transition-colors cursor-pointer"
|
||||
@click="$router.push('/project-tasks')"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="font-semibold text-900">{{ task.name }}</div>
|
||||
<Tag
|
||||
v-if="task.project"
|
||||
:value="task.project.name"
|
||||
severity="info"
|
||||
class="text-xs"
|
||||
/>
|
||||
<Tag
|
||||
v-else
|
||||
value="Projektunabhängig"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="task.description" class="text-sm text-600 mb-3 line-clamp-2">
|
||||
{{ task.description }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div v-if="task.hourlyRate">
|
||||
<div class="text-500 text-xs">Stundensatz</div>
|
||||
<div class="font-semibold">{{ formatCurrency(task.hourlyRate) }}/h</div>
|
||||
</div>
|
||||
<div v-if="task.totalPrice">
|
||||
<div class="text-500 text-xs">Gesamtpreis</div>
|
||||
<div class="font-semibold text-green-600">{{ formatCurrency(task.totalPrice) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.hourContingent" class="mt-2 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
:value="100"
|
||||
:show-value="false"
|
||||
style="height: 0.5rem"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="text-xs text-500">{{ task.hourContingent }} h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Budget Overview -->
|
||||
<Card class="mt-6">
|
||||
<template #content>
|
||||
<h3 class="text-xl font-semibold text-900 mb-4">Budget-Übersicht</h3>
|
||||
|
||||
<div v-if="loading.budgetStats" class="flex justify-center py-8">
|
||||
<ProgressSpinner style="width: 50px; height: 50px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Projects Budget -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-600">Projekte Budget</span>
|
||||
<span class="text-sm font-bold text-900">{{ formatCurrency(budgetStats.projectsBudget) }}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="budgetStats.projectsBudgetPercentage"
|
||||
:show-value="false"
|
||||
class="mb-1"
|
||||
:pt="{ value: { class: 'bg-blue-500' } }"
|
||||
/>
|
||||
<div class="text-xs text-500">{{ budgetStats.projectsWithBudget }} von {{ stats.totalProjects }} Projekten</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Budget -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-600">Tätigkeiten Budget</span>
|
||||
<span class="text-sm font-bold text-900">{{ formatCurrency(budgetStats.tasksBudget) }}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="budgetStats.tasksBudgetPercentage"
|
||||
:show-value="false"
|
||||
class="mb-1"
|
||||
:pt="{ value: { class: 'bg-green-500' } }"
|
||||
/>
|
||||
<div class="text-xs text-500">{{ budgetStats.tasksWithBudget }} von {{ stats.totalTasks }} Tätigkeiten</div>
|
||||
</div>
|
||||
|
||||
<!-- Hours Contingent -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-600">Stundenkontingent</span>
|
||||
<span class="text-sm font-bold text-900">{{ budgetStats.totalHours }} h</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:value="100"
|
||||
:show-value="false"
|
||||
class="mb-1"
|
||||
:pt="{ value: { class: 'bg-purple-500' } }"
|
||||
/>
|
||||
<div class="text-xs text-500">{{ budgetStats.projectHours }} h Projekte, {{ budgetStats.taskHours }} h Tätigkeiten</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Card from 'primevue/card';
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
totalContacts: 0,
|
||||
totalProjects: 0,
|
||||
activeProjects: 0,
|
||||
totalTasks: 0,
|
||||
tasksWithBudget: 0,
|
||||
totalBudget: 0,
|
||||
totalTaskBudget: 0
|
||||
})
|
||||
|
||||
const activeProjects = ref([])
|
||||
const recentTasks = ref([])
|
||||
const budgetStats = ref({
|
||||
projectsBudget: 0,
|
||||
projectsBudgetPercentage: 0,
|
||||
projectsWithBudget: 0,
|
||||
tasksBudget: 0,
|
||||
tasksBudgetPercentage: 0,
|
||||
tasksWithBudget: 0,
|
||||
totalHours: 0,
|
||||
projectHours: 0,
|
||||
taskHours: 0
|
||||
})
|
||||
|
||||
const loading = ref({
|
||||
projects: true,
|
||||
tasks: true,
|
||||
budgetStats: true
|
||||
})
|
||||
|
||||
// Computed
|
||||
const currentDate = computed(() => {
|
||||
const now = new Date()
|
||||
return now.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
function formatCurrency(value) {
|
||||
if (!value && value !== 0) return '0 €'
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
await Promise.all([
|
||||
loadContacts(),
|
||||
loadProjects(),
|
||||
loadTasks()
|
||||
])
|
||||
|
||||
calculateBudgetStats()
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
try {
|
||||
const response = await fetch('/api/contacts?pagination=false')
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Kontakte')
|
||||
|
||||
const data = await response.json()
|
||||
const contacts = data['hydra:member'] || data.member || data || []
|
||||
stats.value.totalContacts = contacts.length
|
||||
} catch (error) {
|
||||
console.error('Error loading contacts:', error)
|
||||
stats.value.totalContacts = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value.projects = true
|
||||
try {
|
||||
const response = await fetch('/api/projects?pagination=false')
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Projekte')
|
||||
|
||||
const data = await response.json()
|
||||
const projects = data['hydra:member'] || data.member || data || []
|
||||
|
||||
stats.value.totalProjects = projects.length
|
||||
|
||||
// Filter active projects (no end date or end date in future)
|
||||
const now = new Date()
|
||||
const active = projects.filter(p => {
|
||||
if (!p.endDate) return true
|
||||
const endDate = new Date(p.endDate)
|
||||
return endDate >= now
|
||||
})
|
||||
|
||||
stats.value.activeProjects = active.length
|
||||
activeProjects.value = active.slice(0, 5) // Top 5
|
||||
|
||||
// Calculate total budget
|
||||
stats.value.totalBudget = projects.reduce((sum, p) => {
|
||||
return sum + (p.budget ? parseFloat(p.budget) : 0)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error)
|
||||
stats.value.totalProjects = 0
|
||||
stats.value.activeProjects = 0
|
||||
activeProjects.value = []
|
||||
} finally {
|
||||
loading.value.projects = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value.tasks = true
|
||||
try {
|
||||
const response = await fetch('/api/project_tasks?pagination=false')
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
|
||||
|
||||
const data = await response.json()
|
||||
const tasks = data['hydra:member'] || data.member || data || []
|
||||
|
||||
stats.value.totalTasks = tasks.length
|
||||
stats.value.tasksWithBudget = tasks.filter(t => t.budget).length
|
||||
|
||||
// Calculate total task budget
|
||||
stats.value.totalTaskBudget = tasks.reduce((sum, t) => {
|
||||
return sum + (t.budget ? parseFloat(t.budget) : 0)
|
||||
}, 0)
|
||||
|
||||
// Get recent tasks (last 5)
|
||||
recentTasks.value = tasks.slice(0, 5)
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error)
|
||||
stats.value.totalTasks = 0
|
||||
stats.value.tasksWithBudget = 0
|
||||
recentTasks.value = []
|
||||
} finally {
|
||||
loading.value.tasks = false
|
||||
}
|
||||
}
|
||||
|
||||
function calculateBudgetStats() {
|
||||
loading.value.budgetStats = true
|
||||
|
||||
try {
|
||||
// Projects budget stats
|
||||
const projectsWithBudget = activeProjects.value.filter(p => p.budget).length
|
||||
budgetStats.value.projectsWithBudget = projectsWithBudget
|
||||
budgetStats.value.projectsBudget = activeProjects.value.reduce((sum, p) => {
|
||||
return sum + (p.budget ? parseFloat(p.budget) : 0)
|
||||
}, 0)
|
||||
budgetStats.value.projectsBudgetPercentage = stats.value.totalProjects > 0
|
||||
? (projectsWithBudget / stats.value.totalProjects) * 100
|
||||
: 0
|
||||
|
||||
// Tasks budget stats
|
||||
const tasksWithBudget = recentTasks.value.filter(t => t.budget).length
|
||||
budgetStats.value.tasksWithBudget = tasksWithBudget
|
||||
budgetStats.value.tasksBudget = recentTasks.value.reduce((sum, t) => {
|
||||
return sum + (t.budget ? parseFloat(t.budget) : 0)
|
||||
}, 0)
|
||||
budgetStats.value.tasksBudgetPercentage = stats.value.totalTasks > 0
|
||||
? (stats.value.tasksWithBudget / stats.value.totalTasks) * 100
|
||||
: 0
|
||||
|
||||
// Hours stats
|
||||
budgetStats.value.projectHours = activeProjects.value.reduce((sum, p) => {
|
||||
return sum + (p.hourContingent ? parseFloat(p.hourContingent) : 0)
|
||||
}, 0)
|
||||
// Only count hours from tasks that are NOT assigned to a project
|
||||
budgetStats.value.taskHours = recentTasks.value
|
||||
.filter(t => !t.project)
|
||||
.reduce((sum, t) => {
|
||||
return sum + (t.hourContingent ? parseFloat(t.hourContingent) : 0)
|
||||
}, 0)
|
||||
budgetStats.value.totalHours = budgetStats.value.projectHours + budgetStats.value.taskHours
|
||||
} catch (error) {
|
||||
console.error('Error calculating budget stats:', error)
|
||||
} finally {
|
||||
loading.value.budgetStats = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard {
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -620,7 +620,8 @@
|
||||
<Tabs value="0">
|
||||
<TabList>
|
||||
<Tab value="0">Projektdaten</Tab>
|
||||
<Tab value="1">Git Repositories</Tab>
|
||||
<Tab value="1">Tätigkeiten</Tab>
|
||||
<Tab value="2">Git Repositories</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Project Data Tab -->
|
||||
@ -771,8 +772,87 @@
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Git Repository Tab -->
|
||||
<!-- Tasks Tab -->
|
||||
<TabPanel value="1">
|
||||
<div v-if="projectTasks.length === 0" class="text-center py-8 text-500">
|
||||
<i class="pi pi-briefcase text-6xl mb-3"></i>
|
||||
<p>Keine Tätigkeiten vorhanden</p>
|
||||
<p class="text-sm">Füge Tätigkeiten hinzu, um Budget und Stunden zu tracken.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Tasks Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div class="p-4 border-round border-1 surface-border bg-blue-50 dark:bg-blue-900/20">
|
||||
<div class="text-sm text-500 mb-1">Gesamt-Budget</div>
|
||||
<div class="text-2xl font-semibold text-blue-600">{{ formatCurrency(tasksSummary.totalBudget) }}</div>
|
||||
</div>
|
||||
<div class="p-4 border-round border-1 surface-border bg-green-50 dark:bg-green-900/20">
|
||||
<div class="text-sm text-500 mb-1">Gesamt-Stunden</div>
|
||||
<div class="text-2xl font-semibold text-green-600">{{ tasksSummary.totalHours }} h</div>
|
||||
</div>
|
||||
<div class="p-4 border-round border-1 surface-border bg-purple-50 dark:bg-purple-900/20">
|
||||
<div class="text-sm text-500 mb-1">Gesamt-Preis</div>
|
||||
<div class="text-2xl font-semibold text-purple-600">{{ formatCurrency(tasksSummary.totalPrice) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Table -->
|
||||
<DataTable
|
||||
:value="projectTasks"
|
||||
striped-rows
|
||||
paginator
|
||||
:rows="10"
|
||||
>
|
||||
<Column field="name" header="Name" style="width: 30%">
|
||||
<template #body="{ data }">
|
||||
<div class="font-medium">{{ data.name }}</div>
|
||||
<div v-if="data.description" class="text-sm text-500 mt-1">{{ data.description }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="budget" header="Budget" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.budget">{{ formatCurrency(data.budget) }}</div>
|
||||
<span v-else class="text-500">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="hourContingent" header="Stunden" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.hourContingent">{{ data.hourContingent }} h</div>
|
||||
<span v-else class="text-500">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="hourlyRate" header="Stundensatz" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.hourlyRate">{{ formatCurrency(data.hourlyRate) }}/h</div>
|
||||
<span v-else class="text-500">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="totalPrice" header="Gesamtpreis" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.totalPrice" class="font-semibold">{{ formatCurrency(data.totalPrice) }}</div>
|
||||
<span v-else class="text-500">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Abrechnungsart" style="width: 10%">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.hourlyRate && data.totalPrice" value="Beide" severity="info" size="small" />
|
||||
<Tag v-else-if="data.hourlyRate" value="Stundensatz" severity="success" size="small" />
|
||||
<Tag v-else-if="data.totalPrice" value="Festpreis" severity="warning" size="small" />
|
||||
<span v-else class="text-500">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Git Repository Tab -->
|
||||
<TabPanel value="2">
|
||||
<div v-if="gitRepositories.length === 0" class="text-center py-8 text-500">
|
||||
<i class="pi pi-github text-6xl mb-3"></i>
|
||||
<p>Keine Git-Repositories verknüpft</p>
|
||||
@ -909,6 +989,28 @@ const deletingGitRepo = ref(false)
|
||||
const testingConnection = ref(false)
|
||||
const connectionTestResult = ref(null)
|
||||
|
||||
// Project Tasks
|
||||
const projectTasks = ref([])
|
||||
const tasksSummary = computed(() => {
|
||||
const totalBudget = projectTasks.value.reduce((sum, task) => {
|
||||
return sum + (task.budget ? parseFloat(task.budget) : 0)
|
||||
}, 0)
|
||||
|
||||
const totalHours = projectTasks.value.reduce((sum, task) => {
|
||||
return sum + (task.hourContingent ? parseFloat(task.hourContingent) : 0)
|
||||
}, 0)
|
||||
|
||||
const totalPrice = projectTasks.value.reduce((sum, task) => {
|
||||
return sum + (task.totalPrice ? parseFloat(task.totalPrice) : 0)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
totalBudget,
|
||||
totalHours,
|
||||
totalPrice
|
||||
}
|
||||
})
|
||||
|
||||
const gitProviders = [
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'Gitea', value: 'gitea' },
|
||||
@ -1039,9 +1141,12 @@ function onDataLoaded(data) {
|
||||
async function viewProject(project) {
|
||||
viewingProject.value = { ...project }
|
||||
viewDialog.value = true
|
||||
|
||||
// Load Git repositories for this project
|
||||
await loadGitRepositories(project.id)
|
||||
|
||||
// Load Git repositories and tasks for this project
|
||||
await Promise.all([
|
||||
loadGitRepositories(project.id),
|
||||
loadProjectTasks(project.id)
|
||||
])
|
||||
}
|
||||
|
||||
async function loadGitRepositories(projectId) {
|
||||
@ -1067,6 +1172,25 @@ async function loadGitRepositories(projectId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectTasks(projectId) {
|
||||
try {
|
||||
const response = await fetch(`/api/project_tasks?project=${projectId}`)
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
|
||||
|
||||
const data = await response.json()
|
||||
projectTasks.value = data['hydra:member'] || data.member || data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading project tasks:', error)
|
||||
projectTasks.value = []
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Fehler',
|
||||
detail: 'Tätigkeiten konnten nicht geladen werden',
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function editFromView() {
|
||||
viewDialog.value = false
|
||||
editProject(viewingProject.value)
|
||||
|
||||
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
|
||||
App\Service\GitHubService:
|
||||
arguments:
|
||||
$httpClient: '@github.client'
|
||||
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
|
||||
|
||||
# Gitea Service with optional token
|
||||
App\Service\GiteaService:
|
||||
arguments:
|
||||
$httpClient: '@gitea.client'
|
||||
$giteaToken: '%env(string:default::GITEA_TOKEN)%'
|
||||
|
||||
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;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@ -11,35 +12,82 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class PermissionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository
|
||||
) {}
|
||||
|
||||
#[Route('/api/permissions', name: 'api_permissions', methods: ['GET'])]
|
||||
public function getPermissions(): JsonResponse
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
|
||||
if (!$user) {
|
||||
return new JsonResponse(['error' => 'Not authenticated'], 401);
|
||||
}
|
||||
|
||||
$permissions = [];
|
||||
|
||||
// Eager load user with all permissions to avoid N+1 queries
|
||||
$userWithPermissions = $this->userRepository->findWithPermissions($user->getId());
|
||||
|
||||
if (!$userWithPermissions) {
|
||||
return new JsonResponse(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
// Build permission map once instead of calling hasModulePermission 42 times
|
||||
$permissionMap = $this->buildPermissionMap($userWithPermissions);
|
||||
|
||||
// Liste aller Module die geprüft werden sollen
|
||||
$modules = ['contacts', 'projects', 'project_statuses', 'documents', 'users', 'roles', 'settings'];
|
||||
|
||||
|
||||
$permissions = [];
|
||||
foreach ($modules as $module) {
|
||||
$permissions[$module] = [
|
||||
'view' => $user->hasModulePermission($module, 'view'),
|
||||
'create' => $user->hasModulePermission($module, 'create'),
|
||||
'edit' => $user->hasModulePermission($module, 'edit'),
|
||||
'delete' => $user->hasModulePermission($module, 'delete'),
|
||||
'export' => $user->hasModulePermission($module, 'export'),
|
||||
'manage' => $user->hasModulePermission($module, 'manage'),
|
||||
$permissions[$module] = $permissionMap[$module] ?? [
|
||||
'view' => false,
|
||||
'create' => false,
|
||||
'edit' => false,
|
||||
'delete' => false,
|
||||
'export' => false,
|
||||
'manage' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'permissions' => $permissions,
|
||||
'isAdmin' => in_array('ROLE_ADMIN', $user->getRoles())
|
||||
'isAdmin' => in_array('ROLE_ADMIN', $userWithPermissions->getRoles())
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildPermissionMap(User $user): array
|
||||
{
|
||||
$permissionMap = [];
|
||||
|
||||
// Iterate once through all roles and permissions
|
||||
foreach ($user->getUserRoles() as $role) {
|
||||
foreach ($role->getPermissions() as $permission) {
|
||||
$moduleCode = $permission->getModule()->getCode();
|
||||
|
||||
// Initialize module permissions if not exists
|
||||
if (!isset($permissionMap[$moduleCode])) {
|
||||
$permissionMap[$moduleCode] = [
|
||||
'view' => false,
|
||||
'create' => false,
|
||||
'edit' => false,
|
||||
'delete' => false,
|
||||
'export' => false,
|
||||
'manage' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Merge permissions (OR logic - if any role has permission, user has it)
|
||||
$permissionMap[$moduleCode]['view'] = $permissionMap[$moduleCode]['view'] || $permission->canView();
|
||||
$permissionMap[$moduleCode]['create'] = $permissionMap[$moduleCode]['create'] || $permission->canCreate();
|
||||
$permissionMap[$moduleCode]['edit'] = $permissionMap[$moduleCode]['edit'] || $permission->canEdit();
|
||||
$permissionMap[$moduleCode]['delete'] = $permissionMap[$moduleCode]['delete'] || $permission->canDelete();
|
||||
$permissionMap[$moduleCode]['export'] = $permissionMap[$moduleCode]['export'] || $permission->canExport();
|
||||
$permissionMap[$moduleCode]['manage'] = $permissionMap[$moduleCode]['manage'] || $permission->canManage();
|
||||
}
|
||||
}
|
||||
|
||||
return $permissionMap;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,11 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
|
||||
?Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
// This extension is disabled because we use a custom ProjectCollectionProvider
|
||||
// that handles access filtering with optimized eager loading
|
||||
return;
|
||||
|
||||
/*
|
||||
if (Project::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
@ -40,5 +45,6 @@ final class ProjectAccessExtension implements QueryCollectionExtensionInterface
|
||||
->leftJoin(sprintf('%s.teamMembers', $rootAlias), $teamMemberAlias)
|
||||
->andWhere(sprintf('%s.owner = :current_user OR %s = :current_user', $rootAlias, $teamMemberAlias))
|
||||
->setParameter('current_user', $user);
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Put;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use App\Entity\Interface\ModuleAwareInterface;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\State\ProjectCollectionProvider;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@ -27,7 +28,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('VIEW', 'projects')",
|
||||
stateless: false
|
||||
stateless: false,
|
||||
provider: ProjectCollectionProvider::class
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('VIEW', object)",
|
||||
@ -61,11 +63,11 @@ class Project implements ModuleAwareInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['project:read'])]
|
||||
#[Groups(['project:read', 'project_task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
#[Groups(['project:read', 'project:write', 'project_task:read'])]
|
||||
#[Assert\NotBlank(message: 'Der Projektname darf nicht leer sein')]
|
||||
#[Assert\Length(max: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
237
src/Entity/ProjectTask.php
Normal file
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
|
||||
* Optimized with eager loading to prevent N+1 queries
|
||||
*/
|
||||
public function findUserProjects(\App\Entity\User $user): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->leftJoin('p.teamMembers', 'tm')
|
||||
->leftJoin('p.customer', 'c')->addSelect('c')
|
||||
->leftJoin('p.status', 's')->addSelect('s')
|
||||
->leftJoin('p.owner', 'o')->addSelect('o')
|
||||
->leftJoin('p.teamMembers', 'tm')->addSelect('tm')
|
||||
->where('p.owner = :user')
|
||||
->orWhere('tm = :user')
|
||||
->setParameter('user', $user)
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// */
|
||||
|
||||
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(
|
||||
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(
|
||||
private HttpClientInterface $httpClient,
|
||||
private ?string $giteaToken = null // Optional: aus .env laden
|
||||
private ?string $giteaToken = null
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user