refactor(admin): split loeschfristen and vvt pages

Reduce both page.tsx files below the 500-LOC hard cap by extracting
all inline tab components and API helpers into colocated _components/.
- loeschfristen/page.tsx: 2720 → 467 LOC
- vvt/page.tsx: 2297 → 256 LOC
New files: LoeschkonzeptTab, loeschfristen/api, TabDokument, TabProcessor
Updated: TabVerzeichnis (template picker + badge), vvt/api (template helpers)
Fixed: VVTLinkSection wrong field name (linkedVVTActivityIds), VendorLinkSection added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 17:11:45 +02:00
parent 2ade65431a
commit e0c1d21879
10 changed files with 1279 additions and 4424 deletions

View File

@@ -370,18 +370,18 @@ export function VVTLinkSection({
Verknuepfen Sie diese Loeschfrist mit einer Verarbeitungstaetigkeit aus Ihrem VVT.
</p>
<div className="space-y-2">
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">Verknuepfte Taetigkeiten:</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVvtIds.map((vvtId: string) => {
{policy.linkedVVTActivityIds.map((vvtId: string) => {
const activity = vvtActivities.find((a: any) => a.id === vvtId)
return (
<span key={vvtId} className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
{activity?.name || vvtId}
<button type="button"
onClick={() => updatePolicy(pid, (p) => ({
...p, linkedVvtIds: (p.linkedVvtIds || []).filter((id: string) => id !== vvtId),
...p, linkedVVTActivityIds: (p.linkedVVTActivityIds || []).filter((id: string) => id !== vvtId),
}))}
className="text-blue-600 hover:text-blue-900">x</button>
</span>
@@ -393,15 +393,15 @@ export function VVTLinkSection({
<select
onChange={(e) => {
const val = e.target.value
if (val && !(policy.linkedVvtIds || []).includes(val)) {
updatePolicy(pid, (p) => ({ ...p, linkedVvtIds: [...(p.linkedVvtIds || []), val] }))
if (val && !(policy.linkedVVTActivityIds || []).includes(val)) {
updatePolicy(pid, (p) => ({ ...p, linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val] }))
}
e.target.value = ''
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
<option value="">Verarbeitungstaetigkeit verknuepfen...</option>
{vvtActivities
.filter((a: any) => !(policy.linkedVvtIds || []).includes(a.id))
.filter((a: any) => !(policy.linkedVVTActivityIds || []).includes(a.id))
.map((a: any) => (<option key={a.id} value={a.id}>{a.name || a.id}</option>))}
</select>
</div>
@@ -415,6 +415,70 @@ export function VVTLinkSection({
)
}
// ---------------------------------------------------------------------------
// Sektion 5b: Auftragsverarbeiter-Verknuepfung
// ---------------------------------------------------------------------------
export function VendorLinkSection({
policy, pid, vendorList, updatePolicy,
}: {
policy: LoeschfristPolicy; pid: string; vendorList: Array<{id: string; name: string}>
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">5b. Verknuepfte Auftragsverarbeiter</h3>
{vendorList.length > 0 ? (
<div>
<p className="text-sm text-gray-500 mb-3">
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
</p>
<div className="space-y-2">
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">Verknuepfte Auftragsverarbeiter:</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVendorIds.map((vendorId: string) => {
const vendor = vendorList.find((v) => v.id === vendorId)
return (
<span key={vendorId} className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full">
{vendor?.name || vendorId}
<button type="button"
onClick={() => updatePolicy(pid, (p) => ({
...p, linkedVendorIds: (p.linkedVendorIds || []).filter((id: string) => id !== vendorId),
}))}
className="text-orange-600 hover:text-orange-900">x</button>
</span>
)
})}
</div>
</div>
)}
<select
onChange={(e) => {
const val = e.target.value
if (val && !(policy.linkedVendorIds || []).includes(val)) {
updatePolicy(pid, (p) => ({ ...p, linkedVendorIds: [...(p.linkedVendorIds || []), val] }))
}
e.target.value = ''
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
<option value="">Auftragsverarbeiter verknuepfen...</option>
{vendorList
.filter((v) => !(policy.linkedVendorIds || []).includes(v.id))
.map((v) => (<option key={v.id} value={v.id}>{v.name || v.id}</option>))}
</select>
</div>
</div>
) : (
<p className="text-sm text-gray-400">
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen herstellen zu koennen.
</p>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Sektion 6: Review-Einstellungen
// ---------------------------------------------------------------------------

View File

@@ -7,7 +7,7 @@ import {
import { renderStatusBadge } from './UebersichtTab'
import {
DataObjectSection, DeletionLogicSection, StorageSection,
ResponsibilitySection, VVTLinkSection, ReviewSection,
ResponsibilitySection, VVTLinkSection, VendorLinkSection, ReviewSection,
} from './EditorSections'
// ---------------------------------------------------------------------------
@@ -19,6 +19,7 @@ interface EditorTabProps {
editingId: string | null
editingPolicy: LoeschfristPolicy | null
vvtActivities: any[]
vendorList: Array<{id: string; name: string}>
saving: boolean
setEditingId: (id: string | null) => void
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
@@ -79,7 +80,7 @@ function EditorNoSelection({
// ---------------------------------------------------------------------------
function EditorForm({
policy, vvtActivities, saving, setEditingId, setTab,
policy, vvtActivities, vendorList, saving, setEditingId, setTab,
updatePolicy, deletePolicy, addLegalHold, removeLegalHold,
addStorageLocation, removeStorageLocation, handleSaveAndClose,
}: Omit<EditorTabProps, 'policies' | 'editingId' | 'editingPolicy' | 'createNewPolicy'> & {
@@ -127,6 +128,7 @@ function EditorForm({
addStorageLocation={addStorageLocation} removeStorageLocation={removeStorageLocation} />
<ResponsibilitySection policy={policy} set={set} />
<VVTLinkSection policy={policy} pid={pid} vvtActivities={vvtActivities} updatePolicy={updatePolicy} />
<VendorLinkSection policy={policy} pid={pid} vendorList={vendorList} updatePolicy={updatePolicy} />
<ReviewSection policy={policy} set={set} />
{/* Action buttons */}

View File

@@ -0,0 +1,257 @@
'use client'
import React from 'react'
import { LoeschfristPolicy } from '@/lib/sdk/loeschfristen-types'
import { ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
import {
buildLoeschkonzeptHtml,
type LoeschkonzeptOrgHeader,
type LoeschkonzeptRevision,
} from '@/lib/sdk/loeschfristen-document'
import { downloadFile } from '@/lib/sdk/loeschfristen-export'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LoeschkonzeptTabProps {
policies: LoeschfristPolicy[]
orgHeader: LoeschkonzeptOrgHeader
revisions: LoeschkonzeptRevision[]
complianceResult: ComplianceCheckResult | null
vvtActivities: any[]
onOrgHeaderChange: (field: keyof LoeschkonzeptOrgHeader, value: string | string[]) => void
onAddRevision: () => void
onUpdateRevision: (index: number, field: keyof LoeschkonzeptRevision, value: string) => void
onRemoveRevision: (index: number) => void
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LoeschkonzeptTab({
policies,
orgHeader,
revisions,
complianceResult,
vvtActivities,
onOrgHeaderChange,
onAddRevision,
onUpdateRevision,
onRemoveRevision,
}: LoeschkonzeptTabProps) {
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
function handlePrintLoeschkonzept() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 300)
}
}
function handleDownloadLoeschkonzeptHtml() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
downloadFile(htmlContent, `loeschkonzept-${new Date().toISOString().split('T')[0]}.html`, 'text/html;charset=utf-8')
}
return (
<div className="space-y-4">
{/* Action bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Loeschkonzept (Art. 5/17/30 DSGVO)
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Druckfertiges Loeschkonzept mit Deckblatt, Loeschregeln, VVT-Verknuepfung und Compliance-Status.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownloadLoeschkonzeptHtml}
disabled={activePolicies.length === 0}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
HTML herunterladen
</button>
<button
onClick={handlePrintLoeschkonzept}
disabled={activePolicies.length === 0}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
Als PDF drucken
</button>
</div>
</div>
{activePolicies.length === 0 && (
<div className="bg-yellow-50 text-yellow-700 text-sm rounded-lg p-3 border border-yellow-200">
Keine aktiven Policies vorhanden. Erstellen Sie mindestens eine Policy, um das Loeschkonzept zu generieren.
</div>
)}
</div>
{/* Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Organisationsdaten (Deckblatt)</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Organisation</label>
<input type="text" value={orgHeader.organizationName}
onChange={e => onOrgHeaderChange('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name der Organisation" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Branche</label>
<input type="text" value={orgHeader.industry}
onChange={e => onOrgHeaderChange('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. IT / Software" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Datenschutzbeauftragter</label>
<input type="text" value={orgHeader.dpoName}
onChange={e => onOrgHeaderChange('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des DSB" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">DSB-Kontakt</label>
<input type="text" value={orgHeader.dpoContact}
onChange={e => onOrgHeaderChange('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="E-Mail oder Telefon" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Verantwortlicher (Art. 4 Nr. 7)</label>
<input type="text" value={orgHeader.responsiblePerson}
onChange={e => onOrgHeaderChange('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des Verantwortlichen" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Mitarbeiter</label>
<input type="text" value={orgHeader.employeeCount}
onChange={e => onOrgHeaderChange('employeeCount', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. 50-249" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Version</label>
<input type="text" value={orgHeader.loeschkonzeptVersion}
onChange={e => onOrgHeaderChange('loeschkonzeptVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="1.0" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Pruefintervall</label>
<select value={orgHeader.reviewInterval}
onChange={e => onOrgHeaderChange('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
<option value="Vierteljaehrlich">Vierteljaehrlich</option>
<option value="Halbjaehrlich">Halbjaehrlich</option>
<option value="Jaehrlich">Jaehrlich</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Letzte Pruefung</label>
<input type="date" value={orgHeader.lastReviewDate}
onChange={e => onOrgHeaderChange('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Naechste Pruefung</label>
<input type="date" value={orgHeader.nextReviewDate}
onChange={e => onOrgHeaderChange('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
</div>
</div>
</div>
{/* Revisions */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-semibold text-gray-900">Aenderungshistorie</h4>
<button onClick={onAddRevision}
className="text-xs bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg px-3 py-1.5 font-medium transition">
+ Revision hinzufuegen
</button>
</div>
{revisions.length === 0 ? (
<p className="text-sm text-gray-400">
Noch keine Revisionen. Die Erstversion wird automatisch im Dokument eingefuegt.
</p>
) : (
<div className="space-y-3">
{revisions.map((rev, idx) => (
<div key={idx} className="grid grid-cols-[80px_120px_1fr_1fr_32px] gap-2 items-start">
<input type="text" value={rev.version}
onChange={e => onUpdateRevision(idx, 'version', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="1.1" />
<input type="date" value={rev.date}
onChange={e => onUpdateRevision(idx, 'date', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" />
<input type="text" value={rev.author}
onChange={e => onUpdateRevision(idx, 'author', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="Autor" />
<input type="text" value={rev.changes}
onChange={e => onUpdateRevision(idx, 'changes', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="Beschreibung der Aenderungen" />
<button onClick={() => onRemoveRevision(idx)}
className="text-red-400 hover:text-red-600 p-1" title="Revision entfernen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
<div className="text-center mb-6">
<div className="text-2xl font-bold text-purple-700 mb-1">Loeschkonzept</div>
<div className="text-sm text-purple-500 mb-4">gemaess Art. 5/17/30 DSGVO</div>
<div className="text-sm text-gray-600">
{orgHeader.organizationName || <span className="text-gray-400 italic">Organisation nicht angegeben</span>}
</div>
<div className="text-xs text-gray-400 mt-2">
Version {orgHeader.loeschkonzeptVersion} | {new Date().toLocaleDateString('de-DE')}
</div>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
<div>1. Ziel und Zweck</div><div>7. Auftragsverarbeiter</div>
<div>2. Geltungsbereich</div><div>8. Legal Hold Verfahren</div>
<div>3. Grundprinzipien</div><div>9. Verantwortlichkeiten</div>
<div>4. Loeschregeln-Uebersicht</div><div>10. Pruef-/Revisionszyklus</div>
<div>5. Detaillierte Loeschregeln</div><div>11. Compliance-Status</div>
<div>6. VVT-Verknuepfung</div><div>12. Aenderungshistorie</div>
</div>
</div>
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
{complianceResult && (
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { LoeschfristPolicy, createEmptyPolicy } from '@/lib/sdk/loeschfristen-types'
export const LOESCHFRISTEN_API = '/api/sdk/v1/compliance/loeschfristen'
export function apiToPolicy(raw: any): LoeschfristPolicy {
const base = createEmptyPolicy()
return {
...base,
id: raw.id,
policyId: raw.policy_id || base.policyId,
dataObjectName: raw.data_object_name || '',
description: raw.description || '',
affectedGroups: raw.affected_groups || [],
dataCategories: raw.data_categories || [],
primaryPurpose: raw.primary_purpose || '',
deletionTrigger: raw.deletion_trigger || 'PURPOSE_END',
retentionDriver: raw.retention_driver || null,
retentionDriverDetail: raw.retention_driver_detail || '',
retentionDuration: raw.retention_duration ?? null,
retentionUnit: raw.retention_unit || null,
retentionDescription: raw.retention_description || '',
startEvent: raw.start_event || '',
hasActiveLegalHold: raw.has_active_legal_hold || false,
legalHolds: raw.legal_holds || [],
storageLocations: raw.storage_locations || [],
deletionMethod: raw.deletion_method || 'MANUAL_REVIEW_DELETE',
deletionMethodDetail: raw.deletion_method_detail || '',
responsibleRole: raw.responsible_role || '',
responsiblePerson: raw.responsible_person || '',
releaseProcess: raw.release_process || '',
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
linkedVendorIds: raw.linked_vendor_ids || [],
status: raw.status || 'DRAFT',
lastReviewDate: raw.last_review_date || base.lastReviewDate,
nextReviewDate: raw.next_review_date || base.nextReviewDate,
reviewInterval: raw.review_interval || 'ANNUAL',
tags: raw.tags || [],
createdAt: raw.created_at || base.createdAt,
updatedAt: raw.updated_at || base.updatedAt,
}
}
export function policyToPayload(p: LoeschfristPolicy): any {
return {
policy_id: p.policyId,
data_object_name: p.dataObjectName,
description: p.description,
affected_groups: p.affectedGroups,
data_categories: p.dataCategories,
primary_purpose: p.primaryPurpose,
deletion_trigger: p.deletionTrigger,
retention_driver: p.retentionDriver || null,
retention_driver_detail: p.retentionDriverDetail,
retention_duration: p.retentionDuration || null,
retention_unit: p.retentionUnit || null,
retention_description: p.retentionDescription,
start_event: p.startEvent,
has_active_legal_hold: p.hasActiveLegalHold,
legal_holds: p.legalHolds,
storage_locations: p.storageLocations,
deletion_method: p.deletionMethod,
deletion_method_detail: p.deletionMethodDetail,
responsible_role: p.responsibleRole,
responsible_person: p.responsiblePerson,
release_process: p.releaseProcess,
linked_vvt_activity_ids: p.linkedVVTActivityIds,
linked_vendor_ids: p.linkedVendorIds,
status: p.status,
last_review_date: p.lastReviewDate || null,
next_review_date: p.nextReviewDate || null,
review_interval: p.reviewInterval,
tags: p.tags,
}
}