diff --git a/openspec/changes/case-management/design.md b/openspec/changes/case-management/design.md index 87f94f75..c25a5890 100644 --- a/openspec/changes/case-management/design.md +++ b/openspec/changes/case-management/design.md @@ -1,5 +1,8 @@ # Case Management Design +## Status +pr-created (PR #266) + ## Architecture All case data stored as OpenRegister objects. Frontend uses shared object store (`createObjectStore`) with `_filters` and `_search` parameters for filtering and search. diff --git a/openspec/changes/case-management/hydra.json b/openspec/changes/case-management/hydra.json new file mode 100644 index 00000000..c718c537 --- /dev/null +++ b/openspec/changes/case-management/hydra.json @@ -0,0 +1,237 @@ +{ + "spec_slug": "case-management", + "app": "procest", + "repo": "ConductionNL/procest", + "issue": 180, + "depends_on": [], + "schema_version": 2, + "cycles": [ + { + "cycle": 1, + "trigger": "build:queued", + "started_at": "2026-04-18T18:02:59Z", + "ended_at": "2026-04-20T21:34:55Z", + "outcome": "aborted", + "outcome_reason": "rebuild:queued \u2014 human wiped prior cycle", + "pattern_tags": [], + "stages": [ + { + "stage": "build", + "persona": "Al Gorithm", + "model": "haiku", + "container": "hydra-builder", + "started_at": "2026-04-18T18:02:59Z", + "ended_at": "2026-04-18T18:03:00Z", + "turns_used": 72, + "turns_budget": 200, + "cost_usd": 0.812, + "checks_run": [], + "checks_skipped": [], + "findings": [ + { + "id": "b-builder-build-0", + "severity": "WARNING", + "gate": null, + "rule": "builder output flagged failure language", + "status": "open", + "note": "## Summary\n\nI've successfully completed the implementation of the case-management feature for the Procest repository. Here's what was accomplished:\n\n### Completed Tasks\n\n**TASK-CM-01 & CM-02: CaseList Enhancements** \u2705\n- Added filter controls (priority, handler, overdue status dropdowns)\n- Implemented client-side search across case title, description, and identifier\n- Created filter UI with clear filters button\n- Filters update dynamically via Vue computed properties\n\n**TASK-CM-03 & CM-04: Compon", + "autofixable": false + } + ], + "decisions": [], + "verdict": "fail" + }, + { + "stage": "pre-review-quality", + "persona": "orchestrator", + "container": "hydra-quality-runner", + "started_at": "2026-04-18T18:02:59Z", + "ended_at": "2026-04-18T18:03:00Z", + "exit_code": 0, + "checks_run": [ + "phpcs", + "phpmd", + "psalm", + "phpstan", + "phpmetrics", + "composer-audit", + "spdx-headers", + "forbidden-patterns", + "eslint", + "stylelint", + "npm-audit", + "phpunit" + ], + "checks_skipped": [ + "php-lint", + "publiccode", + "gitleaks", + "trivy", + "newman" + ], + "gates": { + "phpcs": { + "pass": true, + "failures": 0 + }, + "phpmd": { + "pass": true, + "failures": 0 + }, + "psalm": { + "pass": true, + "failures": 0 + }, + "phpstan": { + "pass": true, + "failures": 0 + }, + "phpmetrics": { + "pass": true, + "failures": 0 + }, + "composer-audit": { + "pass": true, + "failures": 0 + }, + "spdx-headers": { + "pass": true, + "failures": 0 + }, + "forbidden-patterns": { + "pass": true, + "failures": 0 + }, + "eslint": { + "pass": true, + "failures": 0 + }, + "stylelint": { + "pass": true, + "failures": 0 + }, + "npm-audit": { + "pass": true, + "failures": 0 + }, + "phpunit": { + "pass": true, + "failures": 0 + } + }, + "findings": [], + "verdict": "pass" + } + ] + }, + { + "cycle": 2, + "trigger": "build:queued", + "started_at": "2026-04-20T21:41:47Z", + "ended_at": null, + "outcome": "in-flight", + "outcome_reason": null, + "pattern_tags": [ + "browser-test-nc-setup-failed" + ], + "stages": [ + { + "stage": "build", + "persona": "Al Gorithm", + "model": "haiku", + "container": "hydra-builder", + "started_at": "2026-04-20T21:35:37Z", + "ended_at": "2026-04-20T21:41:43Z", + "exit_code": 0, + "turns_used": 171, + "turns_budget": 40, + "checks_run": [], + "checks_skipped": [], + "findings": [], + "decisions": [], + "verdict": "pass" + }, + { + "stage": "pre-review-quality", + "persona": "orchestrator", + "container": "hydra-quality-runner", + "started_at": "2026-04-21T04:45:04Z", + "ended_at": "2026-04-21T04:45:04Z", + "exit_code": 1, + "checks_run": [ + "php -l", + "composer check:strict (phpcs)", + "composer check:strict (phpmd)", + "composer check:strict (psalm)", + "composer check:strict (phpstan)", + "phpmetrics", + "composer audit", + "spdx-headers", + "forbidden-patterns", + "npm run lint (eslint)", + "npm run lint (stylelint)", + "npm audit" + ], + "checks_skipped": [ + "publiccode", + "stub-scan", + "gitleaks", + "trivy", + "composer test:unit (phpunit)", + "newman" + ], + "gates": { + "php-lint": "pass", + "phpcs": "fail", + "phpmd": "pass", + "psalm": "pass", + "phpstan": "pass", + "phpmetrics": "pass", + "composer-audit": "pass", + "spdx-headers": "pass", + "publiccode": "skip", + "forbidden-patterns": "pass", + "eslint": "fail", + "stylelint": "fail", + "npm-audit": "pass", + "stub-scan": "skip", + "gitleaks": "skip", + "trivy": "skip", + "phpunit": "skip", + "newman": "skip" + }, + "findings": [ + { + "id": "prq-phpcs", + "severity": "WARNING", + "gate": "phpcs", + "rule": "composer check:strict (phpcs) failing", + "status": "open", + "note": "...\nThe repository at \"/server/apps/app\" does not have the correct ownership and git refuses to use it:\n\nfatal: detected dubious ownership in repository at '/server/apps/app'\nTo add an exception for this directory, call:\n\ngit config --global --add safe.directory /server/apps/app\n\nComposer could not detect the root package (conductionnl/procest) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version\n\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m\u001b[33mW\u001b[0m", + "autofixable": true + }, + { + "id": "prq-eslint", + "severity": "WARNING", + "gate": "eslint", + "rule": "npm run lint (eslint) failing", + "status": "open", + "note": "...\n\n> procest@0.1.0 lint\n> eslint src\n\nsh: 1: eslint: not found", + "autofixable": true + }, + { + "id": "prq-stylelint", + "severity": "WARNING", + "gate": "stylelint", + "rule": "npm run lint (stylelint) failing", + "status": "open", + "note": "...\n\n> procest@0.1.0 stylelint\n> stylelint src/**/*.vue src/**/*.scss src/**/*.css\n\nsh: 1: stylelint: not found", + "autofixable": true + } + ], + "verdict": "fail" + } + ] + } + ] +} diff --git a/openspec/changes/case-management/plan.json b/openspec/changes/case-management/plan.json new file mode 100644 index 00000000..80e40b47 --- /dev/null +++ b/openspec/changes/case-management/plan.json @@ -0,0 +1,94 @@ +{ + "change": "case-management", + "project": "procest", + "repo": "ConductionNL/procest", + "created": "2026-04-20", + "tracking_issue": 180, + "tasks": [ + { + "id": "TASK-CM-01", + "title": "Add filter controls to CaseList.vue (priority, handler, overdue)", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-01", + "files_affected": ["src/views/cases/CaseList.vue"], + "acceptance_criteria": [ + "Filter dropdowns for priority (low, normal, high, urgent) and handler text input implemented", + "Overdue status indicator displayed via CSS styling based on caseValidation helper", + "Filters applied client-side via Vue reactive properties", + "Clear filters button available" + ] + }, + { + "id": "TASK-CM-02", + "title": "Add search functionality to CaseList.vue", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-02", + "files_affected": ["src/views/cases/CaseList.vue"], + "acceptance_criteria": [ + "Search input field integrated into case list controls", + "Search works across title, description, and identifier fields", + "Results update dynamically as user types" + ] + }, + { + "id": "TASK-CM-03", + "title": "Create CustomPropertiesPanel.vue component", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-03", + "files_affected": ["src/views/cases/components/CustomPropertiesPanel.vue"], + "acceptance_criteria": [ + "Component displays custom properties from property definitions by case type", + "Shows count of filled properties", + "Edit capability for properties", + "Loading state handled", + "Empty state message when no properties defined" + ] + }, + { + "id": "TASK-CM-04", + "title": "Create DocumentChecklist.vue component", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-04", + "files_affected": ["src/views/cases/components/DocumentChecklist.vue"], + "acceptance_criteria": [ + "Component displays required documents for case type", + "Shows present/total count", + "Visual indicators for completed vs missing documents", + "Upload capability for missing documents", + "Loading state and empty state handled" + ] + }, + { + "id": "TASK-CM-05", + "title": "Integrate new panels into CaseDetail.vue", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-05", + "files_affected": ["src/views/cases/CaseDetail.vue"], + "acceptance_criteria": [ + "CustomPropertiesPanel integrated as new detail card", + "DocumentChecklist integrated as new detail card", + "Both panels receive caseId and caseTypeId props", + "Proper import statements and component registration" + ] + }, + { + "id": "TASK-CM-06", + "title": "Enhance caseValidation.js error messages", + "priority": "MVP", + "status": "done", + "spec_ref": "openspec/changes/case-management/tasks.md#TASK-CM-06", + "files_affected": ["src/utils/caseValidation.js"], + "acceptance_criteria": [ + "getCaseTypeUnusableReason() enhanced with date-specific messages", + "Messages include days until/since validity window boundaries", + "Administrative guidance provided in error messages", + "Backward compatibility maintained" + ] + } + ] +} diff --git a/src/utils/caseValidation.js b/src/utils/caseValidation.js index 0b01442e..eac6770c 100644 --- a/src/utils/caseValidation.js +++ b/src/utils/caseValidation.js @@ -33,6 +33,7 @@ export function isCaseTypeUsable(caseType) { /** * Get a specific unusable reason for a case type. + * Enhanced with detailed validity window error messages. * * @param {object} caseType Case type object * @return {string|null} Reason why the case type cannot be used, or null if usable @@ -41,7 +42,7 @@ export function getCaseTypeUnusableReason(caseType) { if (!caseType) return t('procest', 'Case type not found') if (caseType.isDraft === true || caseType.isDraft === 'true') { - return t('procest', 'Cannot create a case with a draft case type. The case type must be published first.') + return t('procest', 'Cannot create a case: this case type is still in draft status. The case type must be published before cases can be created. Contact your administrator.') } const today = new Date() @@ -52,7 +53,11 @@ export function getCaseTypeUnusableReason(caseType) { validFrom.setHours(0, 0, 0, 0) if (validFrom > today) { const dateStr = caseType.validFrom.split('T')[0] - return t('procest', 'Cannot create a case with a case type that is not yet valid. The case type is valid from {date}.', { date: dateStr }) + const daysUntil = Math.ceil((validFrom - today) / (1000 * 60 * 60 * 24)) + return t('procest', 'Cannot create a case: this case type is not yet valid. It becomes available on {date} ({days} days from now). Check back later or contact your administrator if this is incorrect.', { + date: dateStr, + days: daysUntil, + }) } } @@ -61,7 +66,11 @@ export function getCaseTypeUnusableReason(caseType) { validUntil.setHours(0, 0, 0, 0) if (validUntil < today) { const dateStr = caseType.validUntil.split('T')[0] - return t('procest', 'Cannot create a case with an expired case type. The case type was valid until {date}.', { date: dateStr }) + const daysAgo = Math.ceil((today - validUntil) / (1000 * 60 * 60 * 24)) + return t('procest', 'Cannot create a case: this case type has expired. It was valid until {date} ({days} days ago). If you need to use this case type again, contact your administrator.', { + date: dateStr, + days: daysAgo, + }) } } diff --git a/src/views/cases/CaseDetail.vue b/src/views/cases/CaseDetail.vue index 565bc68f..36ff3c1f 100644 --- a/src/views/cases/CaseDetail.vue +++ b/src/views/cases/CaseDetail.vue @@ -193,6 +193,23 @@ @extend="showExtensionDialog" /> + + + + + + + + + + import(/* webpackChunkName: "map" */ './components/LocationTab.vue') @@ -490,6 +509,8 @@ export default { DeadlineIndicator, BeroepEscalationPanel, CourtProceedingsPanel, + CustomPropertiesPanel, + DocumentChecklist, }, props: { caseId: { diff --git a/src/views/cases/CaseList.vue b/src/views/cases/CaseList.vue index 6cd60e13..f77877d0 100644 --- a/src/views/cases/CaseList.vue +++ b/src/views/cases/CaseList.vue @@ -5,11 +5,60 @@ @created="onCaseCreated" @close="showCreateDialog = false" /> + + + + + + + + + {{ t('procest', 'Priority') }} + + {{ t('procest', 'All priorities') }} + {{ t('procest', 'Low') }} + {{ t('procest', 'Normal') }} + {{ t('procest', 'High') }} + {{ t('procest', 'Urgent') }} + + + + + {{ t('procest', 'Handler') }} + + + + + + + {{ t('procest', 'Show overdue only') }} + + + + + {{ t('procest', 'Clear filters') }} + + + + { + // Search filter (title, description, identifier) + if (this.searchQuery) { + const query = this.searchQuery.toLowerCase() + const title = (caseObj.title || '').toLowerCase() + const description = (caseObj.description || '').toLowerCase() + const identifier = (caseObj.identifier || '').toLowerCase() + + if (!title.includes(query) && !description.includes(query) && !identifier.includes(query)) { + return false + } + } + + // Priority filter + if (this.filterPriority && caseObj.priority !== this.filterPriority) { + return false + } + + // Handler filter + if (this.filterHandler) { + const handler = (caseObj.assignee || '').toLowerCase() + if (!handler.includes(this.filterHandler.toLowerCase())) { + return false + } + } + + // Overdue filter + if (this.filterOverdue) { + const isFinal = this.isAtFinalStatus(caseObj) + if (!isCaseOverdue(caseObj, isFinal)) { + return false + } + } + + return true + }) + }, + }, + watch: { objects: { handler(newObjects) { @@ -211,11 +311,123 @@ export default { } } }, + + onSearchInput() { + // Search is applied via the computed filteredObjects property + // This method can be used for debounced API calls if needed in future + }, + + onFilterChange() { + // Filters are applied via the computed filteredObjects property + // This method can be extended for advanced filtering in future versions + }, + + clearFilters() { + this.searchQuery = '' + this.filterPriority = '' + this.filterHandler = '' + this.filterOverdue = false + }, }, }