- 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.
543 lines
18 KiB
Vue
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>
|