diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 87bfd2d3..4a30aef5 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -10,6 +10,7 @@ use App\Filament\Resources\EvidenceSnapshotResource; use App\Models\EnvironmentReview; use App\Models\EvidenceSnapshot; +use App\Models\Finding; use App\Models\FindingException; use App\Models\ManagedEnvironment; use App\Models\OperationRun; @@ -49,6 +50,7 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use UnitEnum; @@ -310,7 +312,10 @@ public function latestReviewConsumptionPayload(): ?array $decision = $this->decisionSummaryForReview($review); $acceptedRisks = $this->acceptedRisksForReview($review); $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); - + $findingPanel = $this->findingPanelForReview($tenant); + $canShowSecondaryReviewLink = $downloadUrl !== null + && ! $hasAcceptedRiskFollowUp + && $findingPanel['open_count'] === 0; $evidencePath = $this->evidencePathForReview($review, $tenant, $packageAvailability, $downloadUrl, $decision, $acceptedRisks); return [ @@ -336,21 +341,20 @@ public function latestReviewConsumptionPayload(): ?array 'primary_action_icon' => $downloadUrl !== null ? 'heroicon-o-arrow-down-tray' : 'heroicon-o-arrow-top-right-on-square', - 'secondary_action_label' => $downloadUrl !== null - ? ($hasAcceptedRiskFollowUp ? __('localization.review.download_review_pack') : __('localization.review.open_review')) + 'secondary_action_label' => $canShowSecondaryReviewLink + ? __('localization.review.open_review') : null, - 'secondary_action_url' => $downloadUrl !== null - ? ($hasAcceptedRiskFollowUp ? $downloadUrl : $reviewUrl) + 'secondary_action_url' => $canShowSecondaryReviewLink + ? $reviewUrl : null, - 'secondary_action_icon' => $downloadUrl !== null && $hasAcceptedRiskFollowUp - ? 'heroicon-o-arrow-down-tray' - : 'heroicon-o-arrow-top-right-on-square', + 'secondary_action_icon' => 'heroicon-o-arrow-top-right-on-square', ], 'readiness' => $this->reviewReadinessForTenant($tenant, $review, $packageAvailability, $downloadUrl, $reviewUrl), - 'readiness_dimensions' => $this->readinessDimensionPayloads($tenant, $review, $packageAvailability), + 'readiness_flow' => $this->reviewConsumptionFlowForReview($tenant, $review, $packageAvailability, $downloadUrl), + 'finding_panel' => $findingPanel, 'decision' => $decision, 'accepted_risks' => $acceptedRisks, - 'accepted_risk_panel' => $this->acceptedRiskPanelForReview($review), + 'accepted_risk_panel' => $this->acceptedRiskPanelForReview($review, $tenant), 'evidence_basis' => $this->evidenceBasisForReview($review, $packageAvailability), 'evidence_path' => $evidencePath, 'aside_evidence_path' => $this->asideEvidencePath($evidencePath), @@ -399,13 +403,20 @@ private function reviewReadinessForTenant( ?string $reviewUrl, ): array { $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); + $findingPanel = $this->findingPanelForReview($tenant); + $hasFindingFollowUp = $findingPanel['open_count'] > 0; $hasReadyPackage = $packageAvailability['state'] === 'available' && $downloadUrl !== null; + $hasAvailableEvidence = $this->evidenceStatusState($tenant) === 'available'; + $hasMappedReviewData = $this->primaryControlSummary($tenant) !== null; $hasCustomerSafeProof = $this->primaryControlSummary($tenant) !== null - && $this->evidenceStatusState($tenant) === 'available'; + && $hasAvailableEvidence; $isReadyToShare = ! $hasAcceptedRiskFollowUp - && ! $this->workspaceReviewNeedsAttention($tenant) - && $hasReadyPackage; - $isShareableWithFollowUp = $hasAcceptedRiskFollowUp && $hasReadyPackage && $hasCustomerSafeProof; + && $findingPanel['open_count'] === 0 + && $hasReadyPackage + && $hasAvailableEvidence + && $hasMappedReviewData; + $isShareableWithFollowUp = $hasAcceptedRiskFollowUp && ! $hasFindingFollowUp && $hasReadyPackage && $hasCustomerSafeProof; + $primaryActionShouldOpenReview = $hasFindingFollowUp || $isShareableWithFollowUp; return [ 'question' => __('localization.review.is_review_ready_to_share'), @@ -421,7 +432,10 @@ private function reviewReadinessForTenant( }, 'reason' => match (true) { $isReadyToShare => __('localization.review.ready_to_share_reason'), - $isShareableWithFollowUp => __('localization.review.shareable_with_follow_up_reason'), + $isShareableWithFollowUp => __('localization.review.accepted_risk_follow_up_required_reason'), + $hasFindingFollowUp => __('localization.review.findings_follow_up_required_reason', [ + 'summary' => $findingPanel['summary'], + ]), default => $this->customerSafeText( $this->reviewOutcomeDescription($tenant) ?? $packageAvailability['description'], __('localization.review.follow_up_required_before_sharing_reason'), @@ -429,16 +443,17 @@ private function reviewReadinessForTenant( }, 'impact' => match (true) { $isReadyToShare => __('localization.review.ready_to_share_impact'), - $isShareableWithFollowUp => __('localization.review.shareable_with_follow_up_impact'), + $isShareableWithFollowUp => __('localization.review.accepted_risk_follow_up_required_impact'), + $hasFindingFollowUp => __('localization.review.findings_follow_up_required_impact'), default => __('localization.review.follow_up_required_before_sharing_impact'), }, - 'primary_action_label' => $isShareableWithFollowUp + 'primary_action_label' => $primaryActionShouldOpenReview ? __('localization.review.open_review') : ($downloadUrl !== null ? __('localization.review.download_review_pack') : __('localization.review.open_latest_review')), - 'primary_action_url' => $isShareableWithFollowUp + 'primary_action_url' => $primaryActionShouldOpenReview ? ($reviewUrl ?? $downloadUrl) : ($downloadUrl ?? $reviewUrl), - 'primary_action_icon' => $isShareableWithFollowUp || $downloadUrl === null + 'primary_action_icon' => $primaryActionShouldOpenReview || $downloadUrl === null ? 'heroicon-o-arrow-top-right-on-square' : 'heroicon-o-arrow-down-tray', ]; @@ -446,42 +461,83 @@ private function reviewReadinessForTenant( /** * @param array{state:string,label:string,description:string} $packageAvailability - * @return list + * @return list */ - private function readinessDimensionPayloads( + private function reviewConsumptionFlowForReview( ManagedEnvironment $tenant, EnvironmentReview $review, array $packageAvailability, + ?string $downloadUrl, ): array { - $acceptedRisk = $this->acceptedRiskDimensionForReview($review); $evidenceState = $this->evidenceStatusState($tenant); + $findingPanel = $this->findingPanelForReview($tenant); + $acceptedRisk = $this->acceptedRiskDimensionForReview($review, $tenant); + $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); + $hasReadyPackage = $packageAvailability['state'] === 'available' && $downloadUrl !== null; + $hasMappedReviewData = $this->primaryControlSummary($tenant) !== null; + $hasBlockingAttention = $findingPanel['open_count'] > 0 + || $hasAcceptedRiskFollowUp + || $evidenceState !== 'available' + || ! $hasMappedReviewData; + + $customerOutputLabel = match (true) { + $hasReadyPackage && ! $hasBlockingAttention => __('localization.review.ready'), + $hasReadyPackage => __('localization.review.needs_review'), + default => __('localization.review.not_ready'), + }; + $customerOutputColor = match (true) { + $hasReadyPackage && ! $hasBlockingAttention => 'success', + $hasReadyPackage => 'warning', + default => 'gray', + }; + $customerOutputDescription = match (true) { + $hasReadyPackage && ! $hasBlockingAttention => __('localization.review.customer_output_ready_description'), + $hasReadyPackage => __('localization.review.customer_output_needs_review_description'), + default => __('localization.review.customer_output_not_ready_description'), + }; return [ [ - 'title' => __('localization.review.readiness'), - 'label' => $this->latestReviewStateLabel($tenant), - 'color' => $this->latestReviewStateColor($tenant), - 'description' => $this->workspaceReviewNeedsAttention($tenant) - ? __('localization.review.readiness_dimension_follow_up_description') - : __('localization.review.readiness_dimension_ready_description'), + 'title' => __('localization.review.review_data'), + 'label' => __('localization.review.available'), + 'color' => 'success', + 'description' => __('localization.review.review_data_available_description'), + 'is_current' => false, ], [ 'title' => __('localization.review.evidence'), 'label' => $this->evidenceStatusLabelForState($evidenceState), 'color' => $this->evidenceStatusColorForState($evidenceState), 'description' => $this->evidenceDimensionDescription($evidenceState), + 'is_current' => $evidenceState !== 'available', ], [ - 'title' => __('localization.review.accepted_risk_status'), + 'title' => __('localization.review.findings_triaged'), + 'label' => $findingPanel['status_label'], + 'color' => $findingPanel['status_color'], + 'description' => $findingPanel['summary'], + 'is_current' => $findingPanel['open_count'] > 0, + ], + [ + 'title' => __('localization.review.accepted_risks_reviewed'), 'label' => $acceptedRisk['label'], 'color' => $acceptedRisk['color'], 'description' => $acceptedRisk['description'], + 'is_current' => $this->acceptedRiskFollowUpRequiredForReview($review), ], [ 'title' => __('localization.review.review_pack'), 'label' => $packageAvailability['label'], 'color' => $this->governancePackageAvailabilityColor($tenant), 'description' => $this->reviewPackDimensionDescription($packageAvailability), + 'is_current' => $packageAvailability['state'] !== 'available', + ], + [ + 'title' => __('localization.review.customer_output'), + 'label' => $customerOutputLabel, + 'color' => $customerOutputColor, + 'description' => $customerOutputDescription, + 'is_current' => ! ($hasReadyPackage && ! $hasBlockingAttention), ], ]; } @@ -514,9 +570,11 @@ private function reviewPackDimensionDescription(array $packageAvailability): str /** * @return array{label:string,color:string,description:string} */ - private function acceptedRiskDimensionForReview(EnvironmentReview $review): array + private function acceptedRiskDimensionForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array { $acceptedRisks = $this->acceptedRisksForReview($review); + $exceptionCount = $this->acceptedRiskExceptionsForTenant($tenant)->count(); + $acceptedRiskCount = max($acceptedRisks['count'], $exceptionCount); $hasFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); if ($hasFollowUp) { @@ -527,7 +585,7 @@ private function acceptedRiskDimensionForReview(EnvironmentReview $review): arra ]; } - if ($acceptedRisks['count'] === 0) { + if ($acceptedRiskCount === 0) { return [ 'label' => __('localization.review.accepted_risk_no_action_needed'), 'color' => 'gray', @@ -536,7 +594,7 @@ private function acceptedRiskDimensionForReview(EnvironmentReview $review): arra } return [ - 'label' => __('localization.review.accepted_risk_on_record', ['count' => $acceptedRisks['count']]), + 'label' => __('localization.review.accepted_risk_on_record', ['count' => $acceptedRiskCount]), 'color' => 'info', 'description' => __('localization.review.accepted_risk_dimension_on_record_description'), ]; @@ -556,13 +614,27 @@ private function acceptedRiskFollowUpRequiredForReview(EnvironmentReview $review $decisionEntries = collect($package['governance_decisions'] ?? []) ->filter(static fn (mixed $entry): bool => is_array($entry)); - return $acceptedEntries + if ($acceptedEntries ->merge($decisionEntries) ->contains(static fn (array $entry): bool => in_array( (string) ($entry['governance_state'] ?? ''), self::ACCEPTED_RISK_FOLLOW_UP_STATES, true, - )); + ))) { + return true; + } + + return FindingException::query() + ->where('workspace_id', (int) $review->workspace_id) + ->where('managed_environment_id', (int) $review->managed_environment_id) + ->current() + ->whereIn('current_validity_state', [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_REVOKED, + FindingException::VALIDITY_MISSING_SUPPORT, + ]) + ->exists(); } /** @@ -687,8 +759,8 @@ private function reviewPackProofForReview(array $packageAvailability, ?string $d default => 'gray', }, 'description' => $packageAvailability['description'], - 'action_label' => $downloadUrl !== null ? __('localization.review.download_review_pack') : null, - 'action_url' => $downloadUrl, + 'action_label' => null, + 'action_url' => null, ]; } @@ -742,12 +814,18 @@ private function operationProofForReview(EnvironmentReview $review, ManagedEnvir ])->first(fn (mixed $candidate): bool => $candidate instanceof OperationRun); if ($run instanceof OperationRun) { + $initiator = is_string($run->initiator_name) && trim($run->initiator_name) !== '' + ? trim($run->initiator_name) + : null; + return [ 'key' => 'operation_proof', 'title' => __('localization.review.operation_proof'), 'label' => __('localization.review.available'), 'color' => 'info', - 'description' => __('localization.review.operation_proof_available_description'), + 'description' => $initiator === null + ? __('localization.review.operation_proof_available_description') + : __('localization.review.operation_proof_available_with_initiator_description', ['initiator' => $initiator]), 'action_label' => OperationRunLinks::openLabel(), 'action_url' => OperationRunLinks::tenantlessView($run), ]; @@ -780,28 +858,191 @@ private function exportArtifactProofForReview(array $packageAvailability, ?strin 'description' => $downloadUrl !== null ? __('localization.review.export_artifact_available_description') : __('localization.review.export_artifact_unavailable_description'), - 'action_label' => $downloadUrl !== null ? __('localization.review.download_review_pack') : null, - 'action_url' => $downloadUrl, + 'action_label' => null, + 'action_url' => null, ]; } /** - * @return array{summary_label:string,summary_color:string,items:list} + * @return array{status_label:string,status_color:string,summary:string,total_count:int,open_count:int,high_impact_count:int,items:list} */ - private function acceptedRiskPanelForReview(EnvironmentReview $review): array + private function findingPanelForReview(ManagedEnvironment $tenant): array + { + $baseQuery = Finding::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()); + + $total = (clone $baseQuery)->count(); + $open = (clone $baseQuery) + ->whereIn('status', Finding::openStatusesForQuery()) + ->count(); + $highImpact = (clone $baseQuery) + ->whereIn('status', Finding::openStatusesForQuery()) + ->whereIn('severity', Finding::highSeverityValues()) + ->count(); + $accepted = (clone $baseQuery) + ->where('status', Finding::STATUS_RISK_ACCEPTED) + ->count(); + + $summary = match (true) { + $open > 0 && $highImpact > 0 => __('localization.review.findings_high_impact_summary', [ + 'open' => trans_choice('localization.review.findings_open_attention_count', $open, ['count' => $open]), + 'high' => trans_choice('localization.review.findings_high_impact_count_summary', $highImpact, ['count' => $highImpact]), + ]), + $open > 0 => trans_choice('localization.review.findings_open_summary', $open, ['count' => $open]), + $total > 0 => __('localization.review.findings_no_open_summary', ['total' => $total]), + default => __('localization.review.findings_none_action_summary'), + }; + + return [ + 'status_label' => $open > 0 + ? __('localization.review.needs_review') + : __('localization.review.no_action_needed'), + 'status_color' => match (true) { + $highImpact > 0 => 'danger', + $open > 0 => 'warning', + default => 'success', + }, + 'summary' => $summary, + 'total_count' => $total, + 'open_count' => $open, + 'high_impact_count' => $highImpact, + 'items' => [ + [ + 'label' => __('localization.review.findings_total'), + 'value' => (string) $total, + 'color' => $total > 0 ? 'info' : 'gray', + ], + [ + 'label' => __('localization.review.findings_open'), + 'value' => (string) $open, + 'color' => $open > 0 ? 'warning' : 'gray', + ], + [ + 'label' => __('localization.review.findings_high_impact'), + 'value' => (string) $highImpact, + 'color' => $highImpact > 0 ? 'danger' : 'gray', + ], + [ + 'label' => __('localization.review.accepted_risks'), + 'value' => (string) $accepted, + 'color' => $accepted > 0 ? 'info' : 'gray', + ], + ], + ]; + } + + /** + * @return Collection + */ + private function acceptedRiskExceptionsForTenant(ManagedEnvironment $tenant): Collection + { + $user = auth()->user(); + + if (! $user instanceof User || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) { + return collect(); + } + + return FindingException::query() + ->with(['owner', 'approver', 'currentDecision']) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->current() + ->orderByRaw("case when current_validity_state in ('expiring', 'expired', 'missing_support') then 0 else 1 end") + ->latest('approved_at') + ->latest('requested_at') + ->latest('id') + ->get(); + } + + /** + * @param Collection $exceptions + * @return list + */ + private function acceptedRiskDetailRows(Collection $exceptions): array + { + if ($exceptions->isEmpty()) { + return []; + } + + $owner = $exceptions + ->map(static fn (FindingException $exception): ?string => $exception->owner?->name ?? $exception->approver?->name) + ->filter(static fn (?string $name): bool => is_string($name) && trim($name) !== '') + ->first(); + $ownedException = $exceptions + ->first(static fn (FindingException $exception): bool => $exception->owner?->name !== null || $exception->approver?->name !== null); + $reviewDate = $exceptions + ->map(static fn (FindingException $exception): mixed => $exception->review_due_at ?? $exception->expires_at) + ->first(static fn (mixed $date): bool => $date instanceof \DateTimeInterface); + $hasMissingReviewDate = $exceptions + ->contains(static fn (FindingException $exception): bool => $exception->review_due_at === null && $exception->expires_at === null); + $reason = $ownedException instanceof FindingException && is_string($ownedException->request_reason) && trim($ownedException->request_reason) !== '' + ? trim($ownedException->request_reason) + : $exceptions + ->map(static fn (FindingException $exception): ?string => is_string($exception->request_reason) ? trim($exception->request_reason) : null) + ->filter(static fn (?string $value): bool => is_string($value) && $value !== '') + ->first(); + + $rows = [ + [ + 'label' => __('localization.review.accepted_risk_owner'), + 'value' => is_string($owner) ? $owner : __('localization.review.not_recorded'), + 'color' => is_string($owner) ? 'info' : 'gray', + ], + ]; + + if ($reviewDate instanceof \DateTimeInterface) { + $rows[] = [ + 'label' => __('localization.review.accepted_risk_next_review'), + 'value' => $reviewDate->format('Y-m-d'), + 'color' => 'info', + ]; + } + + if ($hasMissingReviewDate) { + $rows[] = [ + 'label' => __('localization.review.accepted_risk_next_review'), + 'value' => __('localization.review.review_date_not_recorded'), + 'color' => 'warning', + ]; + } + + $rows[] = [ + 'label' => __('localization.review.accepted_risk_rationale'), + 'value' => is_string($reason) ? Str::limit($reason, 160) : __('localization.review.not_recorded'), + 'color' => is_string($reason) ? 'info' : 'gray', + ]; + + return $rows; + } + + /** + * @return array{summary_label:string,summary_color:string,items:list,detail_rows:list} + */ + private function acceptedRiskPanelForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array { $package = $this->governancePackageSummaryForReview($review); $hasFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); + $exceptions = $this->acceptedRiskExceptionsForTenant($tenant); $acceptedEntries = collect($package['accepted_risks'] ?? []) ->filter(static fn (mixed $entry): bool => is_array($entry)); $decisionEntries = collect($package['governance_decisions'] ?? []) ->filter(static fn (mixed $entry): bool => is_array($entry)); $allEntries = $acceptedEntries->merge($decisionEntries); - $total = $allEntries->count(); - $expiring = $allEntries->where('governance_state', 'expiring_exception')->count(); - $expired = $allEntries->where('governance_state', 'expired_exception')->count(); - $pending = $allEntries->where('governance_state', 'pending_exception')->count(); + $total = max($allEntries->count(), $exceptions->count()); + $expiring = max( + $allEntries->where('governance_state', 'expiring_exception')->count(), + $exceptions->where('current_validity_state', FindingException::VALIDITY_EXPIRING)->count(), + ); + $expired = max( + $allEntries->where('governance_state', 'expired_exception')->count(), + $exceptions->where('current_validity_state', FindingException::VALIDITY_EXPIRED)->count(), + ); + $pending = max( + $allEntries->where('governance_state', 'pending_exception')->count(), + $exceptions->where('status', FindingException::STATUS_PENDING)->count(), + ); $needsReview = $allEntries ->filter(static fn (array $entry): bool => in_array( (string) ($entry['governance_state'] ?? ''), @@ -809,6 +1050,18 @@ private function acceptedRiskPanelForReview(EnvironmentReview $review): array true, )) ->count(); + $needsReview = max($needsReview, $exceptions + ->filter(static fn (FindingException $exception): bool => in_array( + (string) $exception->current_validity_state, + [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_REVOKED, + FindingException::VALIDITY_MISSING_SUPPORT, + ], + true, + )) + ->count()); return [ 'summary_label' => $hasFollowUp @@ -842,6 +1095,7 @@ private function acceptedRiskPanelForReview(EnvironmentReview $review): array 'color' => $needsReview > 0 ? 'warning' : 'gray', ], ], + 'detail_rows' => $this->acceptedRiskDetailRows($exceptions), ]; } diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index bda5b778..fc769dec 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -354,13 +354,31 @@ 'ready_to_share_reason' => 'Das veröffentlichte Review, der Nachweispfad und das aktuelle Review-Pack sind für die kundensichere Übergabe verfügbar.', 'shareable_with_follow_up_reason' => 'Das Review-Pack ist verfügbar, aber Accepted-Risk-Follow-up muss vor der Übergabe benannt werden.', 'follow_up_required_before_sharing_reason' => 'Review-Nachweis oder Paketverfügbarkeit benötigen noch Aufmerksamkeit, bevor dies geteilt werden kann.', + 'findings_follow_up_required_reason' => ':summary Halten Sie offene Findings vor der Kundenübergabe sichtbar.', + 'accepted_risk_follow_up_required_reason' => 'Accepted-Risk-Follow-up ist für dieses Review erfasst. Prüfen Sie Owner, Begründung und Review-Datum vor der Übergabe.', 'ready_to_share_impact' => 'Stakeholder können das aktuelle Review-Pack und das veröffentlichte Review als Nachweispfad nutzen.', 'shareable_with_follow_up_impact' => 'Nutzen Sie das aktuelle Pack nur, wenn das Accepted-Risk-Follow-up in der Kundenübergabe enthalten ist.', 'follow_up_required_before_sharing_impact' => 'Behandeln Sie dieses Review erst als teilbar, wenn der nicht verfügbare Nachweis geprüft wurde.', + 'findings_follow_up_required_impact' => 'Behandeln Sie dieses Review erst als teilbar, wenn offene Findings behoben, akzeptiert oder ausdrücklich geprüft wurden.', + 'accepted_risk_follow_up_required_impact' => 'Das aktuelle Pack darf nur mit enthaltenem Accepted-Risk-Kontext in die Kundenübergabe.', 'impact' => 'Auswirkung', 'scope' => 'Scope', + 'ready' => 'Bereit', + 'needs_review' => 'Review erforderlich', + 'not_ready' => 'Nicht bereit', 'readiness' => 'Bereitschaft', 'evidence' => 'Evidence', + 'review_consumption_flow' => 'Review-Consumption-Flow', + 'review_consumption_flow_description' => 'Prüfen Sie die abgeleiteten Review-, Evidence-, Findings-, Accepted-Risk-, Pack- und Kundenausgabe-Status vor der Weitergabe.', + 'review_data' => 'Review-Daten', + 'review_data_available_description' => 'Ein veröffentlichtes Review ist für diesen kundensicheren Workspace verfügbar.', + 'findings_triaged' => 'Findings triagiert', + 'accepted_risks_reviewed' => 'Akzeptierte Risiken geprüft', + 'customer_output' => 'Kundensichere Ausgabe', + 'customer_output_ready_description' => 'Evidence- und Review-Pack-Truth stützen die kundensichere Nutzung.', + 'customer_output_needs_review_description' => 'Ausgabe ist vorhanden, aber Aufmerksamkeitspunkte müssen bei der Übergabe sichtbar bleiben.', + 'customer_output_not_ready_description' => 'Kundensichere Ausgabe ist aus dem aktuellen Nachweisstatus nicht bereit.', + 'current_attention_point' => 'Aktueller Aufmerksamkeitspunkt', 'readiness_dimension_description' => 'Die Bereitschaft wird aus veröffentlichtem Review, Evidence, Accepted-Risk- und Review-Pack-Status abgeleitet.', 'readiness_dimension_ready_description' => 'Veröffentlichtes Review ist verfügbar.', 'readiness_dimension_follow_up_description' => 'Follow-up vor Übergabe erforderlich.', @@ -384,6 +402,7 @@ 'accepted_risk_records_description' => 'Accepted-Risk-Entscheidungen sind in der Evidence-Basis des veröffentlichten Reviews vorhanden.', 'operation_proof' => 'Operation-Nachweis', 'operation_proof_available_description' => 'Ein zugehöriger Operationsdatensatz existiert für diesen Review-Nachweispfad.', + 'operation_proof_available_with_initiator_description' => 'Ein zugehöriger Operationsdatensatz existiert für diesen Review-Nachweispfad. Gestartet von :initiator.', 'operation_proof_unavailable' => 'Kein Operation-Nachweis verknüpft', 'operation_proof_unavailable_description' => 'Für diesen veröffentlichten Review-Pfad ist kein Operation-Nachweis verknüpft.', 'export_artifact' => 'Export-Artefakt', @@ -403,6 +422,12 @@ 'diagnostics_customer_workspace_default_hidden' => 'Supportdetails bleiben auf autorisierten Diagnoseflächen und werden in diesem kundensicheren Workspace standardmäßig nicht angezeigt.', 'accepted_risk_summary' => 'Akzeptierte Risiken', 'accepted_risk_no_action_needed' => 'Keine Aktion erforderlich', + 'accepted_risk_accountability' => 'Accepted-Risk-Verantwortlichkeit', + 'accepted_risk_owner' => 'Owner', + 'accepted_risk_next_review' => 'Nächstes Review', + 'accepted_risk_rationale' => 'Begründung', + 'review_date_not_recorded' => 'Review-Datum nicht erfasst', + 'not_recorded' => 'Nicht erfasst', 'accepted_risks_expiring_soon' => 'Läuft bald ab', 'accepted_risks_expired' => 'Abgelaufen', 'accepted_risks_pending_approval' => 'Freigabe ausstehend', @@ -443,6 +468,16 @@ 'control_recommendation_unmapped' => 'Prüfen Sie unmapped Evidence vor der Kundenauslieferung.', 'proof_access_state' => 'Proof-Zugriff', 'key_findings' => 'Wichtige Findings', + 'findings_needing_attention' => 'Findings mit Aufmerksamkeit', + 'findings_total' => 'Findings gesamt', + 'findings_open' => 'Offene Findings', + 'findings_high_impact' => 'Hoher Impact', + 'findings_high_impact_summary' => ':open; :high.', + 'findings_open_attention_count' => '{1} 1 offenes Finding benötigt Aufmerksamkeit|[2,*] :count offene Findings benötigen Aufmerksamkeit', + 'findings_high_impact_count_summary' => '{1} 1 hat hohen Impact|[2,*] :count haben hohen Impact', + 'findings_open_summary' => '{1} 1 offenes Finding benötigt Kunden- oder Operator-Review.|[2,*] :count offene Findings benötigen Kunden- oder Operator-Review.', + 'findings_no_open_summary' => ':total Findings sind erfasst, ohne offene Kundenaktions-Findings.', + 'findings_none_action_summary' => 'Keine offenen Findings erfordern Kundenaktion.', 'accepted_risks' => 'Akzeptierte Risiken', 'evidence_proof' => 'Evidence-Nachweis', 'evidence_status' => 'Nachweise', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 2848453d..500be982 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -354,13 +354,31 @@ 'ready_to_share_reason' => 'The released review, evidence path, and current review pack are available for customer-safe handoff.', 'shareable_with_follow_up_reason' => 'The review pack is available, but accepted-risk follow-up must be called out before handoff.', 'follow_up_required_before_sharing_reason' => 'Review proof or package availability still needs attention before this can be shared.', + 'findings_follow_up_required_reason' => ':summary Keep open findings visible before customer handoff.', + 'accepted_risk_follow_up_required_reason' => 'Accepted-risk follow-up is recorded for this review. Review the owner, rationale, and review date before handoff.', 'ready_to_share_impact' => 'Stakeholders can use the current review pack and released review as the evidence path.', 'shareable_with_follow_up_impact' => 'Use the current pack only with the accepted-risk follow-up included in the customer handoff.', 'follow_up_required_before_sharing_impact' => 'Do not treat this review as share-ready until the unavailable proof has been reviewed.', + 'findings_follow_up_required_impact' => 'Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.', + 'accepted_risk_follow_up_required_impact' => 'The pack can be shared only with the accepted-risk context included in the customer handoff.', 'impact' => 'Impact', 'scope' => 'Scope', + 'ready' => 'Ready', + 'needs_review' => 'Needs review', + 'not_ready' => 'Not ready', 'readiness' => 'Readiness', 'evidence' => 'Evidence', + 'review_consumption_flow' => 'Review consumption flow', + 'review_consumption_flow_description' => 'Follow the derived review, evidence, findings, accepted-risk, pack, and customer output states before sharing.', + 'review_data' => 'Review data', + 'review_data_available_description' => 'A released review is available for this customer-safe workspace.', + 'findings_triaged' => 'Findings triaged', + 'accepted_risks_reviewed' => 'Accepted risks reviewed', + 'customer_output' => 'Customer-safe output', + 'customer_output_ready_description' => 'Evidence and review-pack truth support customer-safe consumption.', + 'customer_output_needs_review_description' => 'Output exists, but attention items must stay visible during handoff.', + 'customer_output_not_ready_description' => 'Customer-safe output is not ready from the current proof state.', + 'current_attention_point' => 'Current attention point', 'readiness_dimension_description' => 'Readiness is derived from the released review, evidence, accepted-risk, and review-pack state.', 'readiness_dimension_ready_description' => 'Released review is available.', 'readiness_dimension_follow_up_description' => 'Follow-up required before handoff.', @@ -384,6 +402,7 @@ 'accepted_risk_records_description' => 'Accepted-risk decisions are present in the released review evidence basis.', 'operation_proof' => 'Operation proof', 'operation_proof_available_description' => 'A related operation record exists for this review evidence path.', + 'operation_proof_available_with_initiator_description' => 'A related operation record exists for this review evidence path. Initiated by :initiator.', 'operation_proof_unavailable' => 'No operation proof linked', 'operation_proof_unavailable_description' => 'No operation proof link is attached to this released review path.', 'export_artifact' => 'Export artifact', @@ -403,6 +422,12 @@ 'diagnostics_customer_workspace_default_hidden' => 'Support details stay on authorized diagnostic surfaces and are not shown in this customer-safe workspace by default.', 'accepted_risk_summary' => 'Accepted risks', 'accepted_risk_no_action_needed' => 'No action needed', + 'accepted_risk_accountability' => 'Accepted-risk accountability', + 'accepted_risk_owner' => 'Owner', + 'accepted_risk_next_review' => 'Next review', + 'accepted_risk_rationale' => 'Rationale', + 'review_date_not_recorded' => 'Review date not recorded', + 'not_recorded' => 'Not recorded', 'accepted_risks_expiring_soon' => 'Expiring soon', 'accepted_risks_expired' => 'Expired', 'accepted_risks_pending_approval' => 'Pending approval', @@ -443,6 +468,16 @@ 'control_recommendation_unmapped' => 'Review unmapped evidence before customer delivery.', 'proof_access_state' => 'Proof access', 'key_findings' => 'Key findings', + 'findings_needing_attention' => 'Findings needing attention', + 'findings_total' => 'Total findings', + 'findings_open' => 'Open findings', + 'findings_high_impact' => 'High impact', + 'findings_high_impact_summary' => ':open; :high.', + 'findings_open_attention_count' => '{1} 1 open finding needs attention|[2,*] :count open findings need attention', + 'findings_high_impact_count_summary' => '{1} 1 is high impact|[2,*] :count are high impact', + 'findings_open_summary' => '{1} 1 open finding needs customer or operator review.|[2,*] :count open findings need customer or operator review.', + 'findings_no_open_summary' => ':total findings are recorded, with no open customer-action findings.', + 'findings_none_action_summary' => 'No open findings require customer action.', 'accepted_risks' => 'Accepted risks', 'evidence_proof' => 'Evidence proof', 'evidence_status' => 'Evidence', diff --git a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php index 83f25066..6acd12f2 100644 --- a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php +++ b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php @@ -37,7 +37,8 @@ $latest = $reviewPayload['latest']; $scope = $reviewPayload['scope']; $readiness = $reviewPayload['readiness']; - $dimensions = $reviewPayload['readiness_dimensions']; + $readinessFlow = $reviewPayload['readiness_flow']; + $findingPanel = $reviewPayload['finding_panel']; $asideEvidencePath = $reviewPayload['aside_evidence_path']; $reviewPackPanel = $reviewPayload['review_pack_panel']; $acceptedRisks = $reviewPayload['accepted_risks']; @@ -102,6 +103,7 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit :href="$readiness['primary_action_url']" :icon="$readiness['primary_action_icon']" target="_blank" + data-testid="customer-review-primary-action" > {{ $readiness['primary_action_label'] }} @@ -113,6 +115,7 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit :href="$latest['secondary_action_url']" color="gray" :icon="$latest['secondary_action_icon']" + data-testid="customer-review-secondary-action" > {{ $latest['secondary_action_label'] }} @@ -121,24 +124,81 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit -
- @foreach ($dimensions as $dimension) -
-
-
- {{ $dimension['title'] }} +
+
+
+

+ {{ __('localization.review.review_consumption_flow') }} +

+

+ {{ __('localization.review.review_consumption_flow_description') }} +

+
+ +
+ @foreach ($readinessFlow as $step) +
+
+
+ {{ $step['title'] }} +
+ + {{ $step['label'] }} + +
+

+ {{ $step['description'] }} +

+ @if ($step['is_current']) +
+ {{ __('localization.review.current_attention_point') }} +
+ @endif
-
- - {{ $dimension['label'] }} - -
-

- {{ $dimension['description'] }} + @endforeach +

+
+
+ +
+
+
+
+

+ {{ __('localization.review.findings_needing_attention') }} +

+

+ {{ $findingPanel['summary'] }}

+ + {{ $findingPanel['status_label'] }} +
- @endforeach + +
+ @foreach ($findingPanel['items'] as $item) +
+ {{ $item['label'] }} + + {{ $item['value'] }} + +
+ @endforeach +
+
@@ -189,7 +249,10 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit