File manager - Edit - /var/www/html/portal/app/Http/Resources/Accounts/Cronogramas/CronogramasResource.php
Back
<?php namespace App\Http\Resources\Accounts\Cronogramas; use App\Enums\Accounts\Cronogramas\CronogramaActivityStatus; use App\Enums\Accounts\Cronogramas\CronogramaProjectScopeType; use App\Enums\Accounts\Cronogramas\CronogramaProjectStatus; use App\Enums\Accounts\Cronogramas\CronogramaResponsibleType; use App\Models\Accounts\Cronogramas\Activity; use App\Models\Accounts\Cronogramas\ActivityResponsible; use App\Models\Accounts\Cronogramas\Attachment; use App\Models\Accounts\Cronogramas\HistoryLog; use App\Models\Accounts\Cronogramas\Project; use App\Models\Empresa; use App\Models\Structure; use App\Models\UnidadeOrganizacional; use App\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\StreamedResponse; /** * Regras de negócio, indicadores e utilidades do módulo Cronogramas. */ class CronogramasResource { public const STORAGE_DIRECTORY = 'cronogramas'; public const EMPTY_LABEL = '-'; /** Cache por request: evita chamar listaPermissoes() repetidamente para o mesmo usuário. */ private static array $permissionsCache = []; /** @var array<string, array{structure_id: ?string, unidade_ids: list<string>, empresa_ids: list<string>}> */ private static array $userScopeContextCache = []; private function userPermissions(User $user): \Illuminate\Support\Collection { if (! isset(self::$permissionsCache[$user->id])) { self::$permissionsCache[$user->id] = collect($user->listaPermissoes()); } return self::$permissionsCache[$user->id]; } /** * Administradores globais enxergam todos os projetos, inclusive os sem escopo definido. */ public function isGlobalCronogramasAdmin(User $user): bool { return $this->userPermissions($user)->contains('SYSADMIN'); } public function canViewAllProjects(User $user): bool { return $this->isGlobalCronogramasAdmin($user); } /** * Contexto organizacional do usuário para filtro de escopo (cache por request). * * @return array{structure_id: ?string, unidade_ids: list<string>, empresa_ids: list<string>} */ public function userScopeContext(User $user): array { if (isset(self::$userScopeContextCache[$user->id])) { return self::$userScopeContextCache[$user->id]; } if (! $user->relationLoaded('unidades')) { $user->load('unidades:id'); } if (! $user->relationLoaded('empresas')) { $user->load('empresas:id'); } self::$userScopeContextCache[$user->id] = [ 'structure_id' => $user->structure_id ?: null, 'unidade_ids' => $user->unidades->pluck('id')->filter()->values()->all(), 'empresa_ids' => $user->empresas->pluck('id')->filter()->values()->all(), ]; return self::$userScopeContextCache[$user->id]; } public function projectHasScopes(Project $project): bool { if ($project->relationLoaded('scopes')) { return $project->scopes->isNotEmpty(); } return $project->scopes()->exists(); } public function userMatchesProjectScope(User $user, Project $project): bool { $ctx = $this->userScopeContext($user); if ($project->relationLoaded('scopes')) { foreach ($project->scopes as $scope) { if ($this->scopeMatchesUser($scope->scope_type, $scope->scope_id, $ctx)) { return true; } } return false; } return $project->scopes() ->where(function (Builder $query) use ($ctx) { $this->applyUserScopeMatchConditions($query, $ctx); }) ->exists(); } /** * Primeira camada de autorização: escopo organizacional (OR entre tipos). * Projetos sem escopo só são visíveis a administradores globais. */ public function canManageProject(User $user, Project $project): bool { if ($this->isGlobalCronogramasAdmin($user)) { return true; } if (! $this->projectHasScopes($project)) { return false; } return $this->userMatchesProjectScope($user, $project); } /** * Restringe a listagem de projetos ao escopo organizacional do usuário. * * @param Builder<Project> $query */ public function applyProjectScopeFilter(Builder $query, User $user): void { if ($this->isGlobalCronogramasAdmin($user)) { return; } $ctx = $this->userScopeContext($user); $query->whereHas('scopes', function (Builder $scopeQuery) use ($ctx) { $this->applyUserScopeMatchConditions($scopeQuery, $ctx); }); } /** * @param array{structure_id: ?string, unidade_ids: list<string>, empresa_ids: list<string>} $ctx */ private function applyUserScopeMatchConditions(Builder $query, array $ctx): void { $query->where(function (Builder $matchQuery) use ($ctx) { $hasCondition = false; if ($ctx['structure_id']) { $hasCondition = true; $matchQuery->orWhere(function (Builder $sub) use ($ctx) { $sub->where('scope_type', CronogramaProjectScopeType::Structure->value) ->where('scope_id', $ctx['structure_id']); }); } if ($ctx['unidade_ids'] !== []) { $hasCondition = true; $matchQuery->orWhere(function (Builder $sub) use ($ctx) { $sub->where('scope_type', CronogramaProjectScopeType::Unidade->value) ->whereIn('scope_id', $ctx['unidade_ids']); }); } if ($ctx['empresa_ids'] !== []) { $hasCondition = true; $matchQuery->orWhere(function (Builder $sub) use ($ctx) { $sub->where('scope_type', CronogramaProjectScopeType::Empresa->value) ->whereIn('scope_id', $ctx['empresa_ids']); }); } if (! $hasCondition) { $matchQuery->whereRaw('0 = 1'); } }); } /** * @param array{structure_id: ?string, unidade_ids: list<string>, empresa_ids: list<string>} $ctx */ private function scopeMatchesUser( CronogramaProjectScopeType|string $scopeType, string $scopeId, array $ctx, ): bool { $type = $scopeType instanceof CronogramaProjectScopeType ? $scopeType : CronogramaProjectScopeType::tryFrom((string) $scopeType); return match ($type) { CronogramaProjectScopeType::Structure => $ctx['structure_id'] !== null && $ctx['structure_id'] === $scopeId, CronogramaProjectScopeType::Unidade => in_array($scopeId, $ctx['unidade_ids'], true), CronogramaProjectScopeType::Empresa => in_array($scopeId, $ctx['empresa_ids'], true), default => false, }; } public function assertProjectWritable(Project $project): void { if ($project->isReadOnly()) { throw ValidationException::withMessages([ 'project' => __('Projeto encerrado ou arquivado: somente leitura.'), ]); } } /** * RN05: "Atrasado" é um estado derivado da data (ver Activity::isOverdue()), * exibido somente na interface. O status persistido reflete sempre a escolha * do usuário; valores legados gravados como "overdue" são normalizados para * "Em Andamento". */ public function resolveActivityStatus(Activity $activity): string { if ($activity->status === CronogramaActivityStatus::Overdue->value) { return CronogramaActivityStatus::InProgress->value; } return $activity->status; } /** * Percentual padrão configurado para o status informado. */ public function defaultCompletionPercentForStatus(string $status): ?float { return CronogramaActivityStatus::completionPercentFor($status); } /** * Ajusta o percentual da atividade conforme mudança de status (Kanban / inline / formulário). */ public function applyCompletionPercentForStatusChange( Activity $activity, ?string $previousStatus, bool $explicitPercentProvided, ): void { if ($explicitPercentProvided || $previousStatus === $activity->status) { return; } $percent = $this->defaultCompletionPercentForStatus($activity->status); if ($percent !== null) { $activity->completion_percent = $percent; } } /** * Contagens de atividades usadas pelos indicadores do cabeçalho e colunas do Kanban. * Usa GROUP BY em vez de carregar todas as linhas para PHP. * * @return array{completed_count: int, overdue_count: int, status_counts: array<string, int>} */ public function projectStats(Project $project): array { $rows = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->selectRaw('status, COUNT(*) as cnt') ->groupBy('status') ->get(); $statusCounts = []; $completedCount = 0; foreach ($rows as $row) { $statusCounts[$row->status] = (int) $row->cnt; if ($row->status === CronogramaActivityStatus::Completed->value) { $completedCount = (int) $row->cnt; } } // "Atrasado" é derivado da data, não do status persistido. $overdueCount = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->whereNotIn('status', [ CronogramaActivityStatus::Completed->value, CronogramaActivityStatus::Cancelled->value, ]) ->whereNotNull('planned_end_date') ->whereDate('planned_end_date', '<', now()->toDateString()) ->count(); $totalCount = array_sum($statusCounts); return [ 'total_count' => $totalCount, 'completed_count' => $completedCount, 'overdue_count' => $overdueCount, 'status_counts' => $statusCounts, ]; } /** * RN02: progresso do projeto pela média das atividades de nível raiz. * Usa AVG() no banco em vez de trazer todas as linhas para PHP. */ public function recalculateProjectCompletion(Project $project): void { $avg = Activity::query() ->where('project_id', $project->id) ->whereNull('parent_id') ->whereNull('deleted_at') ->avg('completion_percent'); $project->completion_percent = round((float) ($avg ?? 0), 2); $project->save(); } /** * @param array<string, mixed> $old * @param array<string, mixed> $new */ public function logHistory( ?string $userId, ?string $projectId, ?string $activityId, string $action, ?array $old = null, ?array $new = null, ): void { HistoryLog::create([ 'user_id' => $userId, 'project_id' => $projectId, 'activity_id' => $activityId, 'action' => $action, 'old_value' => $old, 'new_value' => $new, ]); } /** * Indicadores do dashboard - usa queries agregadas em vez de carregar coleções. * * @return array<string, int|float> */ public function dashboardIndicators(User $user): array { $today = now()->toDateString(); // Projetos - stats e overdue $projectStatsQuery = Project::query()->whereNull('deleted_at'); $this->applyProjectScopeFilter($projectStatsQuery, $user); $projectStats = (clone $projectStatsQuery) ->selectRaw(' COUNT(*) as total, SUM(status = ?) as active, SUM(status = ?) as completed, ROUND(AVG(completion_percent), 2) as avg_completion ', [ CronogramaProjectStatus::Active->value, CronogramaProjectStatus::Completed->value, ]) ->first(); $overdueProjects = (clone $projectStatsQuery) ->where('status', CronogramaProjectStatus::Active->value) ->whereNotNull('planned_end_date') ->whereDate('planned_end_date', '<', $today) ->where('completion_percent', '<', 100) ->count(); // Atividades - CASE/WHEN agregado $activityStats = Activity::query() ->whereNull('deleted_at') ->whereHas('project', function ($q) use ($user) { $q->whereNull('deleted_at'); $this->applyProjectScopeFilter($q, $user); }) ->selectRaw(' SUM(CASE WHEN status NOT IN (?, ?) THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = ? OR ( status NOT IN (?, ?) AND planned_end_date IS NOT NULL AND DATE(planned_end_date) < ? ) THEN 1 ELSE 0 END) as critical ', [ CronogramaActivityStatus::Completed->value, CronogramaActivityStatus::Cancelled->value, CronogramaActivityStatus::Overdue->value, CronogramaActivityStatus::Completed->value, CronogramaActivityStatus::Cancelled->value, $today, ]) ->first(); return [ 'total_projects' => (int) ($projectStats->total ?? 0), 'active_projects' => (int) ($projectStats->active ?? 0), 'completed_projects' => (int) ($projectStats->completed ?? 0), 'overdue_projects' => $overdueProjects, 'avg_completion' => (float) ($projectStats->avg_completion ?? 0), 'pending_activities' => (int) ($activityStats->pending ?? 0), 'critical_activities' => (int) ($activityStats->critical ?? 0), ]; } /** * Retorna atividades ativas e sem órfãs (pai excluído). * * @param Collection<int, Activity> $activities * @return array{activities: Collection<int, Activity>, byId: Collection<string, Activity>} */ public function filterActiveActivities(Collection $activities): array { $active = $activities->filter(fn (Activity $a) => $a->deleted_at === null); $byId = $active->keyBy('id'); $filtered = $active->filter( fn (Activity $a) => $a->parent_id === null || $byId->has($a->parent_id) )->values(); return ['activities' => $filtered, 'byId' => $byId]; } /** * Payload unificado de atividades para todas as abas (tabela, Gantt, Kanban, timeline). * * @return array{table_rows: list<array<string, mixed>>, gantt: list<array<string, mixed>>} */ public function activitiesViewPayload(Collection $activities): array { ['activities' => $filtered, 'byId' => $byId] = $this->filterActiveActivities($activities); return [ 'table_rows' => $filtered ->map(fn (Activity $activity) => $this->activityToPayload($activity, $byId)) ->values() ->all(), 'gantt' => $this->activitiesToGanttData($activities), ]; } /** * Serializa uma atividade para uso na tabela e nos formulários (modal de edição). * * @param Collection<int, Activity>|null $byId índice opcional para resolver códigos de predecessoras * @return array<string, mixed> */ public function activityToPayload(Activity $activity, ?Collection $byId = null): array { $predecessorIds = $activity->predecessors ->filter(function ($d) use ($byId) { if ($d->relationLoaded('predecessor') && $d->predecessor?->deleted_at !== null) { return false; } return $byId === null || $byId->has($d->predecessor_id); }) ->pluck('predecessor_id') ->values() ->all(); $predecessorCodes = $activity->predecessors ->filter(function ($d) use ($byId) { if ($d->relationLoaded('predecessor') && $d->predecessor?->deleted_at !== null) { return false; } return $byId === null || $byId->has($d->predecessor_id); }) ->map(function ($d) use ($byId) { if ($d->relationLoaded('predecessor') && $d->predecessor) { return $d->predecessor->code; } return $byId?->get($d->predecessor_id)?->code; }) ->filter() ->values() ->implode(', '); return [ 'id' => $activity->id, 'code' => $activity->code, 'title' => $activity->title, 'description' => $activity->description, 'parent_id' => $activity->parent_id, 'predecessor_ids' => $predecessorIds, 'predecessors' => $predecessorCodes, 'responsible' => $this->responsibleLabel($activity), 'responsible_labels' => $this->responsibleLabels($activity), 'responsibles' => $this->responsiblesPayload($activity), 'status' => $activity->status, 'status_label' => CronogramaActivityStatus::tryFrom($activity->status)?->label() ?? $activity->status, 'completion_percent' => (float) $activity->completion_percent, 'planned_start_date' => $activity->planned_start_date?->format('Y-m-d'), 'planned_end_date' => $activity->planned_end_date?->format('Y-m-d'), 'actual_start_date' => $activity->actual_start_date?->format('Y-m-d'), 'actual_end_date' => $activity->actual_end_date?->format('Y-m-d'), 'notes' => $activity->notes, 'sort_order' => $activity->sort_order, 'is_overdue' => $activity->isOverdue(), 'attachments_count' => $activity->relationLoaded('attachments') ? $activity->attachments->whereNull('deleted_at')->count() : 0, 'comments_count' => $activity->relationLoaded('comments') ? $activity->comments->whereNull('deleted_at')->count() : 0, ]; } /** * @param Collection<int, Activity> $activities * @return list<array<string, mixed>> */ public function activitiesToTableRows(Collection $activities, ?Collection $indexById = null): array { if ($indexById === null) { return $this->activitiesViewPayload($activities)['table_rows']; } // Caminho paginado: a coleção recebida é apenas a página atual. Usamos um // índice global (todas as atividades ativas do projeto) para resolver pais e // predecessoras, evitando descartar subatividades cujo pai está em outra página. return $activities ->filter(fn (Activity $a) => $a->deleted_at === null) ->filter(fn (Activity $a) => $a->parent_id === null || $indexById->has($a->parent_id)) ->map(fn (Activity $a) => $this->activityToPayload($a, $indexById)) ->values() ->all(); } /** * Detalhe de atividade para o offcanvas (comentários e histórico paginados). * * @return array<string, mixed> */ public function activityDetailPayload( Activity $activity, LengthAwarePaginator $comments, LengthAwarePaginator $history, ): array { $attachments = $activity->attachments ->whereNull('deleted_at') ->map(fn ($a) => [ 'id' => $a->id, 'name' => $a->original_name, 'size' => $this->formatBytes((int) $a->file_size_bytes), 'uploaded_by' => $a->uploader?->firstName, ]) ->values() ->all(); return [ 'activity' => $this->activityToPayload($activity), 'comments' => $comments->getCollection()->map(fn ($c) => [ 'id' => $c->id, 'content' => $c->content, 'author' => $c->user?->firstName, 'author_id' => $c->user_id, 'created_at' => $c->created_at?->format('d/m/Y H:i'), ])->values()->all(), 'comments_pagination' => $this->paginationMeta($comments), 'attachments' => $attachments, 'history' => $history->getCollection()->map(fn ($h) => $this->formatHistoryEntry($h))->values()->all(), 'history_pagination' => $this->paginationMeta($history), ]; } /** * @return array{current_page: int, last_page: int, total: int, per_page: int} */ public function paginationMeta(LengthAwarePaginator $paginator): array { return [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'total' => $paginator->total(), 'per_page' => $paginator->perPage(), ]; } /** * Rótulo amigável para ações do histórico. */ public function historyActionLabel(string $action): string { return match ($action) { 'project_create' => __('Projeto criado'), 'project_update' => __('Projeto atualizado'), 'project_archive' => __('Projeto arquivado'), 'project_close' => __('Projeto encerrado'), 'project_delete' => __('Projeto excluído'), 'activity_create' => __('Atividade criada'), 'activity_update' => __('Atividade atualizada'), 'activity_duplicate' => __('Atividade duplicada'), 'activity_archive' => __('Atividade arquivada'), 'activity_restore' => __('Atividade restaurada'), 'activity_delete' => __('Atividade excluída permanentemente'), 'activities_reorder' => __('Atividades reordenadas'), 'comment_create' => __('Comentário adicionado'), 'comment_delete' => __('Comentário removido'), 'attachments_upload' => __('Anexo enviado'), 'attachment_delete' => __('Anexo removido'), 'public_share_enable' => __('Visualização pública ativada'), 'public_share_disable' => __('Visualização pública desativada'), 'public_share_regenerate' => __('Link público regenerado'), default => $action, }; } /** * Nomes dos responsáveis (um por vínculo), sem siglas. * * @return list<string> */ public function responsibleLabels(Activity $activity): array { $activity->loadMissing('responsibles.responsible'); return $activity->responsibles ->map(fn (ActivityResponsible $r) => $this->responsibleEntityLabel($r->responsible_type, $r->responsible)) ->filter() ->values() ->all(); } /** * Rótulo agregado dos responsáveis (pode haver mais de um por atividade). */ public function responsibleLabel(Activity $activity): string { $labels = $this->responsibleLabels($activity); return $labels !== [] ? implode(', ', $labels) : self::EMPTY_LABEL; } /** * Rótulo de uma única entidade responsável conforme o tipo (somente nome, sem sigla). */ public function responsibleEntityLabel(?string $type, mixed $entity): ?string { if ($entity === null) { return null; } $resolved = CronogramaResponsibleType::tryFrom((string) $type); $label = match ($resolved) { CronogramaResponsibleType::Empresa => trim( (string) ($entity->razaoSocial ?: $entity->nomeFantasia ?: $entity->cnpj ?: '') ) ?: null, CronogramaResponsibleType::Structure => trim((string) ($entity->name ?? '')) ?: null, CronogramaResponsibleType::Unidade => trim((string) ($entity->name ?? '')) ?: null, default => trim( ($entity->firstName ?? '') . ' ' . ($entity->lastName ?? '') ) ?: null, }; return $label !== null ? $this->normalizeResponsibleDisplayName($label) : null; } /** * Corrige nomes canônicos conhecidos de responsáveis exibidos no módulo. */ private function normalizeResponsibleDisplayName(string $name): string { $trimmed = trim($name); return match ($trimmed) { 'Secretaria de Tecnológica', 'Secretaria Tecnológica' => 'Secretaria de Tecnologia', default => $trimmed, }; } /** * Lista estruturada de responsáveis para o payload (prefill do formulário). * * @return list<array{type: string, id: string, label: string}> */ public function responsiblesPayload(Activity $activity): array { $activity->loadMissing('responsibles.responsible'); return $activity->responsibles ->map(function (ActivityResponsible $r): ?array { $label = $this->responsibleEntityLabel($r->responsible_type, $r->responsible); if ($label === null) { return null; } return [ 'type' => $r->responsible_type, 'id' => $r->responsible_id, 'label' => $label, ]; }) ->filter() ->values() ->all(); } /** * Substitui os responsáveis da atividade pelos informados (sincronização total). * * @param array<int, array<string, mixed>>|null $responsibles lista de {type, id} */ public function syncActivityResponsibles(Activity $activity, ?array $responsibles): void { $normalized = $this->normalizeResponsiblesInput($responsibles); ActivityResponsible::query()->where('activity_id', $activity->id)->delete(); if ($normalized === []) { $activity->setRelation('responsibles', collect()); return; } $now = now(); $rows = array_map(static fn (array $pair): array => [ 'id' => (string) Str::uuid(), 'activity_id' => $activity->id, 'responsible_type' => $pair['type'], 'responsible_id' => $pair['id'], 'created_at' => $now, 'updated_at' => $now, ], $normalized); ActivityResponsible::insert($rows); $activity->load('responsibles.responsible'); } /** * Normaliza e deduplica a lista de responsáveis informada. * * @param array<int, array<string, mixed>>|null $responsibles * @return list<array{type: string, id: string}> */ private function normalizeResponsiblesInput(?array $responsibles): array { $out = []; $seen = []; foreach ((array) $responsibles as $item) { if (! is_array($item)) { continue; } $id = $item['id'] ?? null; $type = $item['type'] ?? null; if (empty($id) || $type === null) { continue; } $resolved = CronogramaResponsibleType::tryFrom((string) $type); if ($resolved === null) { continue; } $key = $resolved->value . '|' . $id; if (isset($seen[$key])) { continue; } $seen[$key] = true; $out[] = ['type' => $resolved->value, 'id' => (string) $id]; } return $out; } public function formatBytes(int $bytes): string { if ($bytes <= 0) { return '0 B'; } $units = ['B', 'KB', 'MB', 'GB']; $i = (int) floor(log($bytes, 1024)); $i = min($i, count($units) - 1); return round($bytes / (1024 ** $i), 1) . ' ' . $units[$i]; } /** * @return list<array<string, mixed>> */ public function activitiesToGanttData(Collection $activities): array { ['activities' => $filtered, 'byId' => $byId] = $this->filterActiveActivities($activities); return $filtered->map(function (Activity $a) use ($byId) { $start = $a->planned_start_date ?? $a->actual_start_date; $end = $a->planned_end_date ?? $a->actual_end_date; if ($start && $end && $end->lt($start)) { $end = $start; } $responsibleLabels = $this->responsibleLabels($a); $responsibleLabel = $responsibleLabels !== [] ? implode(', ', $responsibleLabels) : self::EMPTY_LABEL; return [ 'id' => $a->id, 'code' => $a->code, 'title' => $a->title, 'parent_id' => $a->parent_id, 'responsible' => $responsibleLabel !== self::EMPTY_LABEL ? $responsibleLabel : null, 'responsible_labels' => $responsibleLabels, 'start' => $start?->format('Y-m-d'), 'end' => $end?->format('Y-m-d'), 'progress' => (float) $a->completion_percent, 'status' => $a->status, 'overdue' => $a->isOverdue(), 'dependencies' => $a->predecessors ->pluck('predecessor_id') ->filter(fn ($id) => $byId->has($id)) ->values() ->all(), ]; })->values()->all(); } /** * @param Collection<int, Activity> $activities * @return list<array<string, mixed>> */ public function archivedActivitiesToTableRows(Collection $activities): array { return $activities ->map(function (Activity $activity) { $row = $this->activityToPayload($activity); $row['archived_at'] = $activity->deleted_at?->format('d/m/Y H:i'); return $row; }) ->values() ->all(); } /** * Snapshot legível de uma atividade para o histórico. * * @return array<string, mixed> */ public function activitySnapshotForHistory(Activity $activity): array { return [ 'code' => $activity->code, 'title' => $activity->title, 'status' => CronogramaActivityStatus::tryFrom($activity->status)?->label() ?? $activity->status, 'completion_percent' => (float) $activity->completion_percent, 'planned_start_date' => $activity->planned_start_date?->format('d/m/Y'), 'planned_end_date' => $activity->planned_end_date?->format('d/m/Y'), 'responsible' => $this->responsibleLabel($activity) !== self::EMPTY_LABEL ? $this->responsibleLabel($activity) : null, ]; } /** * @return array{action: string, user: string, created_at: string|null, details: string} */ public function formatHistoryEntry(HistoryLog $log): array { $userName = $log->user ? trim(($log->user->firstName ?? '') . ' ' . ($log->user->lastName ?? '')) : __('Sistema'); return [ 'action' => $this->historyActionLabel($log->action), 'user' => $userName !== '' ? $userName : __('Sistema'), 'created_at' => $log->created_at?->format('d/m/Y H:i'), 'details' => $this->historyEntryDetails($log), ]; } private function historyEntryDetails(HistoryLog $log): string { $old = is_array($log->old_value) ? $log->old_value : []; $new = is_array($log->new_value) ? $log->new_value : []; return match ($log->action) { 'activity_create' => $this->formatActivitySnapshotText($new), 'activity_update' => $this->formatActivityChangesText($old, $new), 'activity_archive' => $this->formatActivitySnapshotText($old) . ($new['cascade'] ?? false ? ' ' . __('(inclui subatividades)') : ''), 'activity_restore' => $this->formatActivitySnapshotText($new), 'activity_delete' => __('Exclusão definitiva.') . ' ' . $this->formatActivitySnapshotText($old), 'activity_duplicate' => __('Origem: :code - :title', [ 'code' => $old['code'] ?? self::EMPTY_LABEL, 'title' => $old['title'] ?? self::EMPTY_LABEL, ]) . '. ' . __('Cópia: :code - :title', [ 'code' => $new['code'] ?? self::EMPTY_LABEL, 'title' => $new['title'] ?? self::EMPTY_LABEL, ]), 'comment_create' => __('Comentário: :text', [ 'text' => Str::limit((string) ($new['content'] ?? ''), 300), ]), 'comment_delete' => __('Comentário removido: :text', [ 'text' => Str::limit((string) ($old['content'] ?? ''), 300), ]), 'attachments_upload' => __(':count arquivo(s): :files', [ 'count' => (int) ($new['count'] ?? 0), 'files' => Str::limit(implode(', ', (array) ($new['files'] ?? [])), 200), ]), 'attachment_delete' => __('Arquivo: :name (:size)', [ 'name' => $old['name'] ?? $new['name'] ?? self::EMPTY_LABEL, 'size' => $old['size'] ?? '', ]), 'activities_reorder' => __('Nova ordem de :count atividade(s).', [ 'count' => count((array) ($new['order'] ?? [])), ]), default => '', }; } /** * @param array<string, mixed> $data */ private function formatActivitySnapshotText(array $data): string { if ($data === []) { return ''; } $parts = []; if (! empty($data['code']) || ! empty($data['title'])) { $parts[] = trim(($data['code'] ?? '') . ' - ' . ($data['title'] ?? '')); } foreach ([ 'status' => __('Status'), 'completion_percent' => __('Progresso'), 'planned_start_date' => __('Início'), 'planned_end_date' => __('Término'), 'responsible' => __('Responsável'), ] as $key => $label) { if (! array_key_exists($key, $data) || $data[$key] === null || $data[$key] === '') { continue; } $value = $key === 'completion_percent' ? ((int) $data[$key]) . '%' : (string) $data[$key]; $parts[] = $label . ': ' . $value; } if (! empty($data['description'])) { $parts[] = __('Descrição') . ': ' . Str::limit((string) $data['description'], 120); } if (! empty($data['notes'])) { $parts[] = __('Observações') . ': ' . Str::limit((string) $data['notes'], 120); } return implode(' · ', array_filter($parts)); } /** * @param array<string, mixed> $old * @param array<string, mixed> $new */ private function formatActivityChangesText(array $old, array $new): string { $labels = [ 'code' => __('Código'), 'title' => __('Título'), 'description' => __('Descrição'), 'status' => __('Status'), 'completion_percent' => __('Progresso'), 'planned_start_date' => __('Início'), 'planned_end_date' => __('Término'), 'notes' => __('Observações'), 'parent_id' => __('Atividade pai'), 'responsibles' => __('Responsáveis'), ]; $changes = []; foreach ($labels as $field => $label) { if (! array_key_exists($field, $new)) { continue; } $oldVal = $this->normalizeHistoryFieldValue($field, $old[$field] ?? null); $newVal = $this->normalizeHistoryFieldValue($field, $new[$field] ?? null); if ($oldVal === $newVal) { continue; } $changes[] = $label . ': ' . ($oldVal ?: self::EMPTY_LABEL) . ' -> ' . ($newVal ?: self::EMPTY_LABEL); } if ($changes === []) { return __('Registro atualizado.'); } return implode(' · ', $changes); } private function normalizeHistoryFieldValue(string $field, mixed $value): string { if ($value === null || $value === '') { return ''; } if ($field === 'status') { return CronogramaActivityStatus::tryFrom((string) $value)?->label() ?? (string) $value; } if ($field === 'completion_percent') { return ((int) $value) . '%'; } if (in_array($field, ['planned_start_date', 'planned_end_date', 'actual_start_date', 'actual_end_date'], true)) { try { return Carbon::parse((string) $value)->format('d/m/Y'); } catch (\Throwable) { return (string) $value; } } if (in_array($field, ['description', 'notes'], true)) { return Str::limit((string) $value, 80); } return (string) $value; } /** * Filtros e página da listagem do dashboard (persistência ao navegar e voltar). * * @return array<string, mixed> */ public function dashboardListParams(Request $request): array { return $this->filterListParams($request, [ 'q', 'status', 'date_from', 'date_to', 'sort', 'dir', 'page', 'tab', 'arp', ]); } /** * @param Collection<int, Project> $projects * @return list<array<string, mixed>> */ public function archivedProjectsToTableRows(Collection $projects): array { return $projects ->map(fn (Project $project) => [ 'id' => $project->id, 'name' => $project->name, 'status' => $project->status, 'status_label' => CronogramaProjectStatus::tryFrom($project->status)?->label() ?? $project->status, 'completion_percent' => (float) $project->completion_percent, 'start_date' => $project->start_date?->format('d/m/Y'), 'planned_end_date' => $project->planned_end_date?->format('d/m/Y'), 'activities_count' => (int) ($project->activities_count ?? 0), 'archived_at' => $project->updated_at?->format('d/m/Y H:i'), ]) ->values() ->all(); } /** * Parâmetros do detalhe do projeto: filtros do dashboard + aba + páginas das listas internas. * * @return array<string, mixed> */ public function projectShowListParams(Request $request): array { return $this->filterListParams($request, [ 'q', 'status', 'date_from', 'date_to', 'sort', 'dir', 'page', 'tab', 'ap', 'arp', 'tp', 'cp', 'hp', 'per_page', 'f_q', 'f_status', 'f_resp', 'f_overdue', 'f_start_from', 'f_start_to', 'f_end_from', 'f_end_to', 'f_prog_min', 'f_prog_max', ]); } /** * Chaves de filtro server-side da lista de atividades (usadas para persistência e limpeza). * * @return list<string> */ public function activityFilterKeys(): array { return [ 'f_q', 'f_status', 'f_resp', 'f_overdue', 'f_start_from', 'f_start_to', 'f_end_from', 'f_end_to', 'f_prog_min', 'f_prog_max', 'f_parent', 'f_pred', ]; } /** * Normaliza e whitelista os filtros server-side da lista de atividades. * * @return array{ * q: ?string, * status: ?string, * overdue: ?string, * resp_combined: ?string, * resp_type: ?string, * resp_id: ?string, * start_from: ?string, * start_to: ?string, * end_from: ?string, * end_to: ?string, * prog_min: ?int, * prog_max: ?int * } */ public function resolveActivityFilters(Request $request): array { $status = (string) $request->input('f_status', ''); $status = in_array($status, CronogramaActivityStatus::values(), true) ? $status : null; $overdue = (string) $request->input('f_overdue', ''); $overdue = in_array($overdue, ['yes', 'no'], true) ? $overdue : null; $hasParent = (string) $request->input('f_parent', ''); $hasParent = in_array($hasParent, ['yes', 'no'], true) ? $hasParent : null; $hasPred = (string) $request->input('f_pred', ''); $hasPred = in_array($hasPred, ['yes', 'no'], true) ? $hasPred : null; $respType = null; $respId = null; $resp = (string) $request->input('f_resp', ''); if ($resp !== '' && str_contains($resp, ':')) { [$type, $id] = explode(':', $resp, 2); $resolvedType = CronogramaResponsibleType::tryFrom($type); if ($resolvedType !== null && $id !== '') { $respType = $resolvedType->value; $respId = $id; } } $progMin = $this->normalizeProgressFilter($request->input('f_prog_min')); $progMax = $this->normalizeProgressFilter($request->input('f_prog_max')); if ($progMin !== null && $progMax !== null && $progMin > $progMax) { [$progMin, $progMax] = [$progMax, $progMin]; } return [ 'q' => $this->normalizeFilterText($request->input('f_q')), 'status' => $status, 'overdue' => $overdue, 'has_parent' => $hasParent, 'has_pred' => $hasPred, 'resp_combined' => ($respType !== null && $respId !== null) ? $respType . ':' . $respId : null, 'resp_type' => $respType, 'resp_id' => $respId, 'start_from' => $this->normalizeFilterDate($request->input('f_start_from')), 'start_to' => $this->normalizeFilterDate($request->input('f_start_to')), 'end_from' => $this->normalizeFilterDate($request->input('f_end_from')), 'end_to' => $this->normalizeFilterDate($request->input('f_end_to')), 'prog_min' => $progMin, 'prog_max' => $progMax, ]; } /** * Indica se há ao menos um filtro de atividade ativo. * * @param array<string, mixed> $filters */ public function activityFiltersActive(array $filters): bool { foreach (['q', 'status', 'overdue', 'has_parent', 'has_pred', 'resp_combined', 'start_from', 'start_to', 'end_from', 'end_to'] as $key) { if (! empty($filters[$key])) { return true; } } return ($filters['prog_min'] ?? null) !== null || ($filters['prog_max'] ?? null) !== null; } /** * Quantidade de filtros de atividade ativos (badge da UI). * * @param array<string, mixed> $filters */ public function activityFiltersCount(array $filters): int { $count = 0; foreach (['q', 'status', 'overdue', 'has_parent', 'has_pred', 'resp_combined', 'start_from', 'start_to', 'end_from', 'end_to'] as $key) { if (! empty($filters[$key])) { $count++; } } if (($filters['prog_min'] ?? null) !== null || ($filters['prog_max'] ?? null) !== null) { $count++; } return $count; } private function normalizeFilterText(mixed $value): ?string { if ($value === null) { return null; } $clean = trim(strip_tags((string) $value)); return $clean === '' ? null : mb_substr($clean, 0, 150); } private function normalizeFilterDate(mixed $value): ?string { if ($value === null || $value === '') { return null; } try { return Carbon::createFromFormat('Y-m-d', (string) $value)->format('Y-m-d'); } catch (\Throwable) { return null; } } /** * Indicadores derivados das linhas já filtradas (cabeçalho do PDF filtrado). * * @param list<array<string, mixed>> $rows * @return array{total_count: int, completed_count: int, overdue_count: int, status_counts: array<string, int>} */ public function statsFromTableRows(array $rows): array { $statusCounts = []; $completedCount = 0; $overdueCount = 0; foreach ($rows as $row) { $status = (string) ($row['status'] ?? ''); if ($status !== '') { $statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1; } if ($status === CronogramaActivityStatus::Completed->value) { $completedCount++; } if (! empty($row['is_overdue'])) { $overdueCount++; } } return [ 'total_count' => count($rows), 'completed_count' => $completedCount, 'overdue_count' => $overdueCount, 'status_counts' => $statusCounts, ]; } /** * Descrição legível dos filtros ativos (resumo no cabeçalho do PDF). * * @param array<string, mixed> $filters * @return list<string> */ public function activityFiltersSummary(array $filters): array { $parts = []; if (! empty($filters['q'])) { $parts[] = __('Busca') . ': "' . $filters['q'] . '"'; } if (! empty($filters['status'])) { $parts[] = __('Status') . ': ' . (CronogramaActivityStatus::tryFrom($filters['status'])?->label() ?? $filters['status']); } if (($filters['overdue'] ?? null) === 'yes') { $parts[] = __('Apenas atrasadas'); } elseif (($filters['overdue'] ?? null) === 'no') { $parts[] = __('Apenas no prazo'); } if (($filters['has_parent'] ?? null) === 'yes') { $parts[] = __('Com atividade pai (subatividade)'); } elseif (($filters['has_parent'] ?? null) === 'no') { $parts[] = __('Sem atividade pai (raiz)'); } if (($filters['has_pred'] ?? null) === 'yes') { $parts[] = __('Com predecessora'); } elseif (($filters['has_pred'] ?? null) === 'no') { $parts[] = __('Sem predecessora'); } if (! empty($filters['resp_type'])) { $parts[] = __('Responsável') . ': ' . (CronogramaResponsibleType::tryFrom($filters['resp_type'])?->label() ?? $filters['resp_type']); } if (! empty($filters['start_from']) || ! empty($filters['start_to'])) { $parts[] = __('Início') . ': ' . $this->dateRangeLabel($filters['start_from'] ?? null, $filters['start_to'] ?? null); } if (! empty($filters['end_from']) || ! empty($filters['end_to'])) { $parts[] = __('Término') . ': ' . $this->dateRangeLabel($filters['end_from'] ?? null, $filters['end_to'] ?? null); } if (($filters['prog_min'] ?? null) !== null || ($filters['prog_max'] ?? null) !== null) { $min = $filters['prog_min'] ?? 0; $max = $filters['prog_max'] ?? 100; $parts[] = __('Progresso') . ': ' . $min . '% - ' . $max . '%'; } return $parts; } private function dateRangeLabel(?string $from, ?string $to): string { $fmt = static function (?string $value): ?string { if ($value === null || $value === '') { return null; } try { return Carbon::parse($value)->format('d/m/Y'); } catch (\Throwable) { return null; } }; $fromLabel = $fmt($from); $toLabel = $fmt($to); if ($fromLabel && $toLabel) { return $fromLabel . ' - ' . $toLabel; } if ($fromLabel) { return __('a partir de :date', ['date' => $fromLabel]); } if ($toLabel) { return __('até :date', ['date' => $toLabel]); } return self::EMPTY_LABEL; } private function normalizeProgressFilter(mixed $value): ?int { if ($value === null || $value === '') { return null; } if (! is_numeric($value)) { return null; } return max(0, min(100, (int) $value)); } /** * Opções permitidas de registros por página (tabela e linha do tempo). * * @return list<int> */ public function perPageOptions(): array { $options = config('cronogramas.per_page_options', [20, 50, 100, 250, 500]); return array_values(array_filter( array_map(static fn ($value) => (int) $value, (array) $options), static fn (int $value) => $value > 0, )); } /** * Resolve quantidade de registros por página a partir da requisição (whitelist). */ public function resolveActivitiesPerPage(Request $request): int { $options = $this->perPageOptions(); $default = $options[0] ?? 20; $requested = (int) $request->input('per_page', $default); if (in_array($requested, $options, true)) { return $requested; } return $default; } /** * Opções de ordenação da tabela de atividades (whitelist + rótulos traduzíveis). * * @return array<string, string> */ public function activitySortOptions(): array { return [ 'manual' => __('Ordem manual'), 'chronological' => __('Ordem cronológica'), 'code' => __('Código'), 'title' => __('Título'), 'status' => __('Status'), 'progress' => __('Progresso'), 'start' => __('Início'), 'end' => __('Término'), 'updated' => __('Última modificação'), ]; } /** * Resolve campo e direção de ordenação a partir da requisição (whitelist). * * @return array{0: string, 1: string} [campo, direção] */ public function resolveActivitySort(Request $request): array { $allowed = array_keys($this->activitySortOptions()); $sort = (string) $request->input('sort', 'code'); if (! in_array($sort, $allowed, true)) { $sort = 'code'; } // Padrão da tabela: Código - Decrescente. "Última modificação" também // assume decrescente por padrão quando a direção não é informada. $defaultDir = in_array($sort, ['code', 'updated'], true) ? 'desc' : 'asc'; $dir = strtolower((string) $request->input('dir', $defaultDir)); $dir = $dir === 'desc' ? 'desc' : 'asc'; return [$sort, $dir]; } /** * @param list<string> $keys * @return array<string, mixed> */ public function filterListParams(Request $request, array $keys): array { $out = []; foreach ($keys as $key) { $val = $request->input($key); if ($val !== null && $val !== '') { $out[$key] = $val; } } return $out; } /** * Sanitiza campos de texto simples (sem HTML rico). */ public function sanitizePlainText(?string $value): ?string { if ($value === null) { return null; } $clean = trim(strip_tags($value)); return $clean === '' ? null : $clean; } /** * Gera código sequencial para nova atividade do projeto. */ public function generateActivityCode(Project $project): string { $count = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->count(); return 'ATV-' . str_pad((string) ($count + 1), 3, '0', STR_PAD_LEFT); } /** * @param list<string> $predecessorIds */ public function assertNoSelfPredecessor(Activity $activity, array $predecessorIds): void { foreach ($predecessorIds as $predId) { if ($predId === $activity->id) { throw ValidationException::withMessages([ 'predecessor_ids' => __('Uma atividade não pode ser predecessora de si mesma.'), ]); } } } /** * Normaliza, em lote, eventuais status legados gravados como "Atrasado" * para o status de trabalho correspondente. "Atrasado" passou a ser * derivado da data (somente exibição), nunca persistido. */ public function batchResolveOverdueStatuses(Project $project): void { $activities = Activity::query() ->where('project_id', $project->id) ->whereNull('deleted_at') ->get(['id', 'status', 'planned_end_date', 'completion_percent']); $toUpdate = []; foreach ($activities as $activity) { $resolved = $this->resolveActivityStatus($activity); if ($resolved !== $activity->status) { $toUpdate[] = ['id' => $activity->id, 'status' => $resolved]; } } if ($toUpdate === []) { return; } $now = now(); foreach ($toUpdate as $row) { Activity::query() ->where('id', $row['id']) ->update([ 'status' => $row['status'], 'updated_at' => $now, ]); } } /** * Coleta IDs de subatividades ativas em cascata. * * @return list<string> */ public function collectDescendantActivityIds(Project $project, string $parentId): array { $collected = []; $queue = [$parentId]; while ($queue !== []) { $currentId = array_shift($queue); $children = Activity::query() ->where('project_id', $project->id) ->where('parent_id', $currentId) ->whereNull('deleted_at') ->pluck('id') ->all(); foreach ($children as $childId) { $collected[] = $childId; $queue[] = $childId; } } return $collected; } /** * Coleta IDs de subatividades arquivadas em cascata. * * @return list<string> */ public function collectTrashedDescendantActivityIds(Project $project, string $parentId): array { $collected = []; $queue = [$parentId]; while ($queue !== []) { $currentId = array_shift($queue); $children = Activity::onlyTrashed() ->where('project_id', $project->id) ->where('parent_id', $currentId) ->pluck('id') ->all(); foreach ($children as $childId) { $collected[] = $childId; $queue[] = $childId; } } return $collected; } /** * Persiste arquivo validado no disco public do módulo. */ public function storeAttachmentFile(UploadedFile $file): string { $ext = strtolower($file->getClientOriginalExtension() ?: 'bin'); $key = self::STORAGE_DIRECTORY . '/' . Str::uuid() . '.' . $ext; Storage::disk('public')->put($key, file_get_contents($file->getRealPath())); return $key; } /** * Remove arquivo físico (disco public e legado local). */ public function deleteStorageFile(?string $storageKey): void { if ($storageKey === null || $storageKey === '') { return; } if (Storage::disk('public')->exists($storageKey)) { Storage::disk('public')->delete($storageKey); } if (Storage::disk('local')->exists($storageKey)) { Storage::disk('local')->delete($storageKey); } } /** * Download autenticado de anexo (compatível com uploads legados no disco local). */ public function downloadAttachmentResponse(Attachment $attachment): StreamedResponse { $key = $attachment->storage_key; if (Storage::disk('public')->exists($key)) { return Storage::disk('public')->download($key, $attachment->original_name); } if (Storage::disk('local')->exists($key)) { return Storage::disk('local')->download($key, $attachment->original_name); } abort(404); } /** * URL absoluta da visualização pública executiva, quando ativa. */ public function publicShareUrl(Project $project): ?string { if (! $project->public_share_enabled || ! $project->public_share_token) { return null; } return route('cronogramas.public.show', ['token' => $project->public_share_token]); } /** * Rótulos institucionais dos escopos do projeto para a view pública. * * @return array{structures: list<string>, unidades: list<string>, empresas: list<string>} */ public function publicScopeLabels(Project $project): array { $project->loadMissing('scopes'); $structureIds = $project->scopes ->filter(fn ($scope) => $scope->scope_type === CronogramaProjectScopeType::Structure->value) ->pluck('scope_id') ->filter() ->values() ->all(); $unidadeIds = $project->scopes ->filter(fn ($scope) => $scope->scope_type === CronogramaProjectScopeType::Unidade->value) ->pluck('scope_id') ->filter() ->values() ->all(); $empresaIds = $project->scopes ->filter(fn ($scope) => $scope->scope_type === CronogramaProjectScopeType::Empresa->value) ->pluck('scope_id') ->filter() ->values() ->all(); $structures = $structureIds === [] ? [] : Structure::query() ->whereIn('id', $structureIds) ->where('status', 'ATIVADO') ->orderBy('name') ->pluck('name') ->values() ->all(); $unidades = $unidadeIds === [] ? [] : UnidadeOrganizacional::query() ->whereIn('id', $unidadeIds) ->where('status', 'ATIVADO') ->orderBy('abbreviation') ->orderBy('name') ->get(['name', 'abbreviation']) ->map(fn ($u) => trim(($u->abbreviation ? $u->abbreviation . ' - ' : '') . $u->name)) ->values() ->all(); $empresas = $empresaIds === [] ? [] : Empresa::query() ->whereIn('id', $empresaIds) ->where('status', 'ATIVADO') ->orderBy('nomeFantasia') ->orderBy('razaoSocial') ->get(['nomeFantasia', 'razaoSocial']) ->map(fn ($e) => $e->nomeFantasia ?: $e->razaoSocial) ->filter() ->values() ->all(); return [ 'structures' => $structures, 'unidades' => $unidades, 'empresas' => $empresas, ]; } /** * Payload de atividades sanitizado para exibição pública (sem IDs internos desnecessários). * * @param Collection<int, Activity> $activities * @return list<array<string, mixed>> */ public function publicActivitiesToTableRows(Collection $activities): array { return collect($this->activitiesToTableRows($activities)) ->map(fn (array $row) => [ 'id' => $row['code'] ?: $row['id'], 'code' => $row['code'], 'title' => $row['title'], 'description' => $row['description'], 'parent_id' => $row['parent_id'], 'predecessors' => $row['predecessors'], 'responsible' => $row['responsible'], 'status' => $row['status'], 'status_label' => $row['status_label'], 'completion_percent' => $row['completion_percent'], 'planned_start_date' => $row['planned_start_date'], 'planned_end_date' => $row['planned_end_date'], 'actual_start_date' => $row['actual_start_date'], 'actual_end_date' => $row['actual_end_date'], 'notes' => $row['notes'], 'is_overdue' => $row['is_overdue'], ]) ->values() ->all(); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings