diff --git a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts index 6593200dd8f0..9d7229562934 100644 --- a/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts +++ b/frontend/src/app/features/bim/ifc_models/bcf/list/bcf-list.component.ts @@ -31,6 +31,7 @@ import { } from '@angular/core'; import { UIRouterGlobals } from '@uirouter/core'; import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { DragAndDropService } from 'core-app/shared/helpers/drag-and-drop/drag-and-drop.service'; @@ -129,8 +130,13 @@ export class BcfListComponent extends WorkPackageListViewComponent implements Un : 'bim.partitioned.show'; // Passing the card param to the new state because the router doesn't keep // it when going to 'bim.partitioned.show' - const params = { workPackageId, cards, focus }; + const routingId = this.resolveRoutingId(workPackageId); + const params = { workPackageId: routingId, cards, focus }; void this.$state.go(stateToGo, params); } + + private resolveRoutingId(workPackageId:string):string { + return resolveRoutingId(this.states, workPackageId); + } } diff --git a/frontend/src/app/features/boards/board/board-list/board-list.component.ts b/frontend/src/app/features/boards/board/board-list/board-list.component.ts index baa4f648b35a..535899192a0c 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list.component.ts +++ b/frontend/src/app/features/boards/board/board-list/board-list.component.ts @@ -26,6 +26,8 @@ import { AuthorisationService } from 'core-app/core/model-auth/model-auth.servic import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; import { WorkPackageCardViewComponent } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component'; import { WorkPackageStatesInitializationService } from 'core-app/features/work-packages/components/wp-list/wp-states-initialization.service'; +import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { BoardService } from 'core-app/features/boards/board/board.service'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; @@ -175,6 +177,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni readonly keepTab:KeepTabService, readonly currentProject:CurrentProjectService, readonly pathHelper:PathHelperService, + readonly states:States, ) { super(I18n, injector); } @@ -488,17 +491,19 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni openFullViewOnDoubleClick(event:{ workPackageId:string, double:boolean }) { if (event.double) { + const routingId = this.resolveRoutingId(event.workPackageId); const projectIdentifier = this.currentProject.identifier; - const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search; + const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; Turbo.visit(link, { action: 'advance' }); } } openStateLink(event:{ workPackageId:string; requestedState:string }) { + const routingId = this.resolveRoutingId(event.workPackageId); if (event.requestedState === 'split') { - this.goToSplitView(event.workPackageId); + this.goToSplitView(routingId); } else { - this.keepTab.goCurrentShowState(event.workPackageId); + this.keepTab.goCurrentShowState(routingId); } } @@ -509,6 +514,10 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni Turbo.visit(link, { frame: 'content-bodyRight', action: 'advance' }); } + private resolveRoutingId(workPackageId:string):string { + return resolveRoutingId(this.states, workPackageId); + } + private schema(workPackage:WorkPackageResource) { return this.schemaCache.of(workPackage); } diff --git a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts index 86dbd0c0d09d..8717cbde8231 100644 --- a/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts +++ b/frontend/src/app/features/calendar/op-work-packages-calendar.service.ts @@ -53,6 +53,8 @@ import { uiStateLinkClass, } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder'; import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { WorkPackageViewContextMenu, } from 'core-app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive'; @@ -112,6 +114,7 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { readonly calendarService:OpCalendarService, readonly weekdayService:WeekdayService, readonly dayService:DayResourceService, + readonly states:States, ) { super(); } @@ -287,21 +290,27 @@ export class OpWorkPackagesCalendarService extends UntilDestroyedMixin { return; } + const routingId = this.resolveRoutingId(id); void this.$state.go( `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: id, tabIdentifier: 'overview' }, + { workPackageId: routingId, tabIdentifier: 'overview' }, ); } public openFullView(id:string):void { this.wpTableSelection.setSelection(id, -1); + const routingId = this.resolveRoutingId(id); void this.$state.go( 'work-packages.show', - { workPackageId: id }, + { workPackageId: routingId }, ); } + private resolveRoutingId(workPackageId:string):string { + return resolveRoutingId(this.states, workPackageId); + } + public onCardClicked({ workPackageId, event }:{ workPackageId:string, event:MouseEvent }):void { if (isClickedWithModifier(event)) { return; diff --git a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts index e0f6f2f5f2cf..d1c36055438a 100644 --- a/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts +++ b/frontend/src/app/features/calendar/wp-calendar/wp-calendar.component.ts @@ -342,15 +342,16 @@ export class WorkPackagesCalendarComponent extends UntilDestroyedMixin implement } if (evt.event.extendedProps.workPackage) { - const workPackageId = (evt.event.extendedProps.workPackage as WorkPackageResource).id!; + const wp = evt.event.extendedProps.workPackage as WorkPackageResource; // Currently the calendar widget is shown on multiple pages, // but only the calendar module itself is a partitioned query space which can deal with a split screen request if (this.$state.includes('calendar')) { - this.workPackagesCalendar.openSplitView(workPackageId); + this.workPackagesCalendar.openSplitView(wp.id!); } else { + const routingId = wp.displayId ?? wp.id!; void this.$state.go( 'work-packages.show', - { workPackageId }, + { workPackageId: routingId }, ); } } diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index 70ea0c0272fd..e2133009e7b9 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -142,7 +142,10 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen this.untilDestroyed(), map(() => { if (this.selectedWhenOpen) { - return this.uiRouterGlobals.params.workPackageId === this.workPackage.id; + // Route param may be semantic ("PROJ-7") or numeric ("42"). + // Compare against both id and displayId to handle both modes. + const routeWpId = this.uiRouterGlobals.params.workPackageId; + return routeWpId === this.workPackage.id || routeWpId === this.workPackage.displayId; } return this.wpTableSelection.isSelected(this.workPackage.id!); diff --git a/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts b/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts index 77a0449e7f83..c4c920219448 100644 --- a/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts @@ -130,15 +130,16 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O this.editForm?.cancel(false); + const routingId = savedResource.displayId ?? savedResource.id!; if(this.routedFromAngular && this.successState) { - this.$state.go(this.successState, { workPackageId: savedResource.id }) + this.$state.go(this.successState, { workPackageId: routingId }) .then(() => { this.wpViewFocus.updateFocus(savedResource.id!); this.notificationService.showSave(savedResource, isInitial); }); } else { window.OpenProject.pageState = 'submitted'; - Turbo.visit(this.pathHelper.projectWorkPackagePath(savedResource.project.identifier, savedResource.id!) + window.location.search); + Turbo.visit(this.pathHelper.projectWorkPackagePath(savedResource.project.identifier, routingId) + window.location.search); } } diff --git a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts index d2306ca88d41..c2af853f7882 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/embedded/wp-embedded-table.component.ts @@ -23,6 +23,8 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora import { KeepTabService, } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; +import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { firstValueFrom } from 'rxjs'; import { QueryRequestParams } from 'core-app/features/work-packages/components/wp-query/url-params-helper'; @@ -63,6 +65,8 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo @InjectField() keepTab:KeepTabService; + @InjectField() states:States; + // Cache the form promise private formPromise:Promise|undefined; @@ -190,15 +194,17 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo handleWorkPackageClicked(event:{ workPackageId:string; double:boolean }) { if (event.double) { + const routingId = this.resolveRoutingId(event.workPackageId); const projectIdentifier = this.currentProject.identifier; - const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, event.workPackageId) + window.location.search; + const link = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; Turbo.visit(link, { action: 'advance' }); } } openStateLink(event:{ workPackageId:string; requestedState:'show'|'split' }) { + const routingId = this.resolveRoutingId(event.workPackageId); const params = { - workPackageId: event.workPackageId, + workPackageId: routingId, focus: true, }; @@ -208,4 +214,8 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo this.keepTab.goCurrentShowState(params.workPackageId); } } + + private resolveRoutingId(workPackageId:string):string { + return resolveRoutingId(this.states, workPackageId); + } } diff --git a/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts new file mode 100644 index 000000000000..b617ea088f36 --- /dev/null +++ b/frontend/src/app/features/work-packages/helpers/resolve-routing-id.ts @@ -0,0 +1,15 @@ +import { States } from 'core-app/core/states/states.service'; + +/** + * Resolve a numeric work package ID to its semantic routing ID (e.g. "PROJ-42"). + * Falls back to the input ID if the WP is not in cache or has no displayId — + * this is a best-effort lookup, not a guarantee. The URL just shows the + * numeric ID temporarily until the WP is cached. + * + * Used in navigation handlers where only the numeric PK is available from + * data-work-package-id attributes, but the URL should show the semantic form. + */ +export function resolveRoutingId(states:States, workPackageId:string):string { + const wp = states.workPackages.get(workPackageId)?.value; + return wp?.displayId ?? workPackageId; +} diff --git a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts index 913a7fa4b576..e8ccfab1c3ba 100644 --- a/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-list-view/wp-list-view.component.ts @@ -57,6 +57,8 @@ import { KeepTabService } from 'core-app/features/work-packages/components/wp-si import { WorkPackageViewBaselineService } from '../wp-view-base/view-services/wp-view-baseline.service'; import { combineLatest } from 'rxjs'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { States } from 'core-app/core/states/states.service'; +import { resolveRoutingId } from 'core-app/features/work-packages/helpers/resolve-routing-id'; @Component({ selector: 'wp-list-view', @@ -85,6 +87,7 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements readonly elementRef = inject>(ElementRef); readonly wpTableBaseline = inject(WorkPackageViewBaselineService); readonly pathHelper = inject(PathHelperService); + readonly states = inject(States); text = { jump_to_pagination: this.I18n.t('js.work_packages.jump_marks.pagination'), @@ -178,8 +181,9 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements } openStateLink(event:{ workPackageId:string; requestedState:'show'|'split' }) { + const routingId = this.resolveRoutingId(event.workPackageId); const params = { - workPackageId: event.workPackageId, + workPackageId: routingId, focus: true, }; @@ -203,7 +207,12 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements } private openInFullView(workPackageId:string) { + const routingId = this.resolveRoutingId(workPackageId); const projectIdentifier = this.CurrentProject.identifier; - window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId) + window.location.search; + window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, routingId) + window.location.search; + } + + private resolveRoutingId(workPackageId:string):string { + return resolveRoutingId(this.states, workPackageId); } } diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts index eb2c1121b9b7..7ce6e4509078 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts @@ -101,19 +101,13 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp ngOnInit():void { this.observeWorkPackage(); - const wpId = (this.$state.params.workPackageId || this.workPackageId) as string; - this.wpTableFocus.updateFocus(wpId, false); - - if (this.wpTableSelection.isEmpty) { - this.wpTableSelection.setRowState(wpId, true); - } - this.wpTableFocus.whenChanged() .pipe( this.untilDestroyed(), ) .subscribe((newId) => { - const idSame = wpId.toString() === newId.toString(); + const currentId = this.workPackage?.id ?? this.workPackageId; + const idSame = currentId.toString() === newId.toString(); if (!idSame && this.$state.includes(`${this.baseRoute}.details`)) { this.$state.go( (this.$state.current.name!), @@ -121,7 +115,26 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp ); } }); - this.recentItemsService.add(wpId); + } + + /** + * Set focus, selection, and recent-items after the WP has loaded. + * + * Intentionally deferred from ngOnInit because the route param + * (this.workPackageId) may be a semantic identifier like "PROJ-7", + * but focus/selection services are keyed by numeric PK. By the time + * init() runs, this.workPackage.id is guaranteed to be the numeric PK. + */ + protected override init():void { + super.init(); + const numericId = this.workPackage.id!; + this.wpTableFocus.updateFocus(numericId, false); + + if (this.wpTableSelection.isEmpty) { + this.wpTableSelection.setRowState(numericId, true); + } + + this.recentItemsService.add(numericId); } get activeTabComponent():Type|undefined { diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts index 9e12b28fafbc..cd499bb647fb 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/work-package-single-view.base.ts @@ -141,6 +141,12 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { /** * Observe changes of work package and re-run initialization. * Needs to be run explicitly by descendants. + * + * Note: this.workPackageId may be a semantic identifier (e.g. "PROJ-7") + * from the route param. The API resolves it correctly, but the cache key + * would be "PROJ-7" while list queries cache the same WP under "42". + * After the first load we normalize to the numeric PK to prevent + * dual cache entries. */ protected observeWorkPackage():void { this @@ -150,6 +156,13 @@ export abstract class WorkPackageSingleViewBase extends UntilDestroyedMixin { .requireAndStream() .pipe(this.untilDestroyed()) .subscribe((wp:WorkPackageResource) => { + // Normalize semantic route param (e.g. "PROJ-7") to numeric PK + // for cache coherence — downstream code uses this.workPackageId + // as a cache key, and the canonical key is always numeric. + if (this.workPackageId !== wp.id && wp.id) { + this.workPackageId = wp.id; + } + if (!this.workPackage) { this.workPackage = wp; this.init(); diff --git a/frontend/src/app/features/work-packages/services/work-package.service.ts b/frontend/src/app/features/work-packages/services/work-package.service.ts index 919ba47ef9ae..5010a0448435 100644 --- a/frontend/src/app/features/work-packages/services/work-package.service.ts +++ b/frontend/src/app/features/work-packages/services/work-package.service.ts @@ -34,6 +34,7 @@ import { UrlParamsHelperService } from 'core-app/features/work-packages/componen import { ToastService } from 'core-app/shared/components/toaster/toast.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { HalDeletedEvent, HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { States } from 'core-app/core/states/states.service'; @Injectable() export class WorkPackageService { @@ -47,7 +48,8 @@ export class WorkPackageService { private readonly UrlParamsHelper:UrlParamsHelperService, private readonly toastService:ToastService, private readonly I18n:I18nService, - private readonly halEvents:HalEventsService) { + private readonly halEvents:HalEventsService, + private readonly states:States) { } public performBulkDelete(ids:string[], defaultHandling:boolean) { @@ -68,8 +70,11 @@ export class WorkPackageService { ids.forEach((id) => this.halEvents.push({ _type: 'WorkPackage', id }, { eventType: 'deleted' } as HalDeletedEvent)); + const routeWpId = this.$state.params.workPackageId as string; + const wp = this.states.workPackages.get(routeWpId)?.value; + const numericId = wp?.id ?? routeWpId; if (this.$state.includes('**.list.details.**') - && ids.includes(this.$state.params.workPackageId)) { + && ids.includes(numericId)) { this.$state.go('work-packages.partitioned.list', this.$state.params); } }) diff --git a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts index f4e6953a8fe9..2392f5b3af83 100644 --- a/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts +++ b/frontend/src/app/shared/components/op-context-menu/wp-context-menu/wp-view-context-menu.directive.ts @@ -137,12 +137,14 @@ export class WorkPackageViewContextMenu extends OpContextMenuHandler { void this.turboRequests.requestStream(String(link)); break; - case 'relations': + case 'relations': { + const routingId = this.workPackage.displayId ?? this.workPackageId; void this.$state.go( `${splitViewRoute(this.$state)}.tabs`, - { workPackageId: this.workPackageId, tabIdentifier: 'relations' }, + { workPackageId: routingId, tabIdentifier: 'relations' }, ); break; + } default: window.location.href = link!;