myCRM/assets/js/views/Dashboard.vue
olli 8a132d2fb9 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.
2025-11-14 17:12:40 +01:00

543 lines
18 KiB
Vue

<template>
<div class="dashboard">
<!-- 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>
<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>
<!-- Projekte Card -->
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/projects')">
<template #content>
<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>
<!-- Tätigkeiten Card -->
<Card class="hover:shadow-lg transition-shadow cursor-pointer" @click="$router.push('/project-tasks')">
<template #content>
<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>
<!-- Budget Card -->
<Card class="hover:shadow-lg transition-shadow">
<template #content>
<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 { 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 {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>