myCRM/assets/js/views/ProjectManagement.vue
olli 1e02439e8a feat: Implement project ownership and team member management with access control
- Added owner and team members relationships to the Project entity.
- Updated ProjectRepository to find projects based on user ownership or team membership.
- Enhanced ProjectVoter to manage view, edit, and delete permissions based on ownership and team membership.
- Created ProjectAccessExtension to filter projects based on user access.
- Updated ProjectManagement.vue to include owner and team member selection in the UI.
- Implemented API endpoints for managing Git repositories with proper access control.
- Added migration to update the database schema for project ownership and team members.
2025-11-14 10:49:54 +01:00

1491 lines
50 KiB
Vue

<template>
<div class="project-management">
<CrudDataTable
ref="tableRef"
title="Projekte"
entity-name="Projekt"
entity-name-article="ein"
:columns="projectColumns"
data-source="/api/projects"
storage-key="projectTableColumns"
:show-view-button="canView"
:show-edit-button="canEdit"
:show-delete-button="canDelete"
:show-export-button="canExport"
@view="viewProject"
@create="openNewProjectDialog"
@edit="editProject"
@delete="confirmDelete"
@data-loaded="onDataLoaded"
>
<!-- Custom Filter Buttons -->
<template #filter-buttons="{ loadData }">
<div class="flex gap-2 mb-4">
<Button
label="Alle"
:outlined="typeFilter !== 'all'"
@click="filterByType('all', loadData)"
size="small"
/>
<Button
label="Beruflich"
:outlined="typeFilter !== 'business'"
@click="filterByType('business', loadData)"
size="small"
/>
<Button
label="Privat"
:outlined="typeFilter !== 'private'"
@click="filterByType('private', loadData)"
size="small"
/>
</div>
</template>
<!-- Custom Column Templates -->
<template #body-name="{ data }">
<div class="font-semibold">{{ data.name }}</div>
</template>
<template #body-projectNumber="{ data }">
{{ data.projectNumber }}
</template>
<template #body-customer="{ data }">
<div v-if="data.customer">{{ data.customer.companyName }}</div>
<span v-else class="text-500">Kein Kunde zugewiesen</span>
</template>
<template #body-status="{ data }">
<Tag v-if="data.status" :value="data.status.name" :style="{ backgroundColor: data.status.color }" />
<span v-else class="text-500">Kein Status</span>
</template>
<template #body-orderNumber="{ data }">
{{ data.orderNumber }}
</template>
<template #body-orderDate="{ data }">
{{ formatDate(data.orderDate) }}
</template>
<template #body-startDate="{ data }">
{{ formatDate(data.startDate) }}
</template>
<template #body-endDate="{ data }">
{{ formatDate(data.endDate) }}
</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-type="{ data }">
<Tag :value="data.isPrivate ? 'Privat' : 'Beruflich'" :severity="data.isPrivate ? 'info' : 'success'" />
</template>
<template #body-createdAt="{ data }">
{{ formatDate(data.createdAt) }}
</template>
<template #body-updatedAt="{ data }">
{{ formatDate(data.updatedAt) }}
</template>
</CrudDataTable>
<!-- Project Dialog -->
<Dialog
v-model:visible="projectDialog"
:header="editingProject?.id ? 'Projekt bearbeiten' : 'Neues Projekt'"
:modal="true"
:style="{ width: '900px' }"
: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">Projektname *</label>
<InputText
id="name"
v-model="editingProject.name"
:class="{ 'p-invalid': submitted && !editingProject.name }"
:disabled="saving"
/>
<small v-if="submitted && !editingProject.name" class="p-error">Projektname ist erforderlich</small>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label for="description">Beschreibung</label>
<Textarea
id="description"
v-model="editingProject.description"
rows="3"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="customer">Kunde</label>
<Select
id="customer"
v-model="editingProject.customer"
:options="customers"
option-label="companyName"
placeholder="Kunde auswählen"
filter
:disabled="saving"
show-clear
/>
</div>
<div class="flex flex-col gap-2">
<label for="status">Status</label>
<Select
id="status"
v-model="editingProject.status"
:options="statuses"
option-label="name"
placeholder="Status auswählen"
:disabled="saving"
show-clear
>
<template #option="slotProps">
<div class="flex align-items-center gap-2">
<span
class="inline-block w-3 h-3 border-round"
:style="{ backgroundColor: slotProps.option.color }"
></span>
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
</div>
<div class="flex flex-col gap-2">
<label for="isPrivate">Typ</label>
<div class="flex align-items-center gap-3 mt-2">
<div class="flex align-items-center">
<RadioButton
id="business"
v-model="editingProject.isPrivate"
:value="false"
:disabled="saving"
/>
<label for="business" class="ml-2">Beruflich</label>
</div>
<div class="flex align-items-center">
<RadioButton
id="private"
v-model="editingProject.isPrivate"
:value="true"
:disabled="saving"
/>
<label for="private" class="ml-2">Privat</label>
</div>
</div>
</div>
</div>
<!-- Project Numbers & Dates -->
<div class="font-semibold text-lg mt-4">Nummern & Daten</div>
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label for="projectNumber">Projektnummer</label>
<InputText
id="projectNumber"
v-model="editingProject.projectNumber"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="orderNumber">Bestellnummer</label>
<InputText
id="orderNumber"
v-model="editingProject.orderNumber"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="orderDate">Bestelldatum</label>
<DatePicker
id="orderDate"
v-model="editingProject.orderDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label for="startDate">Startdatum</label>
<DatePicker
id="startDate"
v-model="editingProject.startDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label for="endDate">Enddatum</label>
<DatePicker
id="endDate"
v-model="editingProject.endDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
</div>
<!-- Budget & Hours -->
<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="editingProject.budget"
mode="currency"
currency="EUR"
locale="de-DE"
:min="0"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="hourContingent">Stundenkontingent</label>
<InputNumber
id="hourContingent"
v-model="editingProject.hourContingent"
suffix=" h"
:min-fraction-digits="0"
:max-fraction-digits="2"
:min="0"
:disabled="saving"
/>
</div>
</div>
<!-- Team & Access Management -->
<div class="font-semibold text-lg mt-4">Team & Zugriff</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="owner">Eigentümer *</label>
<Select
id="owner"
v-model="editingProject.owner"
:options="users"
option-label="email"
placeholder="Eigentümer auswählen"
filter
:disabled="saving"
:class="{ 'p-invalid': submitted && !editingProject.owner }"
>
<template #option="slotProps">
<div class="flex flex-col">
<span class="font-medium">{{ slotProps.option.email }}</span>
<span v-if="slotProps.option.firstName || slotProps.option.lastName" class="text-sm text-500">
{{ slotProps.option.firstName }} {{ slotProps.option.lastName }}
</span>
</div>
</template>
</Select>
<small v-if="submitted && !editingProject.owner" class="p-error">Eigentümer ist erforderlich</small>
</div>
<div class="flex flex-col gap-2">
<label for="teamMembers">Team-Mitglieder</label>
<Select
id="teamMembers"
v-model="editingProject.teamMembers"
:options="users"
option-label="email"
placeholder="Team-Mitglieder auswählen"
filter
multiple
:disabled="saving"
>
<template #option="slotProps">
<div class="flex flex-col">
<span class="font-medium">{{ slotProps.option.email }}</span>
<span v-if="slotProps.option.firstName || slotProps.option.lastName" class="text-sm text-500">
{{ slotProps.option.firstName }} {{ slotProps.option.lastName }}
</span>
</div>
</template>
</Select>
<small class="text-500">Team-Mitglieder können das Projekt ansehen und bearbeiten</small>
</div>
</div>
<!-- Documents Section (only for existing projects) -->
<div v-if="editingProject.id" class="mt-4">
<div class="font-semibold text-lg mb-3">Dokumente</div>
<DocumentUpload
entity-type="project"
:entity-id="editingProject.id"
:can-upload="true"
:can-delete="true"
/>
</div>
<!-- Git Repositories Section (only for existing projects) -->
<div v-if="editingProject.id" class="mt-4">
<div class="font-semibold text-lg mb-3">Git Repositories</div>
<!-- Repository List -->
<DataTable
v-if="editGitRepositories.length > 0"
:value="editGitRepositories"
class="mb-3"
striped-rows
>
<Column field="name" header="Name" style="width: 25%">
<template #body="{ data }">
<div class="font-medium">{{ data.name }}</div>
</template>
</Column>
<Column field="url" header="URL" style="width: 35%">
<template #body="{ data }">
<a :href="data.url" target="_blank" class="text-primary hover:underline text-sm">
{{ data.url }}
</a>
</template>
</Column>
<Column field="provider" header="Provider" style="width: 15%">
<template #body="{ data }">
<Tag :value="data.provider || 'local'" severity="info" />
</template>
</Column>
<Column field="branch" header="Branch" style="width: 15%">
<template #body="{ data }">
<code class="text-sm">{{ data.branch || 'main' }}</code>
</template>
</Column>
<Column style="width: 10%">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button
icon="pi pi-pencil"
size="small"
text
@click="editGitRepo(data)"
/>
<Button
icon="pi pi-trash"
size="small"
text
severity="danger"
@click="confirmDeleteGitRepo(data)"
/>
</div>
</template>
</Column>
</DataTable>
<div v-else class="p-4 border-round border-1 surface-border text-center text-500 mb-3">
<i class="pi pi-github text-3xl mb-2"></i>
<p>Keine Git-Repositories verknüpft</p>
</div>
<!-- Add Repository Button -->
<Button
label="Repository hinzufügen"
icon="pi pi-plus"
size="small"
@click="openAddGitRepoDialog"
/>
</div>
</div>
<template #footer>
<Button label="Abbrechen" @click="projectDialog = false" text :disabled="saving" />
<Button label="Speichern" @click="saveProject" :loading="saving" />
</template>
</Dialog>
<!-- Git Repository Dialog -->
<Dialog
v-model:visible="gitRepoDialog"
:header="editingGitRepo?.id ? 'Repository bearbeiten' : 'Repository hinzufügen'"
:modal="true"
:style="{ width: '600px' }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="gitRepoName">Name *</label>
<InputText
id="gitRepoName"
v-model="editingGitRepo.name"
:class="{ 'p-invalid': gitRepoSubmitted && !editingGitRepo.name }"
placeholder="z.B. Main Repository"
/>
<small v-if="gitRepoSubmitted && !editingGitRepo.name" class="p-error">Name ist erforderlich</small>
</div>
<div class="flex flex-col gap-2">
<label for="gitRepoUrl">URL *</label>
<InputText
id="gitRepoUrl"
v-model="editingGitRepo.url"
:class="{ 'p-invalid': gitRepoSubmitted && !editingGitRepo.url }"
placeholder="https://github.com/user/repo"
/>
<small v-if="gitRepoSubmitted && !editingGitRepo.url" class="p-error">URL ist erforderlich</small>
</div>
<div class="flex flex-col gap-2">
<label for="gitRepoProvider">Provider</label>
<Select
id="gitRepoProvider"
v-model="editingGitRepo.provider"
:options="gitProviders"
option-label="label"
option-value="value"
placeholder="Provider auswählen"
/>
<small class="text-500">Leer lassen für lokale Repositories</small>
</div>
<div class="flex flex-col gap-2">
<label for="gitRepoBranch">Branch</label>
<InputText
id="gitRepoBranch"
v-model="editingGitRepo.branch"
placeholder="main"
/>
</div>
<!-- Test Connection Section -->
<div v-if="editingGitRepo.url && editingGitRepo.provider" class="p-3 border-round border-1 surface-border">
<div class="flex justify-between align-items-center mb-2">
<span class="font-medium text-sm">Verbindung testen</span>
<Button
label="Testen"
icon="pi pi-check-circle"
size="small"
outlined
:loading="testingConnection"
@click="testRepositoryConnection"
/>
</div>
<div v-if="connectionTestResult" class="mt-2">
<div v-if="connectionTestResult.success" class="flex align-items-start gap-2 p-2 border-round surface-ground border-1 border-green-500">
<i class="pi pi-check-circle text-green-600 mt-1"></i>
<div class="flex-1">
<div class="font-semibold text-sm text-green-700 dark:text-green-400">Verbindung erfolgreich</div>
<div class="text-xs mt-1">{{ connectionTestResult.message }}</div>
<div v-if="connectionTestResult.details" class="text-xs mt-1 text-600">
<div v-if="connectionTestResult.details.defaultBranch">
Standard-Branch: <code>{{ connectionTestResult.details.defaultBranch }}</code>
</div>
<div v-if="connectionTestResult.details.commitCount">
Commits gefunden: {{ connectionTestResult.details.commitCount }}
</div>
</div>
</div>
</div>
<div v-else class="flex align-items-start gap-2 p-2 border-round surface-ground border-1 border-red-500">
<i class="pi pi-times-circle text-red-600 mt-1"></i>
<div class="flex-1">
<div class="font-semibold text-sm text-red-700 dark:text-red-400">Verbindung fehlgeschlagen</div>
<div class="text-xs mt-1">{{ connectionTestResult.message }}</div>
<div v-if="connectionTestResult.error" class="text-xs mt-1 text-600 font-mono">
{{ connectionTestResult.error }}
</div>
<div v-if="connectionTestResult.details" class="text-xs mt-2 text-600">
<div v-if="connectionTestResult.details.hint" class="font-semibold">
💡 {{ connectionTestResult.details.hint }}
</div>
<div v-if="connectionTestResult.details.owner">
Owner: {{ connectionTestResult.details.owner }}
</div>
<div v-if="connectionTestResult.details.repo">
Repo: {{ connectionTestResult.details.repo }}
</div>
<div v-if="connectionTestResult.details.branch">
Branch: {{ connectionTestResult.details.branch }}
</div>
<div v-if="connectionTestResult.details.defaultBranch">
Standard-Branch: {{ connectionTestResult.details.defaultBranch }}
</div>
</div>
<div v-if="connectionTestResult.trace" class="text-xs mt-2 p-2 surface-100 dark:surface-800 border-round overflow-auto max-h-10rem font-mono">
{{ connectionTestResult.trace }}
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="gitRepoLocalPath">Lokaler Pfad (optional)</label>
<InputText
id="gitRepoLocalPath"
v-model="editingGitRepo.localPath"
placeholder="/pfad/zum/repository"
/>
<small class="text-500">Nur für lokale Git-Repositories erforderlich</small>
</div>
<div v-if="editingGitRepo.provider && editingGitRepo.provider !== 'local'" class="flex flex-col gap-2">
<label for="gitRepoToken">Access Token (optional)</label>
<InputText
id="gitRepoToken"
v-model="editingGitRepo.accessToken"
type="password"
placeholder="Token für private Repositories"
/>
<small class="text-500">Für private Repositories oder höhere Rate Limits</small>
</div>
<div class="flex flex-col gap-2">
<label for="gitRepoDescription">Beschreibung</label>
<Textarea
id="gitRepoDescription"
v-model="editingGitRepo.description"
rows="2"
placeholder="Optional: Beschreibung des Repositories"
/>
</div>
</div>
<template #footer>
<Button label="Abbrechen" @click="gitRepoDialog = false" text :disabled="savingGitRepo" />
<Button label="Speichern" @click="saveGitRepo" :loading="savingGitRepo" />
</template>
</Dialog>
<!-- Delete Git Repo Confirmation Dialog -->
<Dialog
v-model:visible="deleteGitRepoDialog"
header="Repository entfernen"
:modal="true"
:style="{ width: '450px' }"
>
<div class="flex align-items-center gap-3">
<i class="pi pi-exclamation-triangle text-4xl text-orange-500" />
<span>Möchten Sie das Repository <b>{{ gitRepoToDelete?.name }}</b> wirklich entfernen?</span>
</div>
<template #footer>
<Button label="Abbrechen" @click="deleteGitRepoDialog = false" text :disabled="deletingGitRepo" />
<Button label="Entfernen" @click="deleteGitRepo" severity="danger" :loading="deletingGitRepo" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="deleteDialog"
header="Projekt 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 das Projekt <b>{{ projectToDelete?.name }}</b> wirklich löschen?</span>
</div>
<template #footer>
<Button label="Abbrechen" @click="deleteDialog = false" text :disabled="deleting" />
<Button label="Löschen" @click="deleteProject" severity="danger" :loading="deleting" />
</template>
</Dialog>
<!-- View Project Dialog -->
<Dialog
v-model:visible="viewDialog"
header="Projekt anzeigen"
:modal="true"
:style="{ width: '1400px' }"
>
<Tabs value="0">
<TabList>
<Tab value="0">Projektdaten</Tab>
<Tab value="1">Git Repositories</Tab>
</TabList>
<TabPanels>
<!-- Project Data Tab -->
<TabPanel value="0">
<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">
<label class="font-medium text-sm text-500">Projektname</label>
<div class="text-900">{{ viewingProject.name || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Projektnummer</label>
<div class="text-900">{{ viewingProject.projectNumber || '-' }}</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">{{ viewingProject.description || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Kunde</label>
<div class="text-900">{{ viewingProject.customer?.companyName || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Status</label>
<Tag v-if="viewingProject.status" :value="viewingProject.status.name" :style="{ backgroundColor: viewingProject.status.color }" />
<div v-else class="text-900">-</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Typ</label>
<Tag :value="viewingProject.isPrivate ? 'Privat' : 'Beruflich'" :severity="viewingProject.isPrivate ? 'info' : 'success'" />
</div>
</div>
</div>
<!-- Project Numbers & Dates -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Nummern & Daten</div>
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Bestellnummer</label>
<div class="text-900">{{ viewingProject.orderNumber || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Bestelldatum</label>
<div class="text-900">{{ formatDate(viewingProject.orderDate) || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Startdatum</label>
<div class="text-900">{{ formatDate(viewingProject.startDate) || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Enddatum</label>
<div class="text-900">{{ formatDate(viewingProject.endDate) || '-' }}</div>
</div>
</div>
</div>
<!-- Budget & Hours -->
<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">{{ viewingProject.budget ? formatCurrency(viewingProject.budget) : '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Stundenkontingent</label>
<div class="text-900">{{ viewingProject.hourContingent ? `${viewingProject.hourContingent} h` : '-' }}</div>
</div>
</div>
</div>
<!-- Documents Section -->
<div class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Dokumente</div>
<DocumentUpload
v-if="viewingProject.id"
entity-type="project"
:entity-id="viewingProject.id"
:can-upload="false"
:can-delete="false"
/>
</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(viewingProject.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(viewingProject.updatedAt) || '-' }}</div>
</div>
</div>
</div>
</div>
</TabPanel>
<!-- Git Repository Tab -->
<TabPanel value="1">
<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>
<p class="text-sm">Füge ein Git-Repository hinzu, um Commits und Contributions zu analysieren.</p>
</div>
<div v-else class="flex flex-col gap-4">
<!-- Repository Selector -->
<div class="flex gap-3 align-items-center flex-wrap">
<label class="font-medium">Repository:</label>
<Select
v-model="selectedRepository"
:options="gitRepositories"
option-label="name"
placeholder="Repository auswählen"
class="flex-1"
/>
<label v-if="selectedRepository" class="font-medium">Branch:</label>
<InputText
v-if="selectedRepository"
v-model="selectedBranch"
placeholder="Branch (z.B. main)"
class="w-15rem"
/>
<label v-if="selectedRepository" class="font-medium">Jahr:</label>
<InputNumber
v-if="selectedRepository"
v-model="selectedYear"
:min="2000"
:max="2100"
:use-grouping="false"
class="w-10rem"
/>
<Button
v-if="selectedRepository"
icon="pi pi-refresh"
label="Cache aktualisieren"
size="small"
outlined
:loading="refreshingCache"
@click="refreshRepositoryCache"
/>
</div>
<!-- Contribution Chart -->
<div v-if="selectedRepository" class="p-4 border-round border-1 surface-border">
<GitContributionChart
:repository-id="selectedRepository.id"
:branch="selectedBranch"
:year="selectedYear"
/>
</div>
<!-- Commit List -->
<div v-if="selectedRepository" class="p-4 border-round border-1 surface-border">
<div class="font-semibold text-lg mb-3">Commit-Historie</div>
<GitCommitList
:repository-id="selectedRepository.id"
:branch="selectedBranch"
:limit="50"
/>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<template #footer>
<Button label="Schließen" @click="viewDialog = false" />
<Button v-if="canEdit" label="Bearbeiten" @click="editFromView" icon="pi pi-pencil" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
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 DatePicker from 'primevue/datepicker'
import InputNumber from 'primevue/inputnumber'
import RadioButton from 'primevue/radiobutton'
import Tag from 'primevue/tag'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import Tab from 'primevue/tab'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import GitCommitList from '../components/GitCommitList.vue'
import GitContributionChart from '../components/GitContributionChart.vue'
import DocumentUpload from '../components/DocumentUpload.vue'
const toast = useToast()
const tableRef = ref(null)
const projectDialog = ref(false)
const viewDialog = ref(false)
const deleteDialog = ref(false)
const editingProject = ref({})
const viewingProject = ref({})
const projectToDelete = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
const typeFilter = ref('all')
const customers = ref([])
const statuses = ref([])
const users = ref([])
const currentUser = ref(null)
const gitRepositories = ref([])
const selectedRepository = ref(null)
const selectedBranch = ref('main')
const selectedYear = ref(new Date().getFullYear())
const refreshingCache = ref(false)
// Git Repository Management
const editGitRepositories = ref([])
const gitRepoDialog = ref(false)
const editingGitRepo = ref({})
const gitRepoSubmitted = ref(false)
const savingGitRepo = ref(false)
const deleteGitRepoDialog = ref(false)
const gitRepoToDelete = ref(null)
const deletingGitRepo = ref(false)
const testingConnection = ref(false)
const connectionTestResult = ref(null)
const gitProviders = [
{ label: 'GitHub', value: 'github' },
{ label: 'Gitea', value: 'gitea' },
{ label: 'Lokal', value: 'local' }
]
// Permission checks (will be replaced with actual permission checks)
const canView = computed(() => true)
const canEdit = computed(() => true)
const canDelete = computed(() => true)
const canExport = computed(() => true)
// Column definitions
const projectColumns = ref([
{ key: 'name', label: 'Projektname', field: 'name', sortable: true, visible: true },
{ key: 'projectNumber', label: 'Projektnummer', field: 'projectNumber', sortable: true, visible: true },
{ key: 'customer', label: 'Kunde', field: 'customer', sortable: false, visible: true },
{ key: 'status', label: 'Status', field: 'status', sortable: false, visible: true },
{ key: 'orderNumber', label: 'Bestellnummer', field: 'orderNumber', sortable: true, visible: false },
{ key: 'orderDate', label: 'Bestelldatum', field: 'orderDate', sortable: true, visible: false },
{ key: 'startDate', label: 'Start', field: 'startDate', sortable: true, visible: true },
{ key: 'endDate', label: 'Ende', field: 'endDate', sortable: true, visible: true },
{ key: 'budget', label: 'Budget', field: 'budget', sortable: true, visible: true },
{ key: 'hourContingent', label: 'Stunden', field: 'hourContingent', sortable: true, visible: false },
{ key: 'type', label: 'Typ', field: 'type', 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 Promise.all([loadCustomers(), loadStatuses(), loadUsers(), loadCurrentUser()])
})
async function loadCustomers() {
try {
const response = await fetch('/api/contacts?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Kunden')
const data = await response.json()
const customersList = data['hydra:member'] || data.member || data
// Ensure it's an array
customers.value = Array.isArray(customersList) ? customersList : []
} catch (error) {
console.error('Error loading customers:', error)
customers.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Kunden konnten nicht geladen werden',
life: 3000
})
}
}
async function loadStatuses() {
try {
const response = await fetch('/api/project_statuses?pagination=false&isActive=true')
if (!response.ok) throw new Error('Fehler beim Laden der Status')
const data = await response.json()
const statusesList = data['hydra:member'] || data.member || data
// Ensure it's an array
statuses.value = Array.isArray(statusesList) ? statusesList : []
} catch (error) {
console.error('Error loading statuses:', error)
statuses.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Status konnten nicht geladen werden',
life: 3000
})
}
}
async function loadUsers() {
try {
const response = await fetch('/api/users?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Benutzer')
const data = await response.json()
const usersList = data['hydra:member'] || data.member || data
users.value = Array.isArray(usersList) ? usersList : []
} catch (error) {
console.error('Error loading users:', error)
users.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Benutzer konnten nicht geladen werden',
life: 3000
})
}
}
async function loadCurrentUser() {
try {
const response = await fetch('/api/me')
if (!response.ok) throw new Error('Fehler beim Laden des aktuellen Benutzers')
currentUser.value = await response.json()
} catch (error) {
console.error('Error loading current user:', error)
currentUser.value = null
}
}
function filterByType(type, loadData) {
typeFilter.value = type
let filters = {}
if (type === 'business') {
filters.isPrivate = false
} else if (type === 'private') {
filters.isPrivate = true
}
loadData(filters)
}
function onDataLoaded(data) {
console.log('Projects loaded:', data.length)
}
async function viewProject(project) {
viewingProject.value = { ...project }
viewDialog.value = true
// Load Git repositories for this project
await loadGitRepositories(project.id)
}
async function loadGitRepositories(projectId) {
try {
const response = await fetch(`/api/git_repositories?project=${projectId}`)
if (!response.ok) throw new Error('Fehler beim Laden der Git-Repositories')
const data = await response.json()
gitRepositories.value = data['hydra:member'] || data.member || data || []
// Select first repository by default
if (gitRepositories.value.length > 0) {
selectedRepository.value = gitRepositories.value[0]
selectedBranch.value = gitRepositories.value[0].branch || 'main'
} else {
selectedRepository.value = null
selectedBranch.value = 'main'
}
} catch (error) {
console.error('Error loading git repositories:', error)
gitRepositories.value = []
selectedRepository.value = null
}
}
function editFromView() {
viewDialog.value = false
editProject(viewingProject.value)
}
function openNewProjectDialog() {
// Find default status
const defaultStatus = statuses.value.find(s => s.isDefault) || statuses.value[0] || null
editingProject.value = {
name: '',
description: '',
customer: null,
status: defaultStatus,
projectNumber: '',
orderNumber: '',
orderDate: null,
startDate: null,
endDate: null,
budget: null,
hourContingent: null,
isPrivate: false,
owner: currentUser.value,
teamMembers: []
}
submitted.value = false
projectDialog.value = true
}
function editProject(project) {
// Convert date strings to Date objects for Calendar component
editingProject.value = {
...project,
orderDate: project.orderDate ? new Date(project.orderDate) : null,
startDate: project.startDate ? new Date(project.startDate) : null,
endDate: project.endDate ? new Date(project.endDate) : null,
budget: project.budget ? parseFloat(project.budget) : null,
hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null,
teamMembers: project.teamMembers || []
}
submitted.value = false
projectDialog.value = true
// Load git repositories for editing if project exists
if (project.id) {
loadGitRepositoriesForEdit(project.id)
}
}
async function loadGitRepositoriesForEdit(projectId) {
try {
const response = await fetch(`/api/git_repositories?project=${projectId}`)
if (!response.ok) throw new Error('Fehler beim Laden der Git-Repositories')
const data = await response.json()
editGitRepositories.value = data['hydra:member'] || data.member || data || []
} catch (error) {
console.error('Error loading git repositories for edit:', error)
editGitRepositories.value = []
}
}
// Git Repository Management Functions
function openAddGitRepoDialog() {
editingGitRepo.value = {
name: '',
url: '',
provider: null,
branch: 'main',
localPath: '',
accessToken: '',
description: ''
}
gitRepoSubmitted.value = false
connectionTestResult.value = null
gitRepoDialog.value = true
}
function editGitRepo(repo) {
editingGitRepo.value = { ...repo }
gitRepoSubmitted.value = false
connectionTestResult.value = null
gitRepoDialog.value = true
}
async function testRepositoryConnection() {
if (!editingGitRepo.value.url || !editingGitRepo.value.provider) {
toast.add({
severity: 'warn',
summary: 'Hinweis',
detail: 'Bitte URL und Provider angeben',
life: 3000
})
return
}
testingConnection.value = true
connectionTestResult.value = null
try {
// Create a temporary repository object for testing
const testData = {
url: editingGitRepo.value.url,
provider: editingGitRepo.value.provider,
branch: editingGitRepo.value.branch || 'main',
accessToken: editingGitRepo.value.accessToken || null,
localPath: editingGitRepo.value.localPath || null
}
const response = await fetch('/api/git-repos/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify(testData)
})
const result = await response.json()
console.log('Test response:', response.status, result)
if (response.ok && result.success) {
connectionTestResult.value = {
success: true,
message: result.message || 'Repository ist erreichbar und korrekt konfiguriert',
details: result.details || {}
}
// Auto-fill branch if detected
if (result.details?.defaultBranch && !editingGitRepo.value.branch) {
editingGitRepo.value.branch = result.details.defaultBranch
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Repository-Verbindung erfolgreich getestet',
life: 3000
})
} else {
connectionTestResult.value = {
success: false,
message: result.message || 'Verbindung zum Repository fehlgeschlagen',
error: result.error || ''
}
toast.add({
severity: 'error',
summary: 'Fehler',
detail: result.message || 'Verbindung fehlgeschlagen',
life: 5000
})
}
} catch (error) {
console.error('Error testing repository connection:', error)
connectionTestResult.value = {
success: false,
message: 'Netzwerkfehler beim Testen der Verbindung',
error: error.message
}
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Verbindung konnte nicht getestet werden',
life: 3000
})
} finally {
testingConnection.value = false
}
}
async function saveGitRepo() {
gitRepoSubmitted.value = true
if (!editingGitRepo.value.name || !editingGitRepo.value.url) {
return
}
savingGitRepo.value = true
try {
const repoData = {
name: editingGitRepo.value.name,
url: editingGitRepo.value.url,
provider: editingGitRepo.value.provider || null,
branch: editingGitRepo.value.branch || 'main',
localPath: editingGitRepo.value.localPath || null,
accessToken: editingGitRepo.value.accessToken || null,
description: editingGitRepo.value.description || null,
project: `/api/projects/${editingProject.value.id}`
}
const isNew = !editingGitRepo.value.id
const url = isNew ? '/api/git_repositories' : `/api/git_repositories/${editingGitRepo.value.id}`
const method = isNew ? 'POST' : 'PUT'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(repoData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Fehler beim Speichern des Repositories')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: isNew ? 'Repository hinzugefügt' : 'Repository aktualisiert',
life: 3000
})
gitRepoDialog.value = false
// Reload git repositories
await loadGitRepositoriesForEdit(editingProject.value.id)
} catch (error) {
console.error('Error saving git repository:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Repository konnte nicht gespeichert werden',
life: 3000
})
} finally {
savingGitRepo.value = false
}
}
function confirmDeleteGitRepo(repo) {
gitRepoToDelete.value = repo
deleteGitRepoDialog.value = true
}
async function deleteGitRepo() {
if (!gitRepoToDelete.value) return
deletingGitRepo.value = true
try {
const response = await fetch(`/api/git_repositories/${gitRepoToDelete.value.id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Fehler beim Löschen des Repositories')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Repository entfernt',
life: 3000
})
deleteGitRepoDialog.value = false
// Reload git repositories
await loadGitRepositoriesForEdit(editingProject.value.id)
} catch (error) {
console.error('Error deleting git repository:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Repository konnte nicht entfernt werden',
life: 3000
})
} finally {
deletingGitRepo.value = false
}
}
async function saveProject() {
submitted.value = true
if (!editingProject.value.name || !editingProject.value.owner) {
return
}
saving.value = true
try {
// Prepare data for API
const projectData = {
name: editingProject.value.name,
description: editingProject.value.description || null,
customer: editingProject.value.customer ? `/api/contacts/${editingProject.value.customer.id}` : null,
status: editingProject.value.status ? `/api/project_statuses/${editingProject.value.status.id}` : null,
projectNumber: editingProject.value.projectNumber || null,
orderNumber: editingProject.value.orderNumber || null,
orderDate: editingProject.value.orderDate ? formatDateForAPI(editingProject.value.orderDate) : null,
startDate: editingProject.value.startDate ? formatDateForAPI(editingProject.value.startDate) : null,
endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null,
budget: editingProject.value.budget ? editingProject.value.budget.toString() : null,
hourContingent: editingProject.value.hourContingent ? editingProject.value.hourContingent.toString() : null,
isPrivate: editingProject.value.isPrivate,
owner: `/api/users/${editingProject.value.owner.id}`,
teamMembers: editingProject.value.teamMembers?.map(member => `/api/users/${member.id}`) || []
}
const isNew = !editingProject.value.id
const url = isNew ? '/api/projects' : `/api/projects/${editingProject.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(projectData)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error['hydra:description'] || 'Fehler beim Speichern')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: `Projekt wurde ${isNew ? 'erstellt' : 'aktualisiert'}`,
life: 3000
})
projectDialog.value = false
tableRef.value?.loadData()
} catch (error) {
console.error('Error saving project:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Projekt konnte nicht gespeichert werden',
life: 3000
})
} finally {
saving.value = false
}
}
function confirmDelete(project) {
projectToDelete.value = project
deleteDialog.value = true
}
async function deleteProject() {
deleting.value = true
try {
const response = await fetch(`/api/projects/${projectToDelete.value.id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Fehler beim Löschen')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Projekt wurde gelöscht',
life: 3000
})
deleteDialog.value = false
projectToDelete.value = null
tableRef.value?.loadData()
} catch (error) {
console.error('Error deleting project:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Projekt konnte nicht gelöscht werden',
life: 3000
})
} finally {
deleting.value = false
}
}
async function refreshRepositoryCache() {
if (!selectedRepository.value) return
refreshingCache.value = true
try {
const response = await fetch(`/api/git-repos/${selectedRepository.value.id}/refresh-cache`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Cache konnte nicht aktualisiert werden')
}
const result = await response.json()
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: result.message || 'Cache wurde aktualisiert',
life: 3000
})
// Reload git data
await loadGitRepositories(viewingProject.value.id)
} catch (error) {
console.error('Error refreshing cache:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Cache konnte nicht aktualisiert werden',
life: 3000
})
} finally {
refreshingCache.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 formatDateForAPI(date) {
if (!date) return null
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatCurrency(value) {
if (!value) return ''
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
</script>
<style scoped>
.project-management {
height: 100%;
}
</style>