Mijn aanvragen (F2): document preview/download on completed uploads

Re-opening a Concept wizard now lets the user preview/download what they already
uploaded (PRD 0001 goal 2).

- document-chip: optional previewUrl input → a "Voorbeeld / Download" link (opens
  the stored bytes; server serves inline for pdf/image, attachment otherwise).
- previewUrlFor callback threaded document-upload → document-category → single-upload
  (which builds the URL from a completed upload's documentId). Keeps URL-building out
  of the presentational atoms.
- registratie + herregistratie wizards supply previewUrlFor via UploadAdapter.contentUrl,
  returning undefined for dev-simulation `demo-*` ids (no stored bytes → no link).
- Story: DocumentChip/WithPreview.

Gates green: vitest 128, lint, ng build, check:tokens; backend dotnet 56.
(build-storybook has a pre-existing Compodoc/$localize issue in untouched files.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:50:40 +02:00
parent 0bef08e5b3
commit 168cf9786c
8 changed files with 15157 additions and 1665 deletions

View File

@@ -51,6 +51,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
@for (u of uploads(); track u.localId) {
<app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="onRemove(u)"
(retry)="retryUpload.emit(u.localId)" />
}
@@ -67,6 +68,7 @@ export class DocumentCategoryComponent {
uploads = input.required<Upload[]>();
channel = input.required<DeliveryChannel>();
rejection = input<string>();
previewUrlFor = input<(documentId: string) => string | undefined>();
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);

View File

@@ -29,10 +29,16 @@ import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-i
text-decoration: underline;
}
.action:hover { color: var(--rhc-color-foreground-link-hover); }
a.action { display: inline-block; }
`],
template: `
<app-upload-status-icon [status]="status().type" />
<span class="name">{{ fileName() }}</span>
@if (previewUrl()) {
<a class="action" [href]="previewUrl()" target="_blank" rel="noopener" [attr.aria-label]="previewLabel">
<span i18n="@@upload.chip.preview">Voorbeeld / Download</span>
</a>
}
@if (status().type === 'failed') {
<button type="button" class="action" [attr.aria-label]="retryLabel" (click)="retry.emit()">
<span i18n="@@upload.chip.retry">Opnieuw</span>
@@ -48,6 +54,8 @@ import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-i
export class DocumentChipComponent {
fileName = input.required<string>();
status = input.required<UploadStatus>();
/** When set, show a preview/download link (opens the stored bytes). */
previewUrl = input<string>();
remove = output<void>();
retry = output<void>();
@@ -55,6 +63,9 @@ export class DocumentChipComponent {
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
}
protected get previewLabel(): string {
return $localize`:@@upload.chip.previewAria:${this.fileName()}:fileName: bekijken of downloaden`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
}

View File

@@ -7,7 +7,7 @@ const meta: Meta<DocumentChipComponent> = {
component: DocumentChipComponent,
render: (args) => ({
props: args,
template: `<app-document-chip [fileName]="fileName" [status]="status" />`,
template: `<app-document-chip [fileName]="fileName" [status]="status" [previewUrl]="previewUrl" />`,
}),
};
export default meta;
@@ -17,6 +17,10 @@ export const Complete: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
};
export const WithPreview: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
};
export const Failed: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
};

View File

@@ -25,6 +25,7 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
[uploads]="uploadsFor(c.categoryId)"
[channel]="channelFor(c.categoryId)"
[rejection]="state().rejections[c.categoryId]"
[previewUrlFor]="previewUrlFor()"
(fileSelected)="fileSelected.emit({ categoryId: c.categoryId, files: $event })"
(removeUpload)="removeUpload.emit($event)"
(retryUpload)="retryUpload.emit($event)"
@@ -36,6 +37,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
})
export class DocumentUploadComponent {
state = input.required<UploadState>();
/** Optional: builds a preview/download URL for a completed upload's documentId. */
previewUrlFor = input<(documentId: string) => string | undefined>();
fileSelected = output<{ categoryId: string; files: File[] }>();
removeUpload = output<string>();

View File

@@ -15,6 +15,7 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
<app-document-chip
[fileName]="upload().fileName"
[status]="upload().status"
[previewUrl]="previewUrl()"
(remove)="remove.emit()"
(retry)="retry.emit()" />
@if (progressPct() !== null) {
@@ -24,6 +25,8 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
})
export class SingleUploadComponent {
upload = input.required<Upload>();
/** Builds a preview URL for a completed upload's documentId (undefined = no link). */
previewUrlFor = input<(documentId: string) => string | undefined>();
/** Narrow the status union once, in TS, so the template stays type-safe. */
protected readonly progressPct = computed<number | null>(() => {
@@ -31,6 +34,11 @@ export class SingleUploadComponent {
return status.type === 'uploading' ? status.progressPct : null;
});
protected readonly previewUrl = computed<string | undefined>(() => {
const status = this.upload().status;
return status.type === 'complete' ? this.previewUrlFor()?.(status.documentId) : undefined;
});
remove = output<void>();
retry = output<void>();
}