diff --git a/ProcessMaker/Cache/AbstractCacheFactory.php b/ProcessMaker/Cache/AbstractCacheFactory.php index b3779f3f8b..bfcf09c3c0 100644 --- a/ProcessMaker/Cache/AbstractCacheFactory.php +++ b/ProcessMaker/Cache/AbstractCacheFactory.php @@ -10,6 +10,7 @@ abstract class AbstractCacheFactory implements CacheFactoryInterface { protected static ?CacheInterface $testInstance = null; + protected static bool $storeMetrics = true; /** diff --git a/ProcessMaker/Filters/BaseFilter.php b/ProcessMaker/Filters/BaseFilter.php index 247cb770d4..2c9acef720 100644 --- a/ProcessMaker/Filters/BaseFilter.php +++ b/ProcessMaker/Filters/BaseFilter.php @@ -25,6 +25,8 @@ abstract class BaseFilter public const TYPE_FIELD = 'Field'; + public const TYPE_STAGE = 'Stage'; + public const TYPE_PROCESS = 'Process'; public const TYPE_PROCESS_NAME = 'ProcessName'; @@ -98,6 +100,8 @@ private function apply($query): void { if ($valueAliasMethod = $this->valueAliasMethod()) { $this->valueAliasAdapter($valueAliasMethod, $query); + } elseif ($this->subjectType === self::TYPE_STAGE) { + $this->filterByStageId($query); } elseif ($this->subjectType === self::TYPE_PROCESS) { $this->filterByProcessId($query); } elseif ($this->subjectValue === self::PROCESS_NAME_IN_REQUEST) { @@ -124,6 +128,19 @@ private function apply($query): void } } + private function filterByStageId(Builder $query): void + { + if ($query->getModel() instanceof ProcessRequestToken) { + $query->whereIn('process_request_id', function ($query) { + $query->select('id') + ->from('process_requests') + ->whereIn('last_stage_id', (array) $this->value()); + }); + } else { + $query->whereIn('last_stage_id', (array) $this->value()); + } + } + private function applyQueryBuilderMethod($query) { $method = $this->method(); diff --git a/ProcessMaker/Http/Controllers/Api/BookmarkController.php b/ProcessMaker/Http/Controllers/Api/BookmarkController.php index 1dcaafed85..339109711b 100644 --- a/ProcessMaker/Http/Controllers/Api/BookmarkController.php +++ b/ProcessMaker/Http/Controllers/Api/BookmarkController.php @@ -43,6 +43,8 @@ public function index(Request $request) // Get the launchpad configuration $process->launchpad = ProcessLaunchpad::getLaunchpad($launchpad, $process->id); $process->counts = $process->getCounts(); + // Load Stages + $process->stagesSummary = $process->getStagesSummary($process->stages); } return new ProcessCollection($processes); diff --git a/ProcessMaker/Http/Controllers/Api/CaseController.php b/ProcessMaker/Http/Controllers/Api/CaseController.php new file mode 100644 index 0000000000..caaacfc14d --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/CaseController.php @@ -0,0 +1,181 @@ +getSpecificCaseStages($case_number); + + return response()->json($responseData); + } + + $responseData = [ + 'parentRequest' => [], + 'requestCount' => 0, + 'all_stages' => [], + 'current_stage' => [], + 'stages_per_case' => $this->getDefaultCaseStages(), + ]; + + return response()->json($responseData); + } + + /** + * Get specific case stages information + * @param string $caseNumber The unique identifier of the case to retrieve stages for + * @return array + */ + private function getSpecificCaseStages($caseNumber) + { + $allRequests = ProcessRequest::where('case_number', $caseNumber)->get(); + // Check if any requests were found + if ($allRequests->isEmpty()) { + return $this->getDefaultCaseStages(); + } + $parentRequest = null; + $requestCount = $allRequests->count(); + // Search the parent request parent_request_id and load $request + foreach ($allRequests as $request) { + if (is_null($request->parent_request_id)) { + $parentRequest = $request; + break; + } + } + + $stagesPerCase = $this->getStagesSummary($parentRequest); + + return [ + 'parentRequest' => [ + 'id' => $parentRequest->id, + 'case_number' => $parentRequest->case_number, + 'status' => $parentRequest->status, + 'completed_at' => $parentRequest->completed_at, + ], + 'requestCount' => $requestCount, + 'all_stages' => [], + 'current_stage' => [], + 'stages_per_case' => $stagesPerCase, + ]; + } + + /** + * Get default case stages with status handling + * + * @param string|null $status The status to set for the stages + * @return array + */ + private function getDefaultCaseStages($status = null) + { + return [ + [ + 'id' => 0, + 'name' => 'In Progress', + 'status' => $this->mapStatus($status, 'In Progress'), + 'completed_at' => '', + ], + [ + 'id' => 0, + 'name' => 'Completed', + 'status' => $this->mapStatus($status, 'Completed'), + 'completed_at' => '', + ], + ]; + } + + /** + * Map the status for each stage based on the input status + * + * @param string|null $status The input status to map + * @param string $stageName The name of the stage ('In Progress' or 'Completed') + * @return string The mapped status + */ + private function mapStatus($status, $stageName) + { + if ($status === 'COMPLETED') { + return 'Done'; + } + + if ($status === 'ACTIVE') { + return match ($stageName) { + 'In Progress' => 'In Progress', + 'Completed' => 'Pending', + default => 'Pending' + }; + } + + return 'Pending'; + } + + /** + * Get the stages summary based on the provided request. + * + * @param $requestId + * @return array An array of stage results, each containing the stage ID, name, status, + * and completion date. + */ + private function getStagesSummary(ProcessRequest $request) + { + $requestId = $request->id; + $processId = $request->process_id; + $process = Process::where('id', $processId)->first(); + if ($process && !empty($process->stages)) { + $allStages = $process->stages; + } else { + // Return the default stages if the process does not have + return $this->getDefaultCaseStages($request->status); + } + + $allCurrentStages = ProcessRequestToken::where('process_request_id', $requestId) + ->select('stage_id', 'stage_name', 'status', 'completed_at') + ->get() + ->toArray(); + if (empty($allCurrentStages)) { + // TO_DO: define what happen if the process does not have task, is a valid use case + } + + // Helper to map status + $mapStatus = function ($status) { + if ($status === 'CLOSED') { + return 'Done'; + } elseif ($status === 'ACTIVE') { + return 'In Progress'; + } else { + return 'Pending'; + } + }; + + $stageResult = []; + // Initialize stage counts with zero for all stages + foreach ($allStages as $stage) { + $stageData = [ + 'id' => $stage['id'], + 'name' => $stage['name'], + 'status' => 'Pending', + 'completed_at' => '', + ]; + + foreach ($allCurrentStages as $task) { + if ($task['stage_id'] === $stage['id']) { + $stageData['status'] = $mapStatus($task['status']); + $stageData['completed_at'] = $task['completed_at'] ?? ''; + break; + } + } + + $stageResult[] = $stageData; + } + + return $stageResult; + } +} diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index 0c223f70da..b9b14d3f11 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -33,6 +33,7 @@ use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessLaunchpad; use ProcessMaker\Models\ProcessPermission; +use ProcessMaker\Models\ProcessVersion; use ProcessMaker\Models\Screen; use ProcessMaker\Models\Script; use ProcessMaker\Models\Template; @@ -49,11 +50,17 @@ class ProcessController extends Controller { use ProjectAssetTrait; - const CAROUSEL_TYPES = [ + public const CAROUSEL_TYPES = [ 'IMAGE' => 'image', 'EMBED' => 'embed', ]; + public const STAGES_STRUCTURE = [ + 'id', + 'name', + 'order', + ]; + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -143,7 +150,7 @@ public function index(Request $request) $modifiedCollection = $processes->map(function ($item) { return [ 'id' => $item['id'], - 'events'=> $item['start_events']]; + 'events' => $item['start_events']]; }); return new ApiCollection($modifiedCollection); @@ -508,6 +515,14 @@ public function update(Request $request, Process $process) $process->warnings = null; } + // Validate stages + $stages = $request->input('stages', null); + if (!empty($stages)) { + if (!is_array($stages) && !$this->validateStagesStructure($stages)) { + return ['error' => 'Invalid stages structure. Each stage must have id, name, and order.']; + } + } + $process->fill($request->except('notifications', 'task_notifications', 'notification_settings', 'cancel_request', 'cancel_request_id', 'start_request_id', 'edit_data', 'edit_data_id', 'projects')); if ($request->has('manager_id')) { $process->manager_id = $request->input('manager_id', null); @@ -585,6 +600,31 @@ public function update(Request $request, Process $process) return new Resource($process->refresh()); } + /** + * Validate the structure of stages. + * + * @param array $stages + * @return bool + */ + private function validateStagesStructure(array $stages): bool + { + foreach ($stages as $stage) { + // Check if all required keys are present + foreach (self::STAGES_STRUCTURE as $key) { + if (!array_key_exists($key, $stage)) { + return false; // Missing required key + } + } + + // Additional validation for data types + if (!is_int($stage['id']) || !is_string($stage['name']) || !is_int($stage['order'])) { + return false; + } + } + + return true; + } + public function updateBpmn(Request $request, Process $process) { $request->validate(Process::rules($process)); @@ -1911,4 +1951,389 @@ public function deleteEmbed(Request $request, Process $process) $embedUrl->delete(); } } + + /** + * Get stages of a process + * + * @OA\Get( + * path="/processes/{process}/stages", + * summary="Get the list of stages for a process", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * in="path", + * required=true, + * description="ID of the process", + * @OA\Schema(type="integer") + * ), + * @OA\Parameter( + * name="alternative", + * in="query", + * required=false, + * description="Alternative version (A or B)", + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=200, + * description="List of stages", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="order", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="selected", type="boolean") + * ) + * ) + * ) + * ) + */ + public function getStages(Process $process, Request $request) + { + $alternative = $request->input('alternative', 'A'); + $stages = []; + if ($alternative === 'B') { + // Get the alternative B version + $alternativeVersion = ProcessVersion::where('process_id', $process->id) + ->where('alternative', 'B') + ->first(); + + if ($alternativeVersion) { + $stages = $alternativeVersion->stages ?? []; + } + } else { + // Default to alternative A or get from main process + $stages = $process->stages ?? []; + } + + return new ApiCollection($stages); + } + + /** + * Save stages for a process + * + * @OA\Post( + * path="/processes/{process}/stages", + * summary="Save or update the list of stages for a process", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * in="path", + * required=true, + * description="ID of the process", + * @OA\Schema(type="integer") + * ), + * @OA\Parameter( + * name="alternative", + * in="query", + * required=false, + * description="Alternative version (A or B)", + * @OA\Schema(type="string") + * ), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="order", type="integer"), + * @OA\Property(property="label", type="string"), + * @OA\Property(property="selected", type="boolean") + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Updated stages", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="order", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="selected", type="boolean") + * ) + * ) + * ) + * ) + */ + public function saveStages(Request $request, Process $process) + { + $alternative = $request->input('alternative', 'A'); + $stages = $request->input('stages'); + + if ($alternative === 'B') { + + // Get or create alternative B version + $alternativeVersion = ProcessVersion::where('process_id', $process->id) + ->where('alternative', 'B') + ->first(); + + // Save stages to alternative B version + $alternativeVersion->stages = $stages; + $alternativeVersion->save(); + + return new ApiCollection($alternativeVersion->stages); + } else { + // Save stages to main process (alternative A) + $process->stages = $stages; + $process->save(); + + return new ApiCollection($process->stages); + } + } + + /** + * Get aggregation for a process + * + * @OA\Get( + * path="/processes/{process}/aggregation", + * summary="Get the aggregation configuration for a process", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * in="path", + * required=true, + * description="ID of the process", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Aggregation configuration", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="aggregation", type="string", description="string containing var aggregation") + * ) + * ) + * ) + */ + public function getAggregation(Process $process) + { + return new ApiResource([ + 'data' => $process->aggregation, + ]); + } + + /** + * Save aggregation for a process + * + * @OA\Post( + * path="/processes/{process}/aggregation", + * summary="Save or update the aggregation field for a process", + * description="Updates the aggregation field of a process. If no aggregation is provided, defaults to 'amount'.", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * in="path", + * required=true, + * description="ID of the process", + * @OA\Schema(type="integer") + * ), + * @OA\RequestBody( + * required=false, + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="aggregation", + * type="string", + * description="Field name to use for aggregation (defaults to 'amount' if not provided)", + * example="amount" + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Updated aggregation field", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="data", + * type="string", + * description="The saved aggregation field value", + * example="amount" + * ) + * ) + * ), + * @OA\Response( + * response=404, + * description="Process not found" + * ) + * ) + */ + public function saveAggregation(Request $request, Process $process) + { + $process->aggregation = $request->input('aggregation', 'amount'); + $process->save(); + + return new ApiResource([ + 'data' => $process->aggregation, + ]); + } + + /** + * Get the stages configuration for a specific process. + * + * @OA\Get( + * path="/processes/{process}/stage-mapping", + * summary="Get process stages configuration", + * description="Retrieves and formats the stages configuration for a specific process, including total counts and individual stages.", + * operationId="getStageMapping", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * description="ID of the process", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="total", + * type="object", + * @OA\Property(property="stage_id", type="number", example="0"), + * @OA\Property(property="stage_name", type="string", example="Total Cases"), + * @OA\Property(property="percentage", type="number", example=100), + * @OA\Property(property="percentage_format", type="string", example="100%"), + * @OA\Property(property="agregation_sum", type="number", example=50000), + * @OA\Property(property="agregation_count", type="number", example=150), + * ), + * @OA\Property( + * property="stages", + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="stage_id", type="number", example="1"), + * @OA\Property(property="stage_name", type="string", example="In progress"), + * @OA\Property(property="percentage", type="number", nullable=true, example=60), + * @OA\Property(property="percentage_format", type="string", example="60%"), + * @OA\Property(property="agregation_sum", type="number", nullable=true, example=28678), + * @OA\Property(property="agregation_count", type="number", nullable=true, example=100), + * ) + * ) + * ) + * ) + * ) + * ) + */ + public function getStageMapping(Process $process) + { + $formattedStages = Process::formatStages($process->id, $process->stages, $process->aggregation); + + return response()->json(['data' => $formattedStages]); + } + + /** + * Get the stages configuration for a specific process. + * + * @OA\Get( + * path="/api/processes/{process}/default-stages", + * summary="Get default process stages configuration", + * description="Retrieves and formats the default stages configuration for a process.", + * operationId="getDefaultStagesPerProcess", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * description="ID of the process", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="stage_id", type="number", example="1"), + * @OA\Property(property="stage_name", type="string", example="In progress"), + * @OA\Property(property="percentage", type="number", nullable=true, example=60), + * @OA\Property(property="percentage_format", type="string", example="60%"), + * @OA\Property(property="agregation_sum", type="number", nullable=true, example=28678), + * @OA\Property(property="agregation_count", type="number", nullable=true, example=100), + * ) + * ) + * ) + * ) + */ + public function getDefaultStagesPerProcess(Process $process) + { + $formattedStages = Process::formatStages($process->id, '', ''); + + return response()->json(['data' => $formattedStages]); + } + + /** + * Get the metrics configuration for a specific process. + * + * @OA\Get( + * path="/api/processes/{process}/metrics", + * summary="Get process metrics configuration", + * description="Retrieves and formats the metrics configuration for a specific process. Supports different formats like 'student' or default metrics.", + * operationId="getMetricsPerProcess", + * tags={"Processes"}, + * @OA\Parameter( + * name="process", + * description="ID of the process", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Parameter( + * name="format", + * description="Format type of the metrics (e.g., 'student')", + * required=false, + * in="query", + * @OA\Schema( + * type="string", + * enum={"student", "default"}, + * default="student" + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="metric_description", type="string", example="Max amount available"), + * @OA\Property(property="metric_count", type="integer", nullable=true, example=10), + * @OA\Property(property="metric_count_description", type="string", example="Across 10 applicants"), + * @OA\Property(property="metric_value", type="number", example=84000), + * @OA\Property(property="metric_value_unit", type="string", example="k") + * ) + * ) + * ), + * @OA\Response( + * response=400, + * description="Invalid format parameter", + * @OA\JsonContent( + * @OA\Property(property="error", type="string", example="Invalid format parameter") + * ) + * ) + * ) + */ + public function getMetricsPerProcess(Process $process, Request $request) + { + try { + $format = $request->query('format', 'student'); + $formattedMetrics = Process::formatMetrics($process, $format); + + return response()->json(['data' => $formattedMetrics]); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessLaunchpadController.php b/ProcessMaker/Http/Controllers/Api/ProcessLaunchpadController.php index cf6ac094f9..72d62ccc95 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessLaunchpadController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessLaunchpadController.php @@ -56,6 +56,8 @@ public function getProcesses(Request $request) $process->bookmark_id = Bookmark::getBookmarked($bookmark, $process->id, $user->id); // Get the launchpad configuration $process->launchpad = ProcessLaunchpad::getLaunchpad($launchpad, $process->id); + // Load Stages + $process->stagesSummary = $process->getStagesSummary($process->stages); } $process = $processes->map(function ($process) { @@ -94,6 +96,7 @@ public function index(Request $request, Process $process) ->get() ->map(function ($process) use ($request) { $process->counts = $process->getCounts(); + $process->stagesSummary = $process->getStagesSummary($process->stages); $process->bookmark_id = Bookmark::getBookmarked(true, $process->id, $request->user()->id); return $process; @@ -201,14 +204,14 @@ public function getProcessesMenu(Request $request) $processes = Process::select('processes.*') ->notArchived() ->distinct() - ->whereHas('requests', function($query) use ($userId) { + ->whereHas('requests', function ($query) use ($userId) { $query->where('user_id', $userId) - ->orWhereHas('tokens', function($query) use ($userId) { + ->orWhereHas('tokens', function ($query) use ($userId) { $query->where('user_id', $userId); }); }) - ->where('asset_type', NULL) - ->where('package_key', '=', NULL) + ->where('asset_type', null) + ->where('package_key', '=', null) ->orderBy('processes.name', 'asc') ->get(); diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php index a183a22f9e..98a9293746 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestController.php @@ -4,6 +4,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Routing\ResponseFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; @@ -42,7 +43,6 @@ class ProcessRequestController extends Controller { use ProcessMapTrait; - const DOMAIN_CACHE_TIME = 86400; /** diff --git a/ProcessMaker/Http/Controllers/Api/UserTokenController.php b/ProcessMaker/Http/Controllers/Api/UserTokenController.php index f3a853c5d7..70215fa19e 100644 --- a/ProcessMaker/Http/Controllers/Api/UserTokenController.php +++ b/ProcessMaker/Http/Controllers/Api/UserTokenController.php @@ -24,22 +24,22 @@ class UserTokenController extends Controller /** * The token repository implementation. * - * @var \Laravel\Passport\TokenRepository + * @var TokenRepository */ protected $tokenRepository; /** * The validation factory implementation. * - * @var \Illuminate\Contracts\Validation\Factory + * @var ValidationFactory */ protected $validation; /** * Create a controller instance. * - * @param \Laravel\Passport\TokenRepository $tokenRepository - * @param \Illuminate\Contracts\Validation\Factory $validation + * @param TokenRepository $tokenRepository + * @param ValidationFactory $validation * @return void */ public function __construct(TokenRepository $tokenRepository, ValidationFactory $validation) @@ -109,7 +109,7 @@ public function index(Request $request, User $user) /** * Create a new personal access token for the user. * - * @param \Illuminate\Http\Request $request + * @param Request $request * @return \Laravel\Passport\PersonalAccessTokenResult * * @OA\Post( @@ -215,7 +215,7 @@ public function show(Request $request, User $user, $tokenId) /** * Delete the given token for a user * - * @param \Illuminate\Http\Request $request + * @param Request $request * @param string $tokenId * @return \Illuminate\Http\Response * diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index a57d90f680..c6a0a94e44 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -84,6 +84,17 @@ public function show($case_number) } else { $request->summary_screen = $request->getSummaryScreen(); } + // Stage information + if ($request->status === 'COMPLETED') { + $currentStages = [ + 'stage_name' => __('Completed'), + ]; + $progressStage = 100.0; // 100% completed + } else { + $currentStages = $this->formatCurrentStage($request->last_stage_id, $request->last_stage_name); + $allStages = $this->getStagesByProcessId($request->process_id); + $progressStage = calculateProgressById($request->last_stage_id, $allStages); + } // Load the screen configured in "Request Detail Screen" $request->request_detail_screen = Screen::find($request->process->request_detail_screen_id); // The user canCancel if has the processPermission and the case has only one request @@ -109,6 +120,9 @@ public function show($case_number) $modelerController = new ModelerController(); $pmBlockList = $modelerController->getPmBlockList(); + // Is TCE Customization + $isTceCustomization = config('app.tce_customization_enable'); + // Return the view return view('cases.edit', compact( 'request', @@ -122,7 +136,10 @@ public function show($case_number) 'managerModelerScripts', 'bpmn', 'inflightData', - 'pmBlockList' + 'pmBlockList', + 'progressStage', + 'currentStages', + 'isTceCustomization', )); } @@ -171,4 +188,31 @@ public function summaryScreenTranslation(ProcessRequest $request): void } } } + + /** + * Get the current stages from a JSON string. + * + * This method decodes a JSON string representing stages into an associative array. + * If the input is null or not a valid JSON string, it returns an empty array. + * + * @param int|null $id The ID of the stage. + * @param string|null $name The name of the stage. + * @return array An associative array of stages if the JSON is valid; + * otherwise, an empty array. + */ + public static function formatCurrentStage(?int $id, ?string $name): array + { + // Initialize currentStages as an empty array + $currentStages = []; + + // Check if $id is not null and $name is a valid string + if (!is_null($id) && is_string($name)) { + $currentStages = [ + 'stage_id' => $id, + 'stage_name' => $name, + ]; + } + + return $currentStages; + } } diff --git a/ProcessMaker/Http/Controllers/ProcessesCatalogueController.php b/ProcessMaker/Http/Controllers/ProcessesCatalogueController.php index b55c89698b..596c5821ed 100644 --- a/ProcessMaker/Http/Controllers/ProcessesCatalogueController.php +++ b/ProcessMaker/Http/Controllers/ProcessesCatalogueController.php @@ -10,6 +10,7 @@ use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Managers\ScreenBuilderManager; use ProcessMaker\Models\Bookmark; +use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessCategory; use ProcessMaker\Models\ProcessLaunchpad; @@ -53,7 +54,22 @@ public function index(Request $request, Process $process = null) $userConfiguration = (new UserConfigurationController())->index()['ui_configuration']; $defaultSavedSearch = $this->getDefaultSavedSearchId(); + $defaultColumns = []; - return view('processes-catalogue.index', compact('process', 'currentUser', 'manager', 'userConfiguration', 'defaultSavedSearch')); + $metricsApiEndpoint = ''; + // If TCE customization is enabled, get the metrics API endpoint + if (config('app.tce_customization_enable') == 'true') { + $metricsApiEndpoint = EnvironmentVariable::getMetricsApiEndpoint(); + } + + return view('processes-catalogue.index', compact( + 'process', + 'currentUser', + 'manager', + 'userConfiguration', + 'defaultSavedSearch', + 'defaultColumns', + 'metricsApiEndpoint', + )); } } diff --git a/ProcessMaker/Http/Controllers/TaskController.php b/ProcessMaker/Http/Controllers/TaskController.php index 374a2201c7..d920c27dbc 100755 --- a/ProcessMaker/Http/Controllers/TaskController.php +++ b/ProcessMaker/Http/Controllers/TaskController.php @@ -19,6 +19,7 @@ use ProcessMaker\Managers\ScreenBuilderManager; use ProcessMaker\Models\AnonymousUser; use ProcessMaker\Models\Comment; +use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequestToken; @@ -76,7 +77,31 @@ public function index() $defaultSavedSearchId = $this->getDefaultSavedSearchId(); - return view('tasks.index', compact('title', 'userFilter', 'defaultColumns', 'isDefaultColumns', 'taskDraftsEnabled', 'userConfiguration', 'showOldTaskScreen', 'currentUser', 'selectedProcess', 'defaultSavedSearchId', 'manager')); + $metricsApiEndpoint = ''; + // If TCE customization is enabled, get the metrics API endpoint + if (config('app.tce_customization_enable') == 'true') { + $metricsApiEndpoint = EnvironmentVariable::getMetricsApiEndpoint(); + } + + return view('tasks.index', compact( + // User and Authentication related + 'currentUser', + 'userFilter', + 'userConfiguration', + // Process and Task related + 'selectedProcess', + 'defaultColumns', + 'isDefaultColumns', + 'taskDraftsEnabled', + 'showOldTaskScreen', + // Search and Display + 'defaultSavedSearchId', + 'manager', + // General + 'title', + // Metrics API Endpoint + 'metricsApiEndpoint', + )); } public function edit(ProcessRequestToken $task, string $preview = '') diff --git a/ProcessMaker/Http/Resources/V1_1/CaseResource.php b/ProcessMaker/Http/Resources/V1_1/CaseResource.php index ba4d25dc14..fe775ae774 100644 --- a/ProcessMaker/Http/Resources/V1_1/CaseResource.php +++ b/ProcessMaker/Http/Resources/V1_1/CaseResource.php @@ -29,6 +29,9 @@ class CaseResource extends ApiResource 'participants', 'initiated_at', 'completed_at', + 'last_stage_id', + 'last_stage_name', + 'progress', ]; public function toArray($request): array diff --git a/ProcessMaker/Models/CaseParticipated.php b/ProcessMaker/Models/CaseParticipated.php index 6f1c6b6e8d..88304a6a68 100644 --- a/ProcessMaker/Models/CaseParticipated.php +++ b/ProcessMaker/Models/CaseParticipated.php @@ -30,6 +30,9 @@ class CaseParticipated extends ProcessMakerModel 'initiated_at', 'completed_at', 'keywords', + 'last_stage_id', + 'last_stage_name', + 'progress', ]; protected $casts = [ diff --git a/ProcessMaker/Models/CaseStarted.php b/ProcessMaker/Models/CaseStarted.php index 5141599ab7..060e33f591 100644 --- a/ProcessMaker/Models/CaseStarted.php +++ b/ProcessMaker/Models/CaseStarted.php @@ -30,6 +30,9 @@ class CaseStarted extends ProcessMakerModel 'initiated_at', 'completed_at', 'keywords', + 'last_stage_id', + 'last_stage_name', + 'progress', ]; protected $casts = [ diff --git a/ProcessMaker/Models/EnvironmentVariable.php b/ProcessMaker/Models/EnvironmentVariable.php index 2d872a11f9..97554fce2a 100644 --- a/ProcessMaker/Models/EnvironmentVariable.php +++ b/ProcessMaker/Models/EnvironmentVariable.php @@ -87,4 +87,15 @@ public static function messages() 'name.regex' => trans('environmentVariables.validation.name.invalid_variable_name'), ]; } + + public static function getMetricsApiEndpoint() + { + $variable = self::where('name', 'METRICS_API_ENDPOINT')->first(); + + if ($variable) { + return $variable->value; + } + + return '/api/1.0/processes/{process}/metrics'; + } } diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index b1cae9afc3..1ddfe81e61 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -226,7 +226,7 @@ class Process extends ProcessMakerModel implements HasMedia, ProcessModelInterfa 'assigned', 'completed', 'due', - 'default' + 'default', ]; protected $appends = [ @@ -241,6 +241,7 @@ class Process extends ProcessMakerModel implements HasMedia, ProcessModelInterfa 'signal_events' => 'array', 'conditional_events' => 'array', 'properties' => 'array', + 'stages' => 'array', ]; public static function boot() @@ -1854,6 +1855,234 @@ public function hasAlternative() return true; } + /** + * Provides default stage data when no configuration exists. + * + * @param int $processId The process_id. + * @return array The default formatted array of stages. + */ + protected static function getDefaultStagesData(int $processId): array + { + $totalCount = 0; + $activeCount = 0; + $completedCount = 0; + $activePercentage = 0; + $completedPercentage = 0; + $defaultCounts = self::getProcessDefaultStageCounts($processId); + if ($defaultCounts) { + $totalCount = $defaultCounts['total_count'] ?? 0; + $activeCount = $defaultCounts['active_count'] ?? 0; + $completedCount = $defaultCounts['completed_count'] ?? 0; + $activePercentage = ($totalCount > 0) ? (($activeCount / $totalCount) * 100) : 0; + $completedPercentage = ($totalCount > 0) ? (($completedCount / $totalCount) * 100) : 0; + } + + return [ + 'total' => [ + 'stage_id' => 0, + 'stage_name' => 'Total Cases', + 'percentage' => 100, + 'percentage_format' => '100%', + 'agregation_sum' => 0, + 'agregation_count' => $totalCount, + ], + 'stages' => [ + [ + 'stage_id' => 'in_progress', + 'stage_name' => 'In progress', + 'percentage' => $activePercentage, + 'percentage_format' => $activePercentage . '%', + 'agregation_sum' => 0, + 'agregation_count' => $activeCount, + ], + [ + 'stage_id' => 'completed', + 'stage_name' => 'Completed', + 'percentage' => $completedPercentage, + 'percentage_format' => $completedPercentage . '%', + 'agregation_sum' => 0, + 'agregation_count' => $completedCount, + ], + ], + ]; + } + + /** + * Maps the stages configuration with the counts from the database. + * + * @param array $stagesConf The decoded stages configuration. + * @param array $stageCounts The counts of process requests by last_stage_id. + * @return array The formatted array of stages with counts. + */ + protected static function mapStagesWithCounts(array $stagesConfig, array $stageCounts): array + { + $stages = collect($stagesConfig)->map(function ($stage, $index) use ($stageCounts) { + $stageId = $stage['id'] ?? 0; + $totalCount = $stageCounts['total_count'] ?? 0; + $stageCount = $stageCounts[$stageId]['count'] ?? 0; + $stageSum = $stageCounts[$stageId]['total_aggregation_sum'] ?? 0; + $stagePercentage = ($totalCount > 0) ? ($stageCount / $totalCount * 100) : 0; + + return [ + 'stage_id' => $stage['id'] ?? 0, + 'stage_name' => $stage['name'] ?? 'Unknown Stage ' . ($index + 1), + 'percentage' => $stagePercentage, + 'percentage_format' => $stagePercentage . '%', + 'agregation_sum' => $stageSum, + 'agregation_count' => $stageCount, + ]; + })->toArray(); + + $totalCount = $stageCounts['total_count'] ?? 0; + $totalSum = collect($stages)->sum('agregation_sum') ?? 0; + + return [ + 'total' => [ + 'stage_id' => 0, + 'stage_name' => 'Total Cases', + 'percentage' => 100, + 'percentage_format' => '100%', + 'agregation_sum' => $totalCount, + 'agregation_count' => $totalSum, + ], + 'stages' => $stages, + ]; + } + + protected static function getProcessDefaultStageCounts(int $processId): array + { + return ProcessRequest::where('process_id', $processId) + ->whereIn('status', ['ACTIVE', 'COMPLETED', 'ERROR', 'CANCELED']) + ->selectRaw(" + COUNT(CASE WHEN status = 'ACTIVE' THEN 1 END) AS active_count, + COUNT(CASE WHEN status = 'ERROR' THEN 1 END) AS error_count, + COUNT(CASE WHEN status = 'CANCELED' THEN 1 END) AS cancel_count, + COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) AS completed_count, + COUNT(*) AS total_count + ") + ->first() + ->toArray(); + } + + protected static function getProcessStageCounts(int $processId, string $amountKey = 'amount'): array + { + $rawSql = sprintf('last_stage_id, COUNT(*) AS count, SUM(CAST(JSON_EXTRACT(data, \'$.%s\') AS DECIMAL(10, 2))) AS var_aggregation_sum', $amountKey); + + $results = ProcessRequest::where('process_id', $processId) + ->groupBy('last_stage_id') + ->selectRaw($rawSql) + ->get() + ->toArray(); + + $stageData = []; + $totalCount = 0; + foreach ($results as $result) { + $stageData[$result['last_stage_id']] = [ + 'count' => $result['count'], + 'total_aggregation_sum' => $result['var_aggregation_sum'], + ]; + // We will consider only the request with stages related + if (!empty($result['last_stage_id'])) { + $totalCount += $result['count']; + } + } + $stageData['total_count'] = $totalCount; + + return $stageData; + } + + /** + * Formats the stages configuration for the API response. + * + * @param int $processId The process_id + * @param array $stages The stages configuration. + * @param string $varAggregation The aggregation var configuration. + * @return array The formatted array of stages. + */ + public static function formatStages($processId, $stages = [], ?string $varAggregation = ''): array + { + if (empty($stages)) { + // If not exist stages we will return the default stages + return self::getDefaultStagesData($processId); + } else { + // Get the stages + $stageCounts = self::getProcessStageCounts($processId, $varAggregation); + + return self::mapStagesWithCounts($stages, $stageCounts); + } + } + + /** + * Formats the stages configuration for the API response. + * + * @param Process $process The process to get metrics for + * @param string|null $stagesJson The JSON string of the stages configuration. + * @return array The formatted array of stages. + */ + public static function formatMetrics(self $process, $format = 'student'): array + { + // Get user authenticated + $user = Auth::user(); + // Create a base query for process requests + $baseQuery = ProcessRequest::query() + ->where('process_id', $process->id) + ->forUser($user); + // Count the number of process requests in different states + $counts = [ + 'started_me' => (clone $baseQuery) + ->startedMe($user->id) + ->notCompleted() + ->count(), + 'in_progress' => (clone $baseQuery) + ->inProgress() + ->count(), + 'completed' => (clone $baseQuery) + ->completed() + ->count(), + ]; + + $totalRequests = array_sum($counts); + $startedPercentage = $totalRequests > 0 ? round(($counts['started_me'] / $totalRequests) * 100) : 0; + $inProgressPercentage = $totalRequests > 0 ? round(($counts['in_progress'] / $totalRequests) * 100) : 0; + $completedPercentage = $totalRequests > 0 ? round(($counts['completed'] / $totalRequests) * 100) : 0; + + switch ($format) { + case 'student': + case 'college': + $metrics = [ + [ + 'id' => 1, + 'metric_description' => sprintf('Cases Started by me (%d%%)', $startedPercentage), + 'metric_count' => 40, + 'metric_count_description' => 'Applications started by me', + 'metric_value' => $counts['started_me'], + 'metric_value_unit' => '', + ], + [ + 'id' => 2, + 'metric_description' => sprintf('Cases In progress (%d%%)', $inProgressPercentage), + 'metric_count' => 0, + 'metric_count_description' => 'Applications are currently being reviewed', + 'metric_value' => $counts['in_progress'], + 'metric_value_unit' => '', + ], + [ + 'id' => 3, + 'metric_description' => sprintf('Completed Cases (%d%%)', $completedPercentage), + 'metric_count' => 0, + 'metric_count_description' => 'Applications have been processed', + 'metric_value' => $counts['completed'], + 'metric_value_unit' => '', + ], + ]; + break; + default: + // Default format for metrics + } + + return $metrics; + } + public function scopeOrderByRecentRequests($query) { return $query->orderByDesc( diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index dbfb962449..0f052e1819 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use DB; +use DOMXPath; use Exception; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Arr; @@ -21,6 +22,7 @@ use ProcessMaker\Nayra\Contracts\Bpmn\MultiInstanceLoopCharacteristicsInterface; use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; +use ProcessMaker\Nayra\Storage\BpmnDocument; use ProcessMaker\Notifications\ActivityActivatedNotification; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\Expression; @@ -1045,6 +1047,53 @@ function ($key) use ($allowed) { }, ARRAY_FILTER_USE_KEY ); + + $this->setStagePropertiesInRecord(); + } + + /** + * Extracts and assigns stage properties from the BPMN sequence flow configuration. + * + * This method loads the BPMN XML from the process associated with the current instance + * and uses XPath to locate any sequenceFlow elements that target the current element ID. + * It iterates over the matching flows and attempts to extract the `pm:config` attribute + * from each, which is expected to be a JSON-encoded string. + * + * If a configuration is found with a defined `stage.id`, the method assigns the `stage_id` + * and `stage_name` properties of the current object accordingly. + * + * Typical use case: setting the stage metadata (ID and name) during execution flow + * resolution in a BPMN process. + * + * @return void + */ + public function setStagePropertiesInRecord() + { + try { + $instance = $this->getInstance(); + $bpmnDocument = new BpmnDocument(); + $bpmnDocument->loadXml($instance->process->bpmn); + $xpath = new DOMXPath($bpmnDocument); + $xpath->registerNamespace('bpmn', BpmnDocument::BPMN_MODEL); + $sequenceFlows = $xpath->query("//bpmn:sequenceFlow[@targetRef='$this->element_id']"); + $config = null; + foreach ($sequenceFlows as $flow) { + $id = $flow->getAttribute('id'); + $incomingFlow = $bpmnDocument->findElementById($id); + if (!$incomingFlow) { + continue; + } + $pmConfig = $incomingFlow->getAttribute('pm:config'); + $config = json_decode($pmConfig ?? ''); + if (isset($config->stage) && isset($config->stage->id)) { + break; + } + } + $this->stage_id = isset($config->stage) && isset($config->stage->id) ? $config->stage->id : null; + $this->stage_name = isset($config->stage) && isset($config->stage->name) ? $config->stage->name : null; + } catch (Exception $e) { + Log::debug($e->getMessage()); + } } public function loadTokenProperties() diff --git a/ProcessMaker/Models/ProcessVersion.php b/ProcessMaker/Models/ProcessVersion.php index eafa4531d4..1d5265669d 100644 --- a/ProcessMaker/Models/ProcessVersion.php +++ b/ProcessMaker/Models/ProcessVersion.php @@ -49,6 +49,7 @@ class ProcessVersion extends ProcessMakerModel implements ProcessModelInterface 'signal_events' => 'array', 'conditional_events' => 'array', 'properties' => 'array', + 'stages' => 'array', ]; /** diff --git a/ProcessMaker/Repositories/CaseApiRepository.php b/ProcessMaker/Repositories/CaseApiRepository.php index 2440e77505..4d4918217f 100644 --- a/ProcessMaker/Repositories/CaseApiRepository.php +++ b/ProcessMaker/Repositories/CaseApiRepository.php @@ -32,6 +32,9 @@ class CaseApiRepository implements CaseApiRepositoryInterface 'participants', 'initiated_at', 'completed_at', + 'last_stage_id', + 'last_stage_name', + 'progress' ]; protected $sortableFields = [ @@ -66,7 +69,7 @@ class CaseApiRepository implements CaseApiRepositoryInterface 'updated_at', ]; - const DEFAULT_SORT_DIRECTION = 'asc'; + public const DEFAULT_SORT_DIRECTION = 'asc'; /** * Get all cases diff --git a/ProcessMaker/Repositories/CaseParticipatedRepository.php b/ProcessMaker/Repositories/CaseParticipatedRepository.php index 48a3b19f77..7a27637989 100644 --- a/ProcessMaker/Repositories/CaseParticipatedRepository.php +++ b/ProcessMaker/Repositories/CaseParticipatedRepository.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use ProcessMaker\Constants\CaseStatusConstants; use ProcessMaker\Models\CaseParticipated; use ProcessMaker\Models\CaseStarted; @@ -66,6 +67,11 @@ public function update(CaseStarted $case): void */ private function mapCaseToArray(CaseStarted $case, int $userId = null): array { + // Define the case status if is not set the stage + if (is_null($case->last_stage_id)) { + $case->last_stage_name = $case->case_status; + $case->progress = 50; + } $data = [ 'case_number' => $case->case_number, 'case_title' => $case->case_title, @@ -79,6 +85,9 @@ private function mapCaseToArray(CaseStarted $case, int $userId = null): array 'initiated_at' => $case->initiated_at, 'completed_at' => $case->completed_at, 'keywords' => $case->keywords, + 'last_stage_id' => $case->last_stage_id, + 'last_stage_name' => $case->last_stage_name, + 'progress' => $case->progress ?? 0, ]; if ($userId !== null) { diff --git a/ProcessMaker/Repositories/CaseRepository.php b/ProcessMaker/Repositories/CaseRepository.php index 87755b6aba..ce21d63723 100644 --- a/ProcessMaker/Repositories/CaseRepository.php +++ b/ProcessMaker/Repositories/CaseRepository.php @@ -53,6 +53,11 @@ public function create(ExecutionInstanceInterface $instance): void $processData = CaseUtils::extractData($instance->process, 'PROCESS'); $requestData = CaseUtils::extractData($instance, 'REQUEST'); $dataKeywords = CaseUtils::extractData($instance, 'KEYWORD'); + // Check the case status + if (is_null($instance->last_stage_id)) { + $instance->last_stage_name = $instance->case_status; + $instance->progress = 50; + } CaseStarted::create([ 'case_number' => $instance->case_number, @@ -68,6 +73,9 @@ public function create(ExecutionInstanceInterface $instance): void 'initiated_at' => $instance->initiated_at, 'completed_at' => null, 'keywords' => CaseUtils::getKeywords($dataKeywords), + 'last_stage_id' => $instance->last_stage_id, + 'last_stage_name' => $instance->last_stage_name, + 'progress' => 0, ]); } catch (\Exception $e) { Log::error('CaseException: ' . $e->getMessage()); @@ -98,6 +106,9 @@ public function update(ExecutionInstanceInterface $instance, TokenInterface $tok $this->case->request_tokens = CaseUtils::storeRequestTokens($this->case->request_tokens, $token->getKey()); $this->case->tasks = CaseUtils::storeTasks($this->case->tasks, $taskData); $this->case->keywords = CaseUtils::getKeywords($dataKeywords); + $this->case->last_stage_id = $token->stage_id; + $this->case->last_stage_name = $token->stage_name; + $this->case->progress = calculateProgressById($token->stage_id, $instance?->process?->stages); $this->updateParticipants($token); @@ -134,6 +145,8 @@ public function updateStatus(ExecutionInstanceInterface $instance): void if (in_array($caseStatus, [CaseStatusConstants::COMPLETED, CaseStatusConstants::CANCELED])) { $data['completed_at'] = $instance->completed_at; + $data['last_stage_name'] = $caseStatus; + $data['progress'] = 100; } // Update the case started and case participated diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index e98a967622..a5880642e7 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -169,6 +169,9 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->saveOrFail(); $token->setId($token->getKey()); $request = $token->getInstance(); + $request->last_stage_id = $token->stage_id; + $request->last_stage_name = $token->stage_name; + $request->progress = calculateProgressById($token->stage_id, $request?->process?->stages); $request->notifyProcessUpdated('ACTIVITY_ACTIVATED', $token); CaseUpdate::dispatchSync($request, $token); @@ -287,10 +290,6 @@ public function persistStartEventTriggered(StartEventInterface $startEvent, Coll $request->notifyProcessUpdated('START_EVENT_TRIGGERED', $token); } - private function assignTaskUser(ActivityInterface $activity, TokenInterface $token, Instance $instance) - { - } - /** * Persists instance and token data when a token within an activity change to error state * diff --git a/ProcessMaker/Traits/ProcessMapTrait.php b/ProcessMaker/Traits/ProcessMapTrait.php index 2b9d0c3a42..8bdfaa10c2 100644 --- a/ProcessMaker/Traits/ProcessMapTrait.php +++ b/ProcessMaker/Traits/ProcessMapTrait.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use ProcessMaker\Bpmn\Process; +use ProcessMaker\Models\Process as ModelsProcess; use ProcessMaker\Models\ProcessRequest; use SimpleXMLElement; @@ -158,4 +159,28 @@ private function loadProcessMap(ProcessRequest $request): array 'requestId' => $request->id, ]; } + + /** + * Get the stages for a specific process ID. + * + * @param int $processId + * @return array|null + */ + public static function getStagesByProcessId(int $processId): ?array + { + // Retrieve the process by ID + $process = ModelsProcess::find($processId); + + // If the process exists, return the stages + if ($process) { + $stages = $process->stages; + + if (!is_null($stages)) { + return $stages; + } + } + + // Return empty array if the process does not exist or has no stages + return []; + } } diff --git a/ProcessMaker/Traits/ProcessTrait.php b/ProcessMaker/Traits/ProcessTrait.php index fd5cf1f9db..0bd2f4b7f4 100644 --- a/ProcessMaker/Traits/ProcessTrait.php +++ b/ProcessMaker/Traits/ProcessTrait.php @@ -218,4 +218,65 @@ public function getCounts() 'total' => $completed + $in_progress, ]; } + + /** + * Get the count of stages per request. + * + * This method retrieves the count of stages based on the last_stage_id for each request. + * If a JSON string of stages is provided, it decodes it; otherwise, it fetches all stages + * from the database. The method returns an array containing each stage's ID, name, and count. + * + * @param string|null $stages A JSON string representing stages. If provided, it will be decoded; + * if null, all stages will be fetched from the database. + * @return array An array of associative arrays, each containing: + * - 'id': The ID of the stage. + * - 'name': The name of the stage. + * - 'count': The count of occurrences of the stage based on last_stage_id. + */ + public function getStagesSummary($stages = null) + { + if (!empty($stages)) { + $allStages = $stages; + } else { + return []; + } + + // Assuming 'stages' is a relationship defined in the model + $result = $this->requests() + ->selectRaw('last_stage_id, count(*) as count') + ->groupBy('last_stage_id') + ->get(); + + // Prepare an array to hold the counts for each stage + $stageCounts = []; + // Initialize stage counts with zero for all stages + foreach ($allStages as $stage) { + $stageCounts[] = [ + 'id' => $stage['id'] ?? 0, + 'name' => $stage['name'] ?? 'Unknow Stage', + 'count' => 0, // Initialize count to 0 for each stage + 'percentage' => 0, // Initialize percentaje to 0 for each stage + ]; + } + + foreach ($result as $stage) { + foreach ($stageCounts as $key => &$countData) { + if ($countData['id'] == $stage->last_stage_id) { + $countData['count'] = $stage->count; // Update the count + } + } + } + + // Calculate the total count of all stages + $totalCount = array_sum(array_column($stageCounts, 'count')); + + // Calculate the percentage for each stage + foreach ($stageCounts as &$countData) { + if ($totalCount > 0) { + $countData['percentage'] = ($countData['count'] / $totalCount) * 100; + } + } + + return $stageCounts; + } } diff --git a/config/app.php b/config/app.php index dba8da36cd..f94062de38 100644 --- a/config/app.php +++ b/config/app.php @@ -280,6 +280,9 @@ 'custom_executors' => env('CUSTOM_EXECUTORS', false), + // Enable or disable TCE customization feature + 'tce_customization_enable' => env('TCE_CUSTOMIZATION_ENABLED', false), + 'prometheus_namespace' => env('PROMETHEUS_NAMESPACE', strtolower(preg_replace('/[^a-zA-Z0-9_]+/', '_', env('APP_NAME', 'processmaker')))), 'server_timing' => [ diff --git a/database/migrations/2025_04_23_162421_add_stages_to_processes_and_process_versions.php b/database/migrations/2025_04_23_162421_add_stages_to_processes_and_process_versions.php new file mode 100644 index 0000000000..84cf210cfb --- /dev/null +++ b/database/migrations/2025_04_23_162421_add_stages_to_processes_and_process_versions.php @@ -0,0 +1,35 @@ +json('stages')->nullable(); + }); + Schema::table('process_versions', function (Blueprint $table) { + $table->json('stages')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('processes', function (Blueprint $table) { + $table->dropColumn('stages'); + }); + + Schema::table('process_versions', function (Blueprint $table) { + $table->dropColumn('stages'); + }); + } +}; diff --git a/database/migrations/2025_04_23_183734_add_stage_id_and_stage_name_to_process_request_tokens.php b/database/migrations/2025_04_23_183734_add_stage_id_and_stage_name_to_process_request_tokens.php new file mode 100644 index 0000000000..91dcfdf4e6 --- /dev/null +++ b/database/migrations/2025_04_23_183734_add_stage_id_and_stage_name_to_process_request_tokens.php @@ -0,0 +1,38 @@ +integer('stage_id')->nullable(); + // Add a 'stage_name' column to store the name of the current stage + // This will facilitate the identification of the stage + $table->string('stage_name')->nullable(); + // This column will be used to display the percentage of advancement of the token through the stages. + $table->float('progress')->default(0)->nullable(); + $table->decimal('amount', 10, 2)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('process_request_tokens', function (Blueprint $table) { + $table->dropColumn('stage_id'); + $table->dropColumn('stage_name'); + $table->dropColumn('progress'); + $table->dropColumn('amount'); + }); + } +}; diff --git a/database/migrations/2025_04_23_183839_add_stage_id_and_stage_name_to_process_request.php b/database/migrations/2025_04_23_183839_add_stage_id_and_stage_name_to_process_request.php new file mode 100644 index 0000000000..abc2092d4f --- /dev/null +++ b/database/migrations/2025_04_23_183839_add_stage_id_and_stage_name_to_process_request.php @@ -0,0 +1,33 @@ +integer('last_stage_id')->nullable(); + $table->string('last_stage_name')->nullable(); + // This column will be used to display the percentage of advancement of the request through the stages. + $table->float('progress')->default(0)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('process_requests', function (Blueprint $table) { + $table->dropColumn('last_stage_id'); + $table->dropColumn('last_stage_name'); + $table->dropColumn('progress'); + }); + } +}; diff --git a/database/migrations/2025_04_23_184251_add_stage_id_and_stage_name_to_cases_participated.php b/database/migrations/2025_04_23_184251_add_stage_id_and_stage_name_to_cases_participated.php new file mode 100644 index 0000000000..a054719cfd --- /dev/null +++ b/database/migrations/2025_04_23_184251_add_stage_id_and_stage_name_to_cases_participated.php @@ -0,0 +1,33 @@ +integer('last_stage_id')->nullable(); + $table->string('last_stage_name')->nullable(); + // This column will be used to display the percentage of advancement of the case through the stages. + $table->float('progress')->default(0)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cases_participated', function (Blueprint $table) { + $table->dropColumn('last_stage_id'); + $table->dropColumn('last_stage_name'); + $table->dropColumn('progress'); + }); + } +}; diff --git a/database/migrations/2025_04_23_184327_add_stage_id_and_stage_name_to_cases_started.php b/database/migrations/2025_04_23_184327_add_stage_id_and_stage_name_to_cases_started.php new file mode 100644 index 0000000000..1788c9d0b4 --- /dev/null +++ b/database/migrations/2025_04_23_184327_add_stage_id_and_stage_name_to_cases_started.php @@ -0,0 +1,33 @@ +integer('last_stage_id')->nullable(); + $table->string('last_stage_name')->nullable(); + // This column will be used to display the percentage of advancement of the case through the stages. + $table->float('progress')->default(0)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cases_started', function (Blueprint $table) { + $table->dropColumn('last_stage_id'); + $table->dropColumn('last_stage_name'); + $table->dropColumn('progress'); + }); + } +}; diff --git a/database/migrations/2025_04_25_222214_add_stages_table.php b/database/migrations/2025_04_25_222214_add_stages_table.php new file mode 100644 index 0000000000..66f972cbfb --- /dev/null +++ b/database/migrations/2025_04_25_222214_add_stages_table.php @@ -0,0 +1,33 @@ +id(); // Auto-incrementing ID for the stage + $table->unsignedInteger('process_id')->nullable(); // Foreign key for the process + $table->integer('process_version_id')->nullable(); + $table->string('stage_name'); // Name of the stage + $table->decimal('total_amount', 10, 2)->default(0); // Total amount accumulated for the stage + $table->timestamps(); // Created at and updated at timestamps + + // Foreign key constraint (assuming you have a processes table) + $table->foreign('process_id')->references('id')->on('processes')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stages'); + } +}; diff --git a/database/migrations/2025_05_19_205653_add_index_stage_id_for_requests_cases_and_tokens.php b/database/migrations/2025_05_19_205653_add_index_stage_id_for_requests_cases_and_tokens.php new file mode 100644 index 0000000000..ac6757ab55 --- /dev/null +++ b/database/migrations/2025_05_19_205653_add_index_stage_id_for_requests_cases_and_tokens.php @@ -0,0 +1,45 @@ +index('stage_id'); + }); + Schema::table('process_requests', function (Blueprint $table) { + $table->index('last_stage_id'); + }); + Schema::table('cases_participated', function (Blueprint $table) { + $table->index('last_stage_id'); + }); + Schema::table('cases_started', function (Blueprint $table) { + $table->index('last_stage_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('process_request_tokens', function (Blueprint $table) { + $table->dropIndex(['stage_id']); + }); + Schema::table('process_requests', function (Blueprint $table) { + $table->dropIndex(['last_stage_id']); + }); + Schema::table('cases_participated', function (Blueprint $table) { + $table->dropIndex(['last_stage_id']); + }); + Schema::table('cases_started', function (Blueprint $table) { + $table->dropIndex(['last_stage_id']); + }); + } +}; diff --git a/database/migrations/2025_05_20_145423_add_aggregation_var_to_process_and_process_versions.php b/database/migrations/2025_05_20_145423_add_aggregation_var_to_process_and_process_versions.php new file mode 100644 index 0000000000..70c91266bc --- /dev/null +++ b/database/migrations/2025_05_20_145423_add_aggregation_var_to_process_and_process_versions.php @@ -0,0 +1,33 @@ +string('aggregation')->default('amount')->nullable(); + }); + Schema::table('process_versions', function (Blueprint $table) { + $table->string('aggregation')->default('amount')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('processes', function (Blueprint $table) { + $table->dropColumn('aggregation'); + }); + Schema::table('process_versions', function (Blueprint $table) { + $table->dropColumn('aggregation'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4109049cd6..d99cbf8465 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -30,6 +30,7 @@ public function run() SignalSeeder::class, SettingsMenusSeeder::class, ScreenEmailSeeder::class, + MetricsApiEnvironmentVariableSeeder::class, ]); $this->callPluginSeeders(); } diff --git a/database/seeders/MetricsApiEnvironmentVariableSeeder.php b/database/seeders/MetricsApiEnvironmentVariableSeeder.php new file mode 100644 index 0000000000..fbc7cf44be --- /dev/null +++ b/database/seeders/MetricsApiEnvironmentVariableSeeder.php @@ -0,0 +1,27 @@ + 'METRICS_API_ENDPOINT', + ], + [ + 'description' => 'API endpoint for retrieving process metrics example: /api/1.0/package-plg/processes/{process}/metrics', + 'value' => '/api/1.0/processes/{process}/metrics', + ] + ); + } +} diff --git a/devhub/pm-font/svg/check-circle-blue.svg b/devhub/pm-font/svg/check-circle-blue.svg new file mode 100644 index 0000000000..9bb8c343c3 --- /dev/null +++ b/devhub/pm-font/svg/check-circle-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/devhub/pm-font/svg/pen-edit.svg b/devhub/pm-font/svg/pen-edit.svg new file mode 100644 index 0000000000..1d8680feb6 --- /dev/null +++ b/devhub/pm-font/svg/pen-edit.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/devhub/pm-font/svg/trash-blue.svg b/devhub/pm-font/svg/trash-blue.svg new file mode 100644 index 0000000000..9a078b0905 --- /dev/null +++ b/devhub/pm-font/svg/trash-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/helpers.php b/helpers.php index 6a284aa690..203e2322c8 100644 --- a/helpers.php +++ b/helpers.php @@ -279,6 +279,52 @@ function shouldShow($element) } } +if (!function_exists('calculateProgressById')) { + /** + * Calculates the progress percentage of a stage based on its order + * within the total number of stages. + * + * This function searches for the stage with the given ID in the provided list + * of stages. Each stage must contain at least 'id' and 'order' keys. + * If the stage is found, it calculates the progress as: + * (stage_order / total_stages) * 100 + * + * The result is rounded to two decimal places. + * + * If the list of stages is `null` or empty, or if the stage with the given ID + * is not found, the function returns 0.0. + * + * @param int|null $id The ID of the stage to calculate progress for. + * @param array|null $stages An array of stages, where each stage is an associative array + * containing at least the keys 'id' and 'order'. + * This parameter can be null. + * + * @return float The progress percentage (0.0 to 100.0), rounded to two decimal places. + */ + function calculateProgressById(int|null $id, array|null $stages = []) + { + if (empty($stages)) { + return 0.0; + } + $totalStages = count($stages); + // Consider the completed stage as the last one + $totalStages++; + $currentStageOrder = 0; + foreach ($stages as $stage) { + if ($stage['id'] === $id) { + $currentStageOrder = $stage['order']; + break; + } + } + if ($currentStageOrder === 0) { + return 0.0; + } + $progress = ($currentStageOrder / $totalStages) * 100; + + return round($progress, 2); + } +} + if (!function_exists('validateURL')) { /** * Validate the URL diff --git a/resources/fonts/pm-font/index.html b/resources/fonts/pm-font/index.html index c4e368bce7..90ee73e5e7 100644 --- a/resources/fonts/pm-font/index.html +++ b/resources/fonts/pm-font/index.html @@ -103,7 +103,7 @@
-

ProcessMaker Icons4.14.0

+

ProcessMaker Icons4.14.2

Icons generated with svgtofont. For add new icons, please check the README file
@@ -115,7 +115,7 @@

ProcessMaker Icons4.14.0

-

ProcessMaker Icons4.14.0

+

ProcessMaker Icons4.14.2

Icons generated with svgtofont. For add new icons, please check the README file
@@ -254,6 +254,13 @@

fp-bpmn-text-annotation

fp-brush-icon

+
  • + +

    fp-check-circle-blue

    +
  • +
  • fp-mobile

    fp-pdf

  • +
  • + +

    fp-pen-edit

    +
  • +
  • fp-table

    fp-tachometer-alt-average

  • +
  • + +

    fp-trash-blue

    +
  • +
  • -

    ProcessMaker Icons4.14.0

    +

    ProcessMaker Icons4.14.2

    Icons generated with svgtofont. For add new icons, please check the README file
    @@ -134,7 +134,7 @@

    ProcessMaker Icons4.14.0

      -
    • add-outlined

      
    • arrow-left

      
    • box-arrow-up-right

      
    • bpmn-action-by-email

      
    • bpmn-data-connector

      
    • bpmn-data-object

      
    • bpmn-data-store

      
    • bpmn-docusign

      
    • bpmn-end-event

      
    • bpmn-flowgenie

      
    • bpmn-gateway

      
    • bpmn-generic-gateway

      
    • bpmn-idp

      
    • bpmn-intermediate-event

      
    • bpmn-pool

      
    • bpmn-send-email

      
    • bpmn-start-event

      
    • bpmn-task

      
    • bpmn-text-annotation

      
    • brush-icon

      
    • close

      
    • cloud-download-outline

      
    • connector-outline

      
    • copy-outline

      
    • copy

      
    • desktop

      
    • edit-outline

      
    • expand

      
    • eye

      
    • fields-icon

      
    • flowgenie-outline

      
    • folder-outline

      
    • fullscreen

      
    • github

      
    • inbox

      
    • layout-icon

      
    • link-icon

      
    • map

      
    • minimize

      
    • mobile

      
    • pdf

      
    • play-outline

      
    • plus-thin

      
    • plus

      
    • pm-block

      
    • remove-outlined

      
    • screen-outline

      
    • script-outline

      
    • slack-notification

      
    • slack

      
    • slideshow

      
    • table

      
    • tachometer-alt-average

      
    • trash

      
    • unlink

      
    • update-outline

      
    • +
    • add-outlined

      
    • arrow-left

      
    • box-arrow-up-right

      
    • bpmn-action-by-email

      
    • bpmn-data-connector

      
    • bpmn-data-object

      
    • bpmn-data-store

      
    • bpmn-docusign

      
    • bpmn-end-event

      
    • bpmn-flowgenie

      
    • bpmn-gateway

      
    • bpmn-generic-gateway

      
    • bpmn-idp

      
    • bpmn-intermediate-event

      
    • bpmn-pool

      
    • bpmn-send-email

      
    • bpmn-start-event

      
    • bpmn-task

      
    • bpmn-text-annotation

      
    • brush-icon

      
    • check-circle-blue

      
    • close

      
    • cloud-download-outline

      
    • connector-outline

      
    • copy-outline

      
    • copy

      
    • desktop

      
    • edit-outline

      
    • expand

      
    • eye

      
    • fields-icon

      
    • flowgenie-outline

      
    • folder-outline

      
    • fullscreen

      
    • github

      
    • inbox

      
    • layout-icon

      
    • link-icon

      
    • map

      
    • minimize

      
    • mobile

      
    • pdf

      
    • pen-edit

      
    • play-outline

      
    • plus-thin

      
    • plus

      
    • pm-block

      
    • remove-outlined

      
    • screen-outline

      
    • script-outline

      
    • slack-notification

      
    • slack

      
    • slideshow

      
    • table

      
    • tachometer-alt-average

      
    • trash-blue

      
    • trash

      
    • unlink

      
    • update-outline

      
    -
    +
    @@ -43,6 +46,7 @@ :allow-empty="false" @open="retrieveDisplayScreen" @search-change="retrieveDisplayScreen" + @input="changeSelectedScreen" > @@ -167,17 +157,21 @@ import Modal from "./Modal.vue"; import IconDropdown from "./IconDropdown.vue"; import InputImageCarousel from "./InputImageCarousel.vue"; -import ColumnChooser from "./ColumnChooser.vue"; +import EditColumnModal from "./EditColumnModal.vue"; + +const isTceCustomization = () => window.ProcessMaker?.isTceCustomization; export default { - components: { Modal, IconDropdown, InputImageCarousel, ColumnChooser }, + components: { + Modal, IconDropdown, InputImageCarousel, EditColumnModal, + }, props: { options: { type: Object, - default: { + default: () => ({ id: "", type: "", - }, + }), }, filter: { type: String, @@ -199,6 +193,10 @@ export default { type: Array, default: () => [], }, + myCasesColumns: { + type: Array, + default: () => [], + }, }, data() { return { @@ -208,6 +206,23 @@ export default { description: "", errors: "", selectedSavedChart: null, + tceScreens: [ + { + id: "tce-student", + uuid: "", + title: this.$t("Distribution Bar Fin Aid Student"), + }, + { + id: "tce-college", + uuid: "", + title: this.$t("Distribution Bar Fin Aid College"), + }, + { + id: "tce-grants", + uuid: "", + title: this.$t("Distribution Bar Grants"), + }, + ], defaultScreen: { id: 0, uuid: "", @@ -248,14 +263,56 @@ export default { }, ], tabs: [], + columnListing: {}, + typeListing: "", myTasks: { currentColumns: [], availableColumns: [], defaultColumns: [], dataColumns: [], }, + myCases: { + currentColumns: [], + availableColumns: [], + defaultColumns: [], + dataColumns: [], + }, + ScreenDefaultId: [0, "tce-student", "tce-college", "tce-grants"], }; }, + computed: { + isEditColumns() { + if (this.ScreenDefaultId.includes(this.selectedScreen?.id)) { + return true; + } + return false; + }, + isTCEScreen() { + if (this.selectedScreen?.id === 0) { + return false; + } + if (this.ScreenDefaultId.includes(this.selectedScreen?.id)) { + return true; + } + return false; + }, + showTasks() { + if (this.isEditColumns && !this.isTCEScreen) { + return true; + } + return false; + }, + showCases() { + if (this.isEditColumns && this.isTCEScreen) { + return true; + } + if (this.isEditColumns && !this.isTCEScreen) { + // This was not implemented now + return false; + } + return false; + }, + }, mounted() { this.retrieveSavedSearchCharts(); this.retrieveDisplayScreen(); @@ -275,44 +332,29 @@ export default { .then((response) => { const firstResponse = response.data.shift(); const unparseProperties = firstResponse?.launchpad?.properties; - const launchpadProperties = unparseProperties - ? JSON.parse(unparseProperties) - : ""; + const launchpadProperties = unparseProperties ? JSON.parse(unparseProperties) : ""; if (launchpadProperties !== "" && "tabs" in launchpadProperties) { this.tabs = launchpadProperties.tabs; } - if ( - launchpadProperties !== "" && - "my_tasks_columns" in launchpadProperties - ) { + if (launchpadProperties !== "" && "my_tasks_columns" in launchpadProperties) { this.myTasks.currentColumns = launchpadProperties.my_tasks_columns; } - if ( - launchpadProperties && - Object.keys(launchpadProperties).length > 0 - ) { - this.selectedSavedChart = - this.getSelectedSavedChartJSONFromResult(launchpadProperties); - this.selectedLaunchpadIcon = this.verifyProperty( - launchpadProperties.icon - ) + if (launchpadProperties !== "" && "my_cases_columns" in launchpadProperties) { + this.myCases.currentColumns = launchpadProperties.my_cases_columns; + } + if (launchpadProperties && Object.keys(launchpadProperties).length > 0) { + this.selectedSavedChart = this.getSelectedSavedChartJSONFromResult(launchpadProperties); + this.selectedLaunchpadIcon = this.verifyProperty(launchpadProperties.icon) ? this.defaultIcon : launchpadProperties.icon; - this.selectedLaunchpadIconLabel = this.verifyProperty( - launchpadProperties.icon_label - ) + this.selectedLaunchpadIconLabel = this.verifyProperty(launchpadProperties.icon_label) ? this.defaultIcon : launchpadProperties.icon_label; - this.selectedScreen = - this.getSelectedScreenJSONFromResult(launchpadProperties); + this.selectedScreen = this.getSelectedScreenJSONFromResult(launchpadProperties); this.$refs["icon-dropdown"].setIcon(this.selectedLaunchpadIcon); } else { - this.selectedSavedChart = this.getSelectedSavedChartJSON( - this.defaultChart - ); - this.selectedScreen = this.getSelectedScreenJSON( - this.defaultScreen - ); + this.selectedSavedChart = this.getSelectedSavedChartJSON(this.defaultChart); + this.selectedScreen = this.getSelectedScreenJSON(this.defaultScreen); } this.oldScreen = this.selectedScreen.id; // Load media into Carousel Container @@ -414,8 +456,7 @@ export default { */ saveProcessDescription() { if (!this.$refs["image-carousel"].checkImages()) return; - this.dataProcess.imagesCarousel = - this.$refs["image-carousel"].getImages(); + this.dataProcess.imagesCarousel = this.$refs["image-carousel"].getImages(); this.dataProcess.properties = JSON.stringify( { saved_chart_id: this.selectedSavedChart.id, @@ -427,9 +468,10 @@ export default { icon_label: this.selectedLaunchpadIconLabel, tabs: this.tabs, my_tasks_columns: this.myTasks.currentColumns, + my_cases_columns: this.myCases.currentColumns, }, null, - 1 + 1, ); ProcessMaker.apiClient @@ -443,7 +485,7 @@ export default { this.$t("The launchpad settings were saved."), "success", 5, - true + true, ); const params = { indexImage: null, @@ -452,13 +494,14 @@ export default { if (this.oldScreen !== this.selectedScreen.id) { ProcessMaker.EventBus.$emit( "reloadByNewScreen", - this.selectedScreenId + this.selectedScreenId, ); } ProcessMaker.EventBus.$emit("getLaunchpadImagesEvent", params); ProcessMaker.EventBus.$emit("getChartId", this.selectedSavedChart.id); this.customModalButtons[1].disabled = false; this.$emit("updateMyTasksColumns", this.myTasks.currentColumns); + this.$emit("updateMyCasesColumns", this.myCases.currentColumns); this.saveLaunchpadSettings(response.data); this.hideModal(); }) @@ -484,9 +527,9 @@ export default { const filter = query === "" || query === null ? "" : `&filter=${query}`; ProcessMaker.apiClient .get( - "saved-searches?page=1&per_page=10&order_by=title&order_direction=asc" + - "&has=charts&include=charts&get=id,title,charts.id,charts.title,charts.saved_search_id,type" + - `${filter}` + "saved-searches?page=1&per_page=10&order_by=title&order_direction=asc" + + "&has=charts&include=charts&get=id,title,charts.id,charts.title,charts.saved_search_id,type" + + `${filter}`, ) .then((response) => { if (response.data.data[0].charts) { @@ -514,7 +557,7 @@ export default { const filter = query === "" || query === null ? "" : `&filter=${query}`; ProcessMaker.apiClient .get( - `screens?page=1&per_page=10&order_by=title&order_direction=asc&include=categories,category&exclude=config&type=DISPLAY${filter}` + `screens?page=1&per_page=10&order_by=title&order_direction=asc&include=categories,category&exclude=config&type=DISPLAY${filter}`, ) .then((response) => { if (response.data.data) { @@ -523,7 +566,12 @@ export default { title: item.title, uuid: item.uuid, })); - this.dropdownSavedScreen = [this.defaultScreen].concat(resultArray); + + this.dropdownSavedScreen = [this.defaultScreen]; + if (isTceCustomization()) { + this.dropdownSavedScreen = this.dropdownSavedScreen.concat(this.tceScreens); + } + this.dropdownSavedScreen = this.dropdownSavedScreen.concat(resultArray); } }) .catch((error) => { @@ -536,8 +584,7 @@ export default { getDescriptionInitial() { if (this.origin !== "core") { if (ProcessMaker.modeler?.process) { - this.processDescriptionInitial = - ProcessMaker.modeler.process.description; + this.processDescriptionInitial = ProcessMaker.modeler.process.description; } } else { this.processDescriptionInitial = this.descriptionSettings; @@ -571,49 +618,474 @@ export default { * This method shows a modal window to edit the columns of the My Tasks list. * If you don't use the nextTick method, the modal will not be displayed correctly. */ - showEditTaskColumn() { - this.$refs["editTaskColumn"].show(); + showEditColumn(type) { + this.columnListing = type === "tasks" ? this.myTasks : this.myCases; + this.typeListing = type; + this.$refs.editColumnModal.showModal(); this.$nextTick(() => { - this.getMyTasksColumns(); + this.getMyColumns(type); }); }, - async getMyTasksColumns() { - this.myTasks.currentColumns = this.myTasksColumns; - + async getMyColumns(type) { + if (this.isTCEScreen) { + this.changeSelectedScreen(); + } await ProcessMaker.apiClient - .get(`saved-searches/columns`) - .then((response) => { - if (response.data && response.data.default) { - this.myTasks.defaultColumns = response.data.default; - this.myTasks.defaultColumns.push({ + .get("saved-searches/columns") + .then((response) => { + this.columnListing.currentColumns = type === "tasks" ? this.myTasksColumns : this.myCasesColumns; + if (this.isTCEScreen) { + if (response.data) { + if (response.data.default) { + this.columnListing.defaultColumns = response.data.default; + } + if (response.data.available) { + const current = this.columnListing.currentColumns; + const available = response.data.available; + const result = available.filter(b => !current.some(a => a.field === b.field)); + this.columnListing.availableColumns = result; + } + if (response.data.data) { + this.columnListing.dataColumns = response.data.data; + } + } + } else { + if (response.data) { + if (response.data.default) { + this.columnListing.defaultColumns = response.data.default; + this.columnListing.defaultColumns.push({ field: "options", label: "", sortable: false, - width: 180 - }); - } - if (response.data) { + width: 180, + }); + } if (response.data.available) { - this.myTasks.availableColumns = response.data.available; + this.columnListing.availableColumns = response.data.available; - //Merge all available and default columns; we use map to avoid duplicates. + // Merge all available and default columns; we use map to avoid duplicates. const allColumns = new Map([ - ...this.myTasks.defaultColumns.map(col => [col.field, col]), - ...this.myTasks.availableColumns.map(col => [col.field, col]) + ...this.columnListing.defaultColumns.map((col) => [col.field, col]), + ...this.columnListing.availableColumns.map((col) => [col.field, col]), ]); - //Filter only those that are not in `currentColumns`. - this.myTasks.availableColumns = [...allColumns.values()].filter( - column => !this.myTasks.currentColumns.some( - currentColumn => currentColumn.field === column.field - ) + // Filter only those that are not in `currentColumns`. + this.columnListing.availableColumns = [...allColumns.values()].filter( + (column) => !this.columnListing.currentColumns.some( + (currentColumn) => currentColumn.field === column.field, + ), ); } if (response.data.data) { - this.myTasks.dataColumns = response.data.data; + this.columnListing.dataColumns = response.data.data; } } - }); + } + }); + }, + updateColumns(columns, type) { + if (type === "tasks") { + this.myTasks.currentColumns = columns; + } else { + this.myCases.currentColumns = columns; + } + }, + changeSelectedScreen() { + if (this.selectedScreen.id === 'tce-student') { + this.myCases.currentColumns = this.getTceStudent(); + return; + } + if (this.selectedScreen.id === 'tce-college') { + this.myCases.currentColumns = this.getTceCollege(); + return; + } + if (this.selectedScreen.id === 'tce-grants') { + this.myCases.currentColumns = this.getTceGrants(); + return; + } + this.myCases.currentColumns = this.getDefaultColumns(); + }, + getDefaultColumns() { + return [ + { + label: "Case #", + field: "case_number", + sortable: true, + default: true, + width: 80, + }, + { + label: "Case title", + field: "case_title", + sortable: true, + default: true, + truncate: true, + width: 220, + }, + { + label: "Status", + field: "status", + sortable: true, + default: true, + width: 100, + filter_subject: { type: "Status" }, + }, + { + label: "Started", + field: "initiated_at", + format: "datetime", + sortable: true, + default: true, + width: 160, + }, + { + label: "Completed", + field: "completed_at", + format: "datetime", + sortable: true, + default: true, + width: 160, + }, + ]; + }, + /** + * column = [ + * 'case_number' + * 'case_title' + * 'tasks' + * 'status' + * 'last_stage_name' + * 'progress' + * 'data.program.name' + * 'data.program.type' + * 'data.program.source' + * 'data.program.deadline' + * 'data.program.amount' + * 'data.program.status' + * ]; + * @returns {Array} + */ + getTceStudent() { + return [ + { + label: "Case #", + field: "case_number", + sortable: true, + default: true, + width: 80, + }, + { + label: "Case title", + field: "case_title", + sortable: true, + default: true, + truncate: true, + width: 220, + }, + { + label: "Tasks", + field: "active_tasks", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Status", + field: "status", + sortable: true, + default: true, + width: 80, + filter_subject: { type: "Status" }, + }, + { + label: "Last Stage Name", + field: "last_stage_name", + sortable: false, + default: true, + truncate: true, + width: 110, + }, + { + label: "Progress", + field: "progress", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Name", + field: "data.name", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Type", + field: "data.type", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Source", + field: "data.source", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Deadline", + field: "data.deadline", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Amount", + field: "data.amount", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Status", + field: "data.status", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + ]; + }, + /** + * column = [ + * 'case_number' + * 'case_title' + * 'tasks' + * 'status' + * 'last_stage_name' + * 'progress' + * 'data.program' + * 'data.type' + * 'data.source' + * 'data.deadline' + * 'data.amount' + * ]; + * @returns {Array} + */ + getTceCollege() { + return [ + { + label: "Case #", + field: "case_number", + sortable: true, + default: true, + width: 80, + }, + { + label: "Case title", + field: "case_title", + sortable: true, + default: true, + truncate: true, + width: 220, + }, + { + label: "Tasks", + field: "active_tasks", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Status", + field: "status", + sortable: true, + default: true, + width: 80, + filter_subject: { type: "Status" }, + }, + { + label: "Last Stage Name", + field: "last_stage_name", + sortable: false, + default: true, + truncate: true, + width: 110, + }, + { + label: "Progress", + field: "progress", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Program", + field: "data.program", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Type", + field: "data.type", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Source", + field: "data.source", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Deadline", + field: "data.deadline", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Amount", + field: "data.amount", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + ]; + }, + /** + * column = [ + * 'case_number' + * 'case_title' + * 'tasks' + * 'status' + * 'last_stage_name' + * 'progress' + * 'data.applicationId' + * 'data.title' + * 'data.department' + * 'data.primaryInvestigator' + * 'data.agency' + * 'data.dueDate' + * ]; + * @returns {Array} + */ + getTceGrants() { + return [ + { + label: "Case #", + field: "case_number", + sortable: true, + default: true, + width: 80, + }, + { + label: "Case title", + field: "case_title", + sortable: true, + default: true, + truncate: true, + width: 220, + }, + { + label: "Tasks", + field: "active_tasks", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Status", + field: "status", + sortable: true, + default: true, + width: 80, + filter_subject: { type: "Status" }, + }, + { + label: "Last Stage Name", + field: "last_stage_name", + sortable: false, + default: true, + truncate: true, + width: 110, + }, + { + label: "Progress", + field: "progress", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Application Id", + field: "data.applicationId", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Title", + field: "data.title", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Department", + field: "data.department", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Primary Investigator", + field: "data.primaryInvestigator", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Agency", + field: "data.agency", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + { + label: "Due Date", + field: "data.dueDate", + sortable: false, + default: true, + truncate: true, + width: 100, + }, + ]; }, }, }; diff --git a/resources/js/processes-catalogue/components/BaseChart.vue b/resources/js/processes-catalogue/components/BaseChart.vue index e65130d731..f677604381 100644 --- a/resources/js/processes-catalogue/components/BaseChart.vue +++ b/resources/js/processes-catalogue/components/BaseChart.vue @@ -310,7 +310,6 @@ export default { diff --git a/resources/js/processes-catalogue/components/Process.vue b/resources/js/processes-catalogue/components/Process.vue index 1096491bcc..07b2caeeb8 100644 --- a/resources/js/processes-catalogue/components/Process.vue +++ b/resources/js/processes-catalogue/components/Process.vue @@ -1,39 +1,49 @@ @@ -44,12 +54,14 @@ import MiniPieChart from "./MiniPieChart.vue"; import Bookmark from "./Bookmark.vue"; import ProcessDescription from "./optionsMenu/ProcessDescription.vue"; import ProcessCounter from "./optionsMenu/ProcessCounter.vue"; +import CustomHomeScreen from "./home/CustomHomeScreen.vue"; +import { isTceCustomization } from "./variables"; export default { - props: ["process", "processId", "ellipsisPermission"], components: { - ProcessInfo, ProcessScreen, MiniPieChart, Bookmark, ProcessDescription, ProcessCounter + ProcessInfo, ProcessScreen, MiniPieChart, Bookmark, ProcessDescription, ProcessCounter, CustomHomeScreen, }, + props: ["process", "processId", "ellipsisPermission"], data() { return { loadedProcess: null, @@ -59,7 +71,7 @@ export default { computed: { /** * if we pass in a process, use that. Otherwise load the process by ID - **/ + * */ selectedProcess() { if (this.process) { return this.process; @@ -74,14 +86,24 @@ export default { /** * Verify if the process open the info or Screen */ - verifyScreen() { + verifyScreen() { let screenId = 0; const unparseProperties = this.selectedProcess?.launchpad?.properties || null; if (unparseProperties !== null) { screenId = JSON.parse(unparseProperties)?.screen_id || 0; } - return screenId !== 0; + const validScreen = ["tce-student", "tce-college", "tce-grants"]; + + if (validScreen.includes(screenId) && isTceCustomization()) { + return "custom"; + } + + if (screenId !== 0) { + return "screen"; + } + + return "default"; }, processVersion() { return moment(this.selectedProcess.updated_at).format(); @@ -90,11 +112,6 @@ export default { return this.selectedProcess.counts?.total || 0; }, }, - methods: { - goBackCategory() { - this.$emit("goBackCategory"); - }, - }, mounted() { if (!this.process && this.processId) { ProcessMaker.apiClient @@ -113,14 +130,16 @@ export default { window.ProcessMaker.navbarMobile.display = true; } }, + methods: { + goBackCategory() { + this.$emit("goBackCategory"); + }, + }, }; \ No newline at end of file +} diff --git a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue index 36954a5443..26ece215b9 100644 --- a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue +++ b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue @@ -1,47 +1,65 @@ @@ -83,13 +103,12 @@ import CreateTemplateModal from "../../components/templates/CreateTemplateModal. import CreatePmBlockModal from "../../components/pm-blocks/CreatePmBlockModal.vue"; import AddToProjectModal from "../../components/shared/AddToProjectModal.vue"; import LaunchpadSettingsModal from "../../components/shared/LaunchpadSettingsModal.vue"; -import ProcessesCarousel from "./ProcessesCarousel.vue"; -import ProcessOptions from "./ProcessOptions.vue"; import ellipsisMenuMixin from "../../components/shared/ellipsisMenuActions"; import processNavigationMixin from "../../components/shared/processNavigation"; import ProcessesMixin from "./mixins/ProcessesMixin"; -import ProcessHeader from "./ProcessHeader.vue"; -import ProcessHeaderStart from "./ProcessHeaderStart.vue"; +import ButtonsStart from "./optionsMenu/ButtonsStart.vue"; +import EllipsisMenu from "../../components/shared/EllipsisMenu.vue"; +import Bookmark from "./Bookmark.vue"; export default { components: { @@ -97,19 +116,28 @@ export default { CreatePmBlockModal, AddToProjectModal, LaunchpadSettingsModal, - ProcessOptions, - ProcessesCarousel, - ProcessHeader, - ProcessHeaderStart, + ButtonsStart, + EllipsisMenu, + Bookmark, }, mixins: [ProcessesMixin, ellipsisMenuMixin, processNavigationMixin], - props: ["process", "currentUserId", "ellipsisPermission", "myTasksColumns"], + props: ["process", "currentUserId", "ellipsisPermission", "myTasksColumns", "myCasesColumns"], + data() { + return { + mobileApp: window.ProcessMaker.mobileApp, + showProcessInfo: false, + collapsed: true, + infoCollapsed: true, + processEvents: [], + singleStartEvent: null, + }; + }, computed: { createdFromWizardTemplate() { return !!this.process?.properties?.wizardTemplateUuid; }, isArchived() { - return this.process?.status === 'ARCHIVED'; + return this.process?.status === "ARCHIVED"; }, wizardTemplateUuid() { return this.process?.properties?.wizardTemplateUuid; @@ -117,16 +145,10 @@ export default { }, mounted() { this.verifyDescription(); - ProcessMaker.EventBus.$on("reloadByNewScreen", (newScreen) => { + ProcessMaker.EventBus.$on("reloadByNewScreen", () => { window.location.reload(); }); - }, - data() { - return { - mobileApp: window.ProcessMaker.mobileApp, - showProcessInfo: false, - collapsed: true, - }; + this.getStartEvents(); }, methods: { /** @@ -148,13 +170,46 @@ export default { }, toggleInfo() { this.showProcessInfo = !this.showProcessInfo; + this.$emit("toggle-info"); + }, + setShowProcessInfo(show) { + this.showProcessInfo = show; }, updateMyTasksColumns(columns) { - this.$emit('updateMyTasksColumns', columns); + this.$emit("updateMyTasksColumns", columns); + }, + updateMyCasesColumns(columns) { + this.$emit("updateMyCasesColumns", columns); }, onProcessInfoCollapsed(collapsed) { this.collapsed = collapsed; }, + ellipsisNavigate(action, data) { + this.onProcessNavigate(action, data); + }, + /** + * get start events for dropdown Menu + */ + getStartEvents() { + this.processEvents = []; + ProcessMaker.apiClient + .get(`process_bookmarks/processes/${this.process.id}/start_events`) + .then((response) => { + this.processEvents = response.data.data; + if (this.processEvents.length === 0) { + ProcessMaker.alert(this.$t("The current user does not have permission to start this process"), "danger"); + } + const nonWebEntryStartEvents = this.processEvents.filter( + (e) => !("webEntry" in e) || !e.webEntry, + ); + if (nonWebEntryStartEvents.length === 1 && this.processEvents.length === 1) { + this.singleStartEvent = nonWebEntryStartEvents[0].id; + } + }) + .catch((err) => { + ProcessMaker.alert(err, "danger"); + }); + }, }, }; @@ -250,4 +305,103 @@ export default { align-items: center; } } + +/* Estilos integrados de ProcessHeaderStart */ +.header-mobile .title, +.header .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.flex-grow-1 { + flex-grow: 1; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.d-flex.align-items-center { + min-width: 0; +} + +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.card-bookmark { + float: right; + width: 20px; + height: 23px; +} + +.card-bookmark:hover { + cursor: pointer; +} + +.card-custom { + background-color: #F6F9FB; + border: 1px solid rgba(205, 221, 238, 0.125); +} + +.title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + max-width: 100%; + font-size: 22px; + letter-spacing: -0.2; + color: #4C545C; + font-weight: 400; +} + +.custom-color { + color: #4C545C; +} + +.info-button { + width: 20px; + height: 20px; + background-color: #6A7887; + border: none; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: 700; + position: relative; + + span { + color: #ffffff; + font-size: 14px; + } +} + +.info-button-active { + background-color: #2773F3 !important; + + &::before { + content: ''; + position: absolute; + top: -8px; + left: -8px; + right: -8px; + bottom: -8px; + background-color: rgba(106, 120, 135, 0.1); + border-radius: 8px; + z-index: 0; + border: 1px solid #d3dbe2; + } +} + +.ellipsis-border div button span { + font-size: 16px; +} diff --git a/resources/js/processes-catalogue/components/ProcessInfo.vue b/resources/js/processes-catalogue/components/ProcessInfo.vue index d8d2a95179..689ed743b2 100644 --- a/resources/js/processes-catalogue/components/ProcessInfo.vue +++ b/resources/js/processes-catalogue/components/ProcessInfo.vue @@ -1,52 +1,75 @@ diff --git a/resources/js/processes-catalogue/components/ProcessTab.vue b/resources/js/processes-catalogue/components/ProcessTab.vue index 90c37ec8d8..65db6bb76d 100644 --- a/resources/js/processes-catalogue/components/ProcessTab.vue +++ b/resources/js/processes-catalogue/components/ProcessTab.vue @@ -1,437 +1,480 @@