@dignite/vault-extract 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +17 -0
  2. package/fesm2022/dignite-vault-extract-config.mjs +82 -0
  3. package/fesm2022/dignite-vault-extract-config.mjs.map +1 -0
  4. package/fesm2022/dignite-vault-extract-documents-cabinet-list.component-Ch0gpSCc.mjs +184 -0
  5. package/fesm2022/dignite-vault-extract-documents-cabinet-list.component-Ch0gpSCc.mjs.map +1 -0
  6. package/fesm2022/dignite-vault-extract-documents-content-type-DjCs-s4E.mjs +115 -0
  7. package/fesm2022/dignite-vault-extract-documents-content-type-DjCs-s4E.mjs.map +1 -0
  8. package/fesm2022/dignite-vault-extract-documents-document-detail.component-DHs42DWJ.mjs +1146 -0
  9. package/fesm2022/dignite-vault-extract-documents-document-detail.component-DHs42DWJ.mjs.map +1 -0
  10. package/fesm2022/dignite-vault-extract-documents-document-file-preview.component-CStXf8v9.mjs +72 -0
  11. package/fesm2022/dignite-vault-extract-documents-document-file-preview.component-CStXf8v9.mjs.map +1 -0
  12. package/fesm2022/dignite-vault-extract-documents-document-list.component-jThR5cct.mjs +642 -0
  13. package/fesm2022/dignite-vault-extract-documents-document-list.component-jThR5cct.mjs.map +1 -0
  14. package/fesm2022/dignite-vault-extract-documents-document-overview.component-BHUUUIVr.mjs +318 -0
  15. package/fesm2022/dignite-vault-extract-documents-document-overview.component-BHUUUIVr.mjs.map +1 -0
  16. package/fesm2022/dignite-vault-extract-documents-document-recycle-bin.component-dqeBrw22.mjs +178 -0
  17. package/fesm2022/dignite-vault-extract-documents-document-recycle-bin.component-dqeBrw22.mjs.map +1 -0
  18. package/fesm2022/dignite-vault-extract-documents-document-type-list.component-C8kXFJGb.mjs +464 -0
  19. package/fesm2022/dignite-vault-extract-documents-document-type-list.component-C8kXFJGb.mjs.map +1 -0
  20. package/fesm2022/dignite-vault-extract-documents-export-template-list.component-DlmZFFF1.mjs +361 -0
  21. package/fesm2022/dignite-vault-extract-documents-export-template-list.component-DlmZFFF1.mjs.map +1 -0
  22. package/fesm2022/dignite-vault-extract-documents-extensible-table-DkLXuoWo.mjs +53 -0
  23. package/fesm2022/dignite-vault-extract-documents-extensible-table-DkLXuoWo.mjs.map +1 -0
  24. package/fesm2022/dignite-vault-extract-documents-field-definition-list.component-ClmWkRun.mjs +530 -0
  25. package/fesm2022/dignite-vault-extract-documents-field-definition-list.component-ClmWkRun.mjs.map +1 -0
  26. package/fesm2022/dignite-vault-extract-documents-field-reextraction-modal.component-D7OOycv9.mjs +163 -0
  27. package/fesm2022/dignite-vault-extract-documents-field-reextraction-modal.component-D7OOycv9.mjs.map +1 -0
  28. package/fesm2022/dignite-vault-extract-documents-format-bytes-Cd3QwfQZ.mjs +19 -0
  29. package/fesm2022/dignite-vault-extract-documents-format-bytes-Cd3QwfQZ.mjs.map +1 -0
  30. package/fesm2022/dignite-vault-extract-documents-format-field-value-Xjb8lwzA.mjs +22 -0
  31. package/fesm2022/dignite-vault-extract-documents-format-field-value-Xjb8lwzA.mjs.map +1 -0
  32. package/fesm2022/dignite-vault-extract-documents.mjs +71 -0
  33. package/fesm2022/dignite-vault-extract-documents.mjs.map +1 -0
  34. package/fesm2022/dignite-vault-extract.mjs +522 -0
  35. package/fesm2022/dignite-vault-extract.mjs.map +1 -0
  36. package/package.json +38 -0
  37. package/types/dignite-vault-extract-config.d.ts +5 -0
  38. package/types/dignite-vault-extract-documents.d.ts +5 -0
  39. package/types/dignite-vault-extract.d.ts +521 -0
@@ -0,0 +1,1146 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, DestroyRef, signal, computed, effect, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { forkJoin, timer, tap, switchMap, of } from 'rxjs';
5
+ import { ActivatedRoute, Router } from '@angular/router';
6
+ import * as i2 from '@angular/common';
7
+ import { Location, DOCUMENT, CommonModule } from '@angular/common';
8
+ import * as i1 from '@angular/forms';
9
+ import { FormsModule } from '@angular/forms';
10
+ import { marked } from 'marked';
11
+ import { PermissionService, LocalizationService, LocalizationPipe } from '@abp/ng.core';
12
+ import { DynamicFormComponent } from '@abp/ng.components/dynamic-form';
13
+ import { ToasterService, ConfirmationService, Confirmation } from '@abp/ng.theme.shared';
14
+ import { DocumentService, DocumentPipelineRunService, DocumentTypeService, FieldDefinitionService, CabinetService, EXTRACT_PERMISSIONS, DocumentLifecycleStatus, DocumentReviewDisposition, DocumentReviewReasons, PipelineRunStatus, FieldDataType } from '@dignite/vault-extract';
15
+ import { f as formatExtractedFieldValue } from './dignite-vault-extract-documents-format-field-value-Xjb8lwzA.mjs';
16
+ import { D as DocumentFileBlobService, i as isImageContentType, a as isPdfContentType } from './dignite-vault-extract-documents-content-type-DjCs-s4E.mjs';
17
+
18
+ /**
19
+ * Removes Markdown code-fence delimiter lines from LLM-produced Markdown, keeping the fenced content in place.
20
+ *
21
+ * A vision-LLM OCR transcription (and, defensively, any LLM Markdown rendered in the operator UI) sometimes
22
+ * arrives wrapped — wholly, or only partly — in a ```` ```markdown ```` fence despite the OCR prompt forbidding
23
+ * it (#448). `marked` then renders the fenced block (typically a table) as a literal `<pre><code>` block instead
24
+ * of Markdown, so the table shows as raw pipes. In this channel the payload is digitized DOCUMENT text
25
+ * (headings / tables / lists), never source code, so a triple-backtick / triple-tilde fence is always such an
26
+ * artifact: drop the fence delimiter lines (keeping their content) before parsing — this handles a whole-output
27
+ * fence, a partial fence, and an unmatched (never-closed) fence uniformly.
28
+ *
29
+ * The backend `VisionLlmOutputGuard.StripCodeFences` strips this at the source for newly extracted documents;
30
+ * this frontend twin also rescues documents already stored with the fence baked in (`Document.Markdown` is
31
+ * write-once, so they are never re-extracted).
32
+ *
33
+ * A fence delimiter is a line whose trimmed text is a run of >= 3 back-ticks or >= 3 tildes, optionally
34
+ * followed by an info string that contains no further back-tick — so an inline `` `code` `` / ```` ```code``` ````
35
+ * span sitting on its own line is not mistaken for a fence.
36
+ */
37
+ function stripMarkdownCodeFences(markdown) {
38
+ // Fast path: no fence character at all (the overwhelming majority of documents) — return untouched.
39
+ if (!markdown || (markdown.indexOf('`') < 0 && markdown.indexOf('~') < 0)) {
40
+ return markdown;
41
+ }
42
+ return markdown
43
+ .split('\n')
44
+ .filter(line => !isCodeFenceLine(line))
45
+ .join('\n');
46
+ }
47
+ function isCodeFenceLine(line) {
48
+ const s = line.trim();
49
+ const marker = s[0];
50
+ if (marker !== '`' && marker !== '~') {
51
+ return false;
52
+ }
53
+ let run = 0;
54
+ while (run < s.length && s[run] === marker) {
55
+ run++;
56
+ }
57
+ // >= 3 markers, and no back-tick after the run (a back-tick would make it an inline span, not a fence).
58
+ return run >= 3 && s.indexOf('`', run) < 0;
59
+ }
60
+
61
+ // Mirrors core/src/Dignite.Vault.Extract.Domain.Shared/Documents/ExtractPipelines.cs.
62
+ const KNOWN_PIPELINE_CODES = [
63
+ 'text-extraction',
64
+ 'classification',
65
+ 'field-extraction',
66
+ ];
67
+ // #440 interim live status: while a document is still in-flight, silently re-fetch it + its pipeline runs so
68
+ // the detail page reflects server-side progress without a manual Refresh. Bounded and self-terminating — to
69
+ // be replaced by server push (SSE / SignalR), see issue #440. Start fast, then back the interval off toward
70
+ // the cap so a long-running pipeline does not keep polling at full rate.
71
+ const POLL_BASE_INTERVAL_MS = 2000;
72
+ const POLL_MAX_INTERVAL_MS = 10000;
73
+ class DocumentDetailComponent {
74
+ toPipelineRow(pipelineCode, labelKey, isKnown, run) {
75
+ return {
76
+ pipelineCode,
77
+ labelKey,
78
+ isKnown,
79
+ run,
80
+ statusBadgeClass: this.getRunStatusBadgeClass(run?.status),
81
+ statusLabel: this.getRunStatusLabel(run?.status),
82
+ inProgress: this.isRunInProgress(run?.status),
83
+ elapsedDisplay: run ? this.formatElapsedOrNull(run) : null,
84
+ retryable: isKnown && this.isRetryable(run),
85
+ };
86
+ }
87
+ formatElapsedOrNull(run) {
88
+ return this.getElapsedMs(run) === null ? null : this.formatElapsed(run);
89
+ }
90
+ // #418: shared Markdown -> HTML step, used by both the left-column preview and the LongText
91
+ // extracted-field values. Same GFM options and the same sanitize-on-bind contract: every caller MUST
92
+ // bind the result via [innerHTML] so Angular's DomSanitizer runs. Never bypassSecurityTrustHtml — a
93
+ // LongText field value is attacker-influenced too (LLM extraction / VLM OCR can be prompt-injected), so
94
+ // the sanitizer has to stay on end to end.
95
+ // #448: strip stray ```markdown code fences first. A vision-LLM OCR transcription is sometimes wrapped —
96
+ // wholly or partly — in a code fence despite the prompt forbidding it, which would make marked render the
97
+ // fenced table as literal <pre><code> (raw pipes) instead of a GFM table. The backend guard removes this at
98
+ // the source for new documents; stripping here also rescues documents already stored with the fence
99
+ // (Document.Markdown is write-once).
100
+ renderMarkdown(md) {
101
+ return md ? marked.parse(stripMarkdownCodeFences(md), { gfm: true, async: false }) : '';
102
+ }
103
+ constructor() {
104
+ this.route = inject(ActivatedRoute);
105
+ this.router = inject(Router);
106
+ this.location = inject(Location);
107
+ this.documentService = inject(DocumentService);
108
+ this.documentPipelineRunService = inject(DocumentPipelineRunService);
109
+ this.documentTypeService = inject(DocumentTypeService);
110
+ this.fieldDefinitionService = inject(FieldDefinitionService);
111
+ this.cabinetService = inject(CabinetService);
112
+ this.toaster = inject(ToasterService);
113
+ this.confirmation = inject(ConfirmationService);
114
+ this.permissionService = inject(PermissionService);
115
+ this.localization = inject(LocalizationService);
116
+ this.destroyRef = inject(DestroyRef);
117
+ // #440: DOM document handle for the Page Visibility check that pauses background polling on a hidden tab.
118
+ this.domDocument = inject(DOCUMENT);
119
+ // Original-file blob load / sanitize / revoke lifecycle (#277), shared with the file-preview page.
120
+ this.fileBlob = inject(DocumentFileBlobService);
121
+ this.canDelete = this.permissionService.getGrantedPolicy(EXTRACT_PERMISSIONS.Documents.Delete);
122
+ this.canEditFields = this.permissionService.getGrantedPolicy(EXTRACT_PERMISSIONS.Documents.ConfirmClassification);
123
+ this.canViewCabinets = this.permissionService.getGrantedPolicy(EXTRACT_PERMISSIONS.Cabinets.Default);
124
+ this.document = signal(null, ...(ngDevMode ? [{ debugName: "document" }] : /* istanbul ignore next */ []));
125
+ // #306/#354: when this document is a sub-document (originDocumentId set), the source/container document
126
+ // loaded for the provenance banner so it can show the parent's title instead of a raw id. null when this
127
+ // is a normal document, or when the parent is inaccessible (soft-deleted / cross-layer) — the banner then
128
+ // falls back to the id.
129
+ this.parentDocument = signal(null, ...(ngDevMode ? [{ debugName: "parentDocument" }] : /* istanbul ignore next */ []));
130
+ // #306/#354: true when the parent (origin) lookup failed — the source was removed (soft / permanently deleted) or is
131
+ // cross-layer / inaccessible. Drives the banner's "source unavailable" label and hides the dead "view parent" link,
132
+ // distinguishing a failed lookup from the brief in-flight window (both otherwise leave parentDocument null).
133
+ this.parentLookupFailed = signal(false, ...(ngDevMode ? [{ debugName: "parentLookupFailed" }] : /* istanbul ignore next */ []));
134
+ // #216: PipelineRun was split into an independent aggregate root and removed from
135
+ // DocumentDto.pipelineRuns. It is now an independent signal loaded separately through
136
+ // DocumentPipelineRunService in loadDocument.
137
+ this.pipelineRuns = signal([], ...(ngDevMode ? [{ debugName: "pipelineRuns" }] : /* istanbul ignore next */ []));
138
+ this.isLoading = signal(true, ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
139
+ // Left column three-tab area (#274): Markdown preview by default; switching to 'file' triggers lazy
140
+ // loading of the original-file blob.
141
+ this.activeTab = signal('preview', ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
142
+ this.retryingPipeline = signal(null, ...(ngDevMode ? [{ debugName: "retryingPipeline" }] : /* istanbul ignore next */ []));
143
+ this.isRerecognizing = signal(false, ...(ngDevMode ? [{ debugName: "isRerecognizing" }] : /* istanbul ignore next */ []));
144
+ this.isEditingFields = signal(false, ...(ngDevMode ? [{ debugName: "isEditingFields" }] : /* istanbul ignore next */ []));
145
+ this.isSavingFields = signal(false, ...(ngDevMode ? [{ debugName: "isSavingFields" }] : /* istanbul ignore next */ []));
146
+ this.fieldDefinitions = signal([], ...(ngDevMode ? [{ debugName: "fieldDefinitions" }] : /* istanbul ignore next */ []));
147
+ this.extractedFieldFormFields = signal([], ...(ngDevMode ? [{ debugName: "extractedFieldFormFields" }] : /* istanbul ignore next */ []));
148
+ // Candidate cabinets for the document: cabinetId-to-name display mapping plus reassignment dropdown
149
+ // options (#257). Loaded only with Cabinets.Default permission.
150
+ this.cabinets = signal([], ...(ngDevMode ? [{ debugName: "cabinets" }] : /* istanbul ignore next */ []));
151
+ // Cabinet reassignment (#257) edit state: entering edit shows the dropdown; empty selectedCabinetId
152
+ // means unclassified.
153
+ this.isEditingCabinet = signal(false, ...(ngDevMode ? [{ debugName: "isEditingCabinet" }] : /* istanbul ignore next */ []));
154
+ this.isSavingCabinet = signal(false, ...(ngDevMode ? [{ debugName: "isSavingCabinet" }] : /* istanbul ignore next */ []));
155
+ this.selectedCabinetId = signal('', ...(ngDevMode ? [{ debugName: "selectedCabinetId" }] : /* istanbul ignore next */ []));
156
+ // Document types visible in the current layer, used for typeCode-to-displayName mapping and the
157
+ // confirm-classification picker. Populated together with field definition loading.
158
+ this.documentTypes = signal([], ...(ngDevMode ? [{ debugName: "documentTypes" }] : /* istanbul ignore next */ []));
159
+ // #395: manual confirm/assign classification — the authoritative override for UnresolvedClassification,
160
+ // relocated here from the removed review-queue page.
161
+ this.showClassifyDialog = signal(false, ...(ngDevMode ? [{ debugName: "showClassifyDialog" }] : /* istanbul ignore next */ []));
162
+ this.selectedTypeId = signal('', ...(ngDevMode ? [{ debugName: "selectedTypeId" }] : /* istanbul ignore next */ []));
163
+ this.isConfirmingClassification = signal(false, ...(ngDevMode ? [{ debugName: "isConfirmingClassification" }] : /* istanbul ignore next */ []));
164
+ // #395: reject (#237/#284 recoverable disposition). The review queue was the only place this action
165
+ // lived; it moves to the remediation hub so the disposition is not lost.
166
+ this.showRejectDialog = signal(false, ...(ngDevMode ? [{ debugName: "showRejectDialog" }] : /* istanbul ignore next */ []));
167
+ this.rejectReason = signal('', ...(ngDevMode ? [{ debugName: "rejectReason" }] : /* istanbul ignore next */ []));
168
+ this.isRejecting = signal(false, ...(ngDevMode ? [{ debugName: "isRejecting" }] : /* istanbul ignore next */ []));
169
+ // #411: in-flight guard for the "Allow duplicate" operator action.
170
+ this.isAllowingDuplicate = signal(false, ...(ngDevMode ? [{ debugName: "isAllowingDuplicate" }] : /* istanbul ignore next */ []));
171
+ this.DocumentLifecycleStatus = DocumentLifecycleStatus;
172
+ this.DocumentReviewDisposition = DocumentReviewDisposition;
173
+ this.DocumentReviewReasons = DocumentReviewReasons;
174
+ this.PipelineRunStatus = PipelineRunStatus;
175
+ this.pipelineRows = computed(() => {
176
+ if (!this.document())
177
+ return [];
178
+ // #216: run source changed from doc.pipelineRuns to the independent pipelineRuns signal
179
+ // (DocumentPipelineRunService).
180
+ const allRuns = this.pipelineRuns();
181
+ const known = KNOWN_PIPELINE_CODES.map(code => this.toPipelineRow(code, `::Document:Pipeline:${code}`, true, this.pickLatestRun(allRuns, code)));
182
+ const unknownCodes = Array.from(new Set(allRuns
183
+ .map(r => r.pipelineCode)
184
+ .filter((code) => !!code && !KNOWN_PIPELINE_CODES.includes(code))));
185
+ const unknown = unknownCodes.map(code => this.toPipelineRow(code, code, false, this.pickLatestRun(allRuns, code)));
186
+ return [...known, ...unknown];
187
+ }, ...(ngDevMode ? [{ debugName: "pipelineRows" }] : /* istanbul ignore next */ []));
188
+ // #284: operator attention required, for any unresolved review reason, equals server-provided
189
+ // requiresReview. The client does not infer it locally.
190
+ this.needsReview = computed(() => this.document()?.requiresReview ?? false, ...(ngDevMode ? [{ debugName: "needsReview" }] : /* istanbul ignore next */ []));
191
+ // #395: classification still unresolved AND the operator may act — drives the "Confirm classification"
192
+ // CTA. Mirrors the list's needsConfirmation; the blocking UnresolvedClassification reason is the only one
193
+ // a manual type assignment resolves.
194
+ this.needsClassification = computed(() => this.canEditFields &&
195
+ (((this.document()?.reviewReasons ?? DocumentReviewReasons.None) & DocumentReviewReasons.UnresolvedClassification)
196
+ !== DocumentReviewReasons.None), ...(ngDevMode ? [{ debugName: "needsClassification" }] : /* istanbul ignore next */ []));
197
+ // #411: a suspected duplicate AND the operator may act — drives the "Allow" CTA (release as not a duplicate).
198
+ // The opposite resolution (confirm the duplicate) is the existing Delete action.
199
+ this.needsDuplicateReview = computed(() => this.canEditFields &&
200
+ (((this.document()?.reviewReasons ?? DocumentReviewReasons.None) & DocumentReviewReasons.DuplicateSuspected)
201
+ !== DocumentReviewReasons.None), ...(ngDevMode ? [{ debugName: "needsDuplicateReview" }] : /* istanbul ignore next */ []));
202
+ // #412: required fields are missing AND the operator may act — drives the "Complete fields" CTA. The reason
203
+ // is non-blocking and clears itself once the values are filled in the fields card below, so the banner
204
+ // offers a jump-and-edit shortcut rather than a confirm/approve button.
205
+ this.needsFieldCompletion = computed(() => this.canEditFields &&
206
+ (((this.document()?.reviewReasons ?? DocumentReviewReasons.None) & DocumentReviewReasons.MissingRequiredFields)
207
+ !== DocumentReviewReasons.None), ...(ngDevMode ? [{ debugName: "needsFieldCompletion" }] : /* istanbul ignore next */ []));
208
+ // #284: pure availability axis. After the two axes became orthogonal, the review banner and processing
209
+ // banner are judged independently; the template's @if needsReview takes precedence over isProcessing.
210
+ this.isProcessing = computed(() => {
211
+ const status = this.document()?.lifecycleStatus;
212
+ return status === DocumentLifecycleStatus.Uploaded ||
213
+ status === DocumentLifecycleStatus.Processing;
214
+ }, ...(ngDevMode ? [{ debugName: "isProcessing" }] : /* istanbul ignore next */ []));
215
+ this.isReady = computed(() => this.document()?.lifecycleStatus === DocumentLifecycleStatus.Ready, ...(ngDevMode ? [{ debugName: "isReady" }] : /* istanbul ignore next */ []));
216
+ // #306/#354: this document was derived from a constituent of another (container) document. Drives the
217
+ // provenance banner and its "view parent / view siblings" navigation. originDocumentId is a system signal
218
+ // carried on the Document output contract (DocumentDto), null for normally-uploaded documents.
219
+ this.isSubDocument = computed(() => !!this.document()?.originDocumentId, ...(ngDevMode ? [{ debugName: "isSubDocument" }] : /* istanbul ignore next */ []));
220
+ // True when any critical pipeline, text extraction or classification, has an in-progress run
221
+ // (Pending/Running).
222
+ this.pipelineInProgress = computed(() => this.pipelineRows().some(r => r.isKnown && r.inProgress), ...(ngDevMode ? [{ debugName: "pipelineInProgress" }] : /* istanbul ignore next */ []));
223
+ // #263 "rerecognize" availability: extracted text exists, ConfirmClassification permission is present
224
+ // (same as canEditFields), no critical pipeline is currently running, and the page is not loading. This
225
+ // avoids stacking reclassification onto a document already being processed or reprocessed.
226
+ // Use pipelineInProgress instead of !isProcessing(): the latter is always false when needsReview() is
227
+ // true, which would still expose the button on a pending-review document while reclassification is in
228
+ // progress (review #5). The in-flight POST is covered by button [disabled]="isRerecognizing()".
229
+ this.canRerecognize = computed(() => this.canEditFields &&
230
+ !!this.document()?.markdown &&
231
+ !this.pipelineInProgress() &&
232
+ !this.isLoading(), ...(ngDevMode ? [{ debugName: "canRerecognize" }] : /* istanbul ignore next */ []));
233
+ this.isReextracting = signal(false, ...(ngDevMode ? [{ debugName: "isReextracting" }] : /* istanbul ignore next */ []));
234
+ // #289 "field re-extraction only" availability: same prerequisites as "rerecognize", plus already
235
+ // classified with documentTypeCode. Field extraction is attached to a type, so unclassified documents
236
+ // have nothing to extract from; this mirrors the backend NotClassified guard.
237
+ this.canReextractFields = computed(() => this.canEditFields &&
238
+ !!this.document()?.documentTypeCode &&
239
+ !!this.document()?.markdown &&
240
+ !this.pipelineInProgress() &&
241
+ !this.isLoading(), ...(ngDevMode ? [{ debugName: "canReextractFields" }] : /* istanbul ignore next */ []));
242
+ this.isImage = computed(() => isImageContentType(this.document()?.fileOrigin?.contentType), ...(ngDevMode ? [{ debugName: "isImage" }] : /* istanbul ignore next */ []));
243
+ this.isPdf = computed(() => isPdfContentType(this.document()?.fileOrigin?.contentType), ...(ngDevMode ? [{ debugName: "isPdf" }] : /* istanbul ignore next */ []));
244
+ // Sub-documents (#306/#346) are spawned with no FileOrigin — their Markdown is seeded from the source
245
+ // segment slice, so there is no original file to preview or download. Gate every source-file affordance
246
+ // (Original File tab, footer, Download) on this so the UI never calls GetBlobAsync for a blob-less
247
+ // document, which fails with Extract:DocumentNoSourceBlob (and re-fires on every reload after rerecognize
248
+ // / Refresh while the file tab is active).
249
+ this.hasSourceFile = computed(() => !!this.document()?.fileOrigin, ...(ngDevMode ? [{ debugName: "hasSourceFile" }] : /* istanbul ignore next */ []));
250
+ // Intermediate computed for Markdown source (#274 review): when document() changes but markdown does
251
+ // not, such as field or cabinet changes, return the same string so downstream renderedMarkdown can
252
+ // short-circuit by value equality and avoid repeated marked.parse calls.
253
+ this.markdownSource = computed(() => this.document()?.markdown ?? '', ...(ngDevMode ? [{ debugName: "markdownSource" }] : /* istanbul ignore next */ []));
254
+ // Markdown preview (#274): marked renders to an HTML string. When the template binds [innerHTML],
255
+ // Angular's built-in DomSanitizer sanitizes it automatically by stripping <script>, on*, and
256
+ // javascript:. Never bypassSecurityTrustHtml: Markdown is attacker-influenced content because VLM OCR
257
+ // can be prompt-injected by text inside an image, so the sanitizer must stay on end to end.
258
+ this.renderedMarkdown = computed(() => this.renderMarkdown(this.markdownSource()), ...(ngDevMode ? [{ debugName: "renderedMarkdown" }] : /* istanbul ignore next */ []));
259
+ // Owning cabinet name for the document, cabinetId to name. Returns null when unclassified or
260
+ // unresolved because of missing permission or deleted cabinet.
261
+ this.cabinetName = computed(() => {
262
+ const id = this.document()?.cabinetId;
263
+ if (!id)
264
+ return null;
265
+ return this.cabinets().find(c => c.id === id)?.name ?? null;
266
+ }, ...(ngDevMode ? [{ debugName: "cabinetName" }] : /* istanbul ignore next */ []));
267
+ // Document type displayName, mapped from typeCode. Returns null when unclassified, and falls back to
268
+ // code for cross-layer or deleted types.
269
+ this.documentTypeDisplayName = computed(() => {
270
+ const code = this.document()?.documentTypeCode;
271
+ if (!code)
272
+ return null;
273
+ return this.documentTypes().find(t => t.typeCode === code)?.displayName ?? code;
274
+ }, ...(ngDevMode ? [{ debugName: "documentTypeDisplayName" }] : /* istanbul ignore next */ []));
275
+ // Type-bound extracted fields (field architecture v2). Show only values corresponding to currently
276
+ // active field definitions:
277
+ // The backend ExtractedFields output pierces soft-delete and still includes historical values for
278
+ // deleted field definitions, preserving data for downstream consumers (#206/#207). The operator UI no
279
+ // longer shows them, matching the list's dynamic columns which also use only active definitions. Labels
280
+ // use displayName and sorting uses displayOrder.
281
+ this.extractedFieldEntries = computed(() => {
282
+ const fields = this.document()?.extractedFields;
283
+ if (!fields)
284
+ return [];
285
+ const defByName = new Map(this.fieldDefinitions().map(d => [d.name ?? '', d]));
286
+ return Object.keys(fields)
287
+ .filter(key => defByName.has(key))
288
+ .sort((a, b) => (defByName.get(a).displayOrder ?? 0) - (defByName.get(b).displayOrder ?? 0) ||
289
+ a.localeCompare(b))
290
+ .map(key => {
291
+ const def = defByName.get(key);
292
+ const raw = fields[key];
293
+ const value = this.formatFieldValue(raw);
294
+ // #418: a LongText value is rendered as Markdown here in the detail view (never in the compact
295
+ // list, so multi-paragraph content cannot blow apart table rows). LongText is an explicit
296
+ // config-time choice on FieldDefinition.DataType — a declared type, not a runtime guess. Only
297
+ // render when there is real string content; a null / empty value (formatted as "—") stays plain
298
+ // text. renderedHtml keeps the sanitize-on-bind contract via renderMarkdown + [innerHTML].
299
+ const isMarkdown = def.dataType === FieldDataType.LongText &&
300
+ typeof raw === 'string' &&
301
+ raw.trim().length > 0;
302
+ return {
303
+ key,
304
+ label: def.displayName || key,
305
+ value,
306
+ isMarkdown,
307
+ renderedHtml: isMarkdown ? this.renderMarkdown(value) : '',
308
+ };
309
+ });
310
+ }, ...(ngDevMode ? [{ debugName: "extractedFieldEntries" }] : /* istanbul ignore next */ []));
311
+ // Field card display condition: existing extracted values for read-only display, or editable with field
312
+ // definitions on this type so empty fields can be completed.
313
+ this.showFieldsCard = computed(() => this.extractedFieldEntries().length > 0 ||
314
+ (this.canEditFields && this.fieldDefinitions().length > 0), ...(ngDevMode ? [{ debugName: "showFieldsCard" }] : /* istanbul ignore next */ []));
315
+ // If the blob has not loaded when Download File is clicked, set this flag and trigger one download
316
+ // after the blob is ready. See downloadFile plus the constructor effect.
317
+ this.pendingDownload = false;
318
+ // #440: subscription to the pending poll tick (null when not polling) + the current backoff interval.
319
+ this.pollTimer = null;
320
+ this.pollIntervalMs = POLL_BASE_INTERVAL_MS;
321
+ // Pending download completion: when the blob arrives, trigger one download; when blob loading fails,
322
+ // show a hint. pendingDownload is a plain boolean, and this effect reruns only when fileBlob signals
323
+ // change. The download click first changes signals by triggering loading, so the logic naturally hits
324
+ // only once.
325
+ effect(() => {
326
+ const url = this.fileBlob.blobUrl();
327
+ const failed = this.fileBlob.hasError();
328
+ if (!this.pendingDownload)
329
+ return;
330
+ if (url) {
331
+ this.pendingDownload = false;
332
+ this.fileBlob.download(this.downloadFileName());
333
+ }
334
+ else if (failed) {
335
+ this.pendingDownload = false;
336
+ this.toaster.error('::Document:DownloadFailed', '::Error');
337
+ }
338
+ });
339
+ // #440: pause the background poll while the tab is hidden (no point re-fetching an unseen page) and
340
+ // resume with an immediate refresh when it returns to the foreground. Always tear the timer down on
341
+ // destroy so navigating away stops polling.
342
+ const onVisibilityChange = () => {
343
+ if (this.domDocument.hidden) {
344
+ this.clearPollTimer();
345
+ }
346
+ else if (this.shouldPoll() && !this.pollTimer) {
347
+ this.pollReload();
348
+ }
349
+ };
350
+ this.domDocument.addEventListener('visibilitychange', onVisibilityChange);
351
+ this.destroyRef.onDestroy(() => {
352
+ this.domDocument.removeEventListener('visibilitychange', onVisibilityChange);
353
+ this.clearPollTimer();
354
+ });
355
+ }
356
+ ngOnInit() {
357
+ // React to the :id route param rather than reading a one-time snapshot. Navigating between documents
358
+ // that share this route — a sub-document's "view parent"/"view siblings" actions, or any /documents/:id
359
+ // link — reuses this component instance, so ngOnInit does not fire again. Without reacting to the param
360
+ // the URL would change but the loaded document would not. Reload whenever the id actually changes,
361
+ // dropping the previous document's cached file blob and resetting the tab so a stale preview never
362
+ // carries over to the new document.
363
+ this.route.paramMap
364
+ .pipe(takeUntilDestroyed(this.destroyRef))
365
+ .subscribe(params => {
366
+ const id = params.get('id');
367
+ if (!id || id === this.documentId)
368
+ return;
369
+ this.documentId = id;
370
+ this.fileBlob.reset();
371
+ this.activeTab.set('preview');
372
+ this.loadDocument();
373
+ });
374
+ }
375
+ refresh() {
376
+ this.loadDocument();
377
+ }
378
+ loadDocument() {
379
+ this.isLoading.set(true);
380
+ // Any explicit (re)load — navigation, manual Refresh, post-action reload — restarts the poll backoff
381
+ // from the base interval.
382
+ this.pollIntervalMs = POLL_BASE_INTERVAL_MS;
383
+ this.fetchDocument(false);
384
+ }
385
+ // #440: a background poll tick. Silently re-fetches — never toggles isLoading, so the full-page spinner
386
+ // and the Refresh-button spin stay quiet — and skips the one-time metadata (parent / cabinets) that does
387
+ // not change mid-pipeline, refreshing field definitions only when classification has just resolved a type.
388
+ pollReload() {
389
+ this.fetchDocument(true);
390
+ }
391
+ fetchDocument(quiet) {
392
+ // doc and runs are independent after #216, so load them once in parallel; fieldDefinitions still
393
+ // depend on doc.documentTypeCode and remain sequential.
394
+ forkJoin({
395
+ doc: this.documentService.get(this.documentId),
396
+ runs: this.documentPipelineRunService.getList(this.documentId),
397
+ })
398
+ .pipe(takeUntilDestroyed(this.destroyRef))
399
+ .subscribe({
400
+ next: ({ doc, runs }) => {
401
+ const previousTypeCode = this.document()?.documentTypeCode ?? null;
402
+ this.document.set(doc);
403
+ this.pipelineRuns.set(runs);
404
+ if (!quiet) {
405
+ this.isLoading.set(false);
406
+ }
407
+ // Original-file blob lazy loading (#274): do not fetch while loading the document by default;
408
+ // download only when the Original File tab is selected. See selectTab.
409
+ // If the user is already on that tab, call ensureFilePreview once after reload
410
+ // (Refresh / rerecognize). It returns early when the blob is cached and resets a previous error
411
+ // for retry, preventing Refresh from being ineffective on a stuck preview (#274 review).
412
+ if (this.activeTab() === 'file') {
413
+ this.ensureFilePreview();
414
+ }
415
+ // Field definitions are used for: 1. displayName in extracted-field display for all viewers;
416
+ // 2. completing empty fields when editable. Backend GetListAsync has been open to
417
+ // Documents.Default since #223, so load whenever a type exists and no longer gate on edit
418
+ // permission. #395: visible types are always loaded (even when unclassified) so the
419
+ // confirm-classification picker has its options.
420
+ // #440: on a quiet poll, reload them only when the type actually changed (classification just
421
+ // resolved) — otherwise the fields card would flash empty on every tick.
422
+ if (!quiet || (doc.documentTypeCode ?? null) !== previousTypeCode) {
423
+ this.fieldDefinitions.set([]);
424
+ this.loadDocumentTypesAndFields(doc.documentTypeCode);
425
+ }
426
+ // #306/#354: a sub-document carries its source (container) id. Fetch the parent's lightweight
427
+ // metadata so the provenance banner can show its title; reset first so a previous document's
428
+ // parent never lingers when navigating between documents in the same component instance.
429
+ // #440: skipped on a quiet poll — provenance is immutable for a loaded document, so re-fetching
430
+ // it would only make the banner flicker.
431
+ if (!quiet) {
432
+ this.parentDocument.set(null);
433
+ this.parentLookupFailed.set(false);
434
+ if (doc.originDocumentId) {
435
+ this.loadParentDocument(doc.originDocumentId);
436
+ }
437
+ }
438
+ // Cabinet name mapping: fetch only when Cabinets.Default is granted and not already loaded. If
439
+ // there is no permission, the cabinet row is hidden.
440
+ if (this.canViewCabinets && this.cabinets().length === 0) {
441
+ this.loadCabinets();
442
+ }
443
+ // #440: (re)schedule or stop the poll based on the freshly loaded state.
444
+ this.syncPolling();
445
+ },
446
+ error: () => {
447
+ if (!quiet) {
448
+ this.isLoading.set(false);
449
+ }
450
+ // #440: stop polling on a failed tick rather than hammering a failing endpoint. Manual Refresh
451
+ // stays available, and any successful (re)load restarts it.
452
+ this.stopPolling();
453
+ },
454
+ });
455
+ }
456
+ // #440: a document warrants polling only while it is genuinely still advancing on the server — a known
457
+ // pipeline run is Pending/Running, or it is pre-terminal (Uploaded/Processing) and NOT parked waiting on
458
+ // the operator. The needs-review guard matters because a blocking review reason (e.g. low-confidence
459
+ // UnresolvedClassification) leaves lifecycle at Processing indefinitely — DeriveLifecycleAsync only
460
+ // reaches Ready once no blocking reason remains — so without the guard such a document would poll forever.
461
+ // When a run is actually in progress (e.g. the operator re-classified a needs-review document) we still
462
+ // poll, because pipelineInProgress() takes precedence.
463
+ shouldPoll() {
464
+ return this.pipelineInProgress() || (this.isProcessing() && !this.needsReview());
465
+ }
466
+ // #440: called after every load. Cancels any pending tick, then — if still in-flight and the tab is
467
+ // visible — schedules the next silent re-fetch, backing the interval off toward the cap.
468
+ syncPolling() {
469
+ this.clearPollTimer();
470
+ if (!this.shouldPoll()) {
471
+ this.pollIntervalMs = POLL_BASE_INTERVAL_MS;
472
+ return;
473
+ }
474
+ if (this.domDocument.hidden) {
475
+ return; // resumed by the visibilitychange handler in the constructor
476
+ }
477
+ this.pollTimer = timer(this.pollIntervalMs).subscribe(() => {
478
+ this.pollIntervalMs = Math.min(this.pollIntervalMs * 2, POLL_MAX_INTERVAL_MS);
479
+ this.pollReload();
480
+ });
481
+ }
482
+ clearPollTimer() {
483
+ this.pollTimer?.unsubscribe();
484
+ this.pollTimer = null;
485
+ }
486
+ stopPolling() {
487
+ this.clearPollTimer();
488
+ this.pollIntervalMs = POLL_BASE_INTERVAL_MS;
489
+ }
490
+ // doc.documentTypeCode is the current code projection in the Document output contract (#207). The
491
+ // field definition API associates by immutable DocumentTypeId, so first resolve code to id from types
492
+ // visible in the current layer, then query. #395: visible types are always stored (the
493
+ // confirm-classification picker needs them even when the document is unclassified); field definitions
494
+ // are only fetched when the document already has a type.
495
+ loadDocumentTypesAndFields(typeCode) {
496
+ this.documentTypeService.getVisible()
497
+ .pipe(
498
+ // One getVisible call serves two purposes: store documentTypes for document type displayName
499
+ // mapping + the confirm picker, then resolve typeId for field definition lookup.
500
+ tap(types => this.documentTypes.set(types)), switchMap(types => {
501
+ if (!typeCode)
502
+ return of([]);
503
+ const documentTypeId = types.find(t => t.typeCode === typeCode)?.id;
504
+ if (!documentTypeId)
505
+ return of([]);
506
+ return this.fieldDefinitionService.getList({ documentTypeId });
507
+ }), takeUntilDestroyed(this.destroyRef))
508
+ .subscribe({
509
+ next: defs => this.fieldDefinitions.set([...defs].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || (a.name ?? '').localeCompare(b.name ?? ''))),
510
+ error: () => this.fieldDefinitions.set([]),
511
+ });
512
+ }
513
+ // Cabinet candidates for name mapping display plus reassignment dropdown (#257). Called only with
514
+ // Cabinets.Default permission.
515
+ loadCabinets() {
516
+ this.cabinetService.getList()
517
+ .pipe(takeUntilDestroyed(this.destroyRef))
518
+ .subscribe({
519
+ next: list => this.cabinets.set(list),
520
+ error: () => this.cabinets.set([]),
521
+ });
522
+ }
523
+ // #306/#354: load the source (container) document of a sub-document for the provenance banner. A removed
524
+ // (soft / permanently deleted) or cross-layer parent (404 / filtered) is an EXPECTED outcome — a sub-document
525
+ // outlives its source — so pass skipHandleError to suppress ABP's global error popup; the component handles it by
526
+ // flagging parentLookupFailed, and the banner shows a "source unavailable" label. It never blocks the
527
+ // sub-document's own page.
528
+ loadParentDocument(originDocumentId) {
529
+ this.documentService.get(originDocumentId, { skipHandleError: true })
530
+ .pipe(takeUntilDestroyed(this.destroyRef))
531
+ .subscribe({
532
+ next: parent => {
533
+ this.parentDocument.set(parent);
534
+ this.parentLookupFailed.set(false);
535
+ },
536
+ error: () => {
537
+ this.parentDocument.set(null);
538
+ this.parentLookupFailed.set(true);
539
+ },
540
+ });
541
+ }
542
+ // Cabinet reassignment (#257): enter edit mode and preselect the current cabinet; empty string means
543
+ // unclassified.
544
+ startEditCabinet() {
545
+ this.selectedCabinetId.set(this.document()?.cabinetId ?? '');
546
+ this.isEditingCabinet.set(true);
547
+ }
548
+ cancelEditCabinet() {
549
+ this.isEditingCabinet.set(false);
550
+ }
551
+ saveCabinet() {
552
+ const doc = this.document();
553
+ if (!doc)
554
+ return;
555
+ this.isSavingCabinet.set(true);
556
+ // Empty string becomes null, meaning removed from cabinet / unclassified. Backend validates cabinet
557
+ // existence and current-layer ownership.
558
+ const cabinetId = this.selectedCabinetId() || null;
559
+ this.documentService.updateCabinet(doc.id, { cabinetId })
560
+ .pipe(takeUntilDestroyed(this.destroyRef))
561
+ .subscribe({
562
+ next: updated => {
563
+ this.document.set(updated);
564
+ this.isSavingCabinet.set(false);
565
+ this.isEditingCabinet.set(false);
566
+ this.toaster.success('::Document:CabinetUpdated', '::Success');
567
+ },
568
+ error: () => {
569
+ this.isSavingCabinet.set(false);
570
+ this.toaster.error('::Document:UpdateFailed', '::Error');
571
+ },
572
+ });
573
+ }
574
+ // Unified entry point for the Original File tab (#274 review): reset the previous preview error first
575
+ // so <img> rebuilds and retries, and failed downloads can refetch. Then ensure the blob is ready; the
576
+ // service prevents duplicate requests and revokes on component destroy.
577
+ // Fixes imageError getting stuck and Refresh being ineffective.
578
+ ensureFilePreview() {
579
+ // A blob-less sub-document has no original file: never call getBlob for it (would throw
580
+ // Extract:DocumentNoSourceBlob). The Original File tab is hidden for it, but loadDocument still reaches
581
+ // here when the tab was active, so guard at the source — this is the path that re-fired on every reload.
582
+ if (!this.hasSourceFile())
583
+ return;
584
+ this.fileBlob.resetError();
585
+ this.fileBlob.ensureLoaded(this.documentId);
586
+ }
587
+ // Tab switch (#274): when switching to Original File, ensure the original-file preview is ready.
588
+ selectTab(tab) {
589
+ this.activeTab.set(tab);
590
+ if (tab === 'file') {
591
+ this.ensureFilePreview();
592
+ }
593
+ }
594
+ // Download File footer action: trigger browser download of the original file directly, avoiding an
595
+ // extra trip through the preview page.
596
+ // If the blob is cached, from viewing Original File or a previous download, trigger immediately. If not
597
+ // cached, set pendingDownload and reuse the service fetch, which prevents duplicate requests. Blob
598
+ // arrival or failure is completed by the constructor effect.
599
+ downloadFile() {
600
+ // Defensive: the Download action is hidden for blob-less sub-documents; never trigger a getBlob for one.
601
+ if (!this.hasSourceFile())
602
+ return;
603
+ if (this.fileBlob.blobUrl()) {
604
+ this.fileBlob.download(this.downloadFileName());
605
+ return;
606
+ }
607
+ this.pendingDownload = true;
608
+ this.fileBlob.ensureLoaded(this.documentId);
609
+ }
610
+ downloadFileName() {
611
+ return this.document()?.fileOrigin?.originalFileName || this.document()?.title || 'document';
612
+ }
613
+ // <img> render failure, where the blob downloaded but decode failed, is folded into the unified preview
614
+ // error signal.
615
+ onPreviewError() {
616
+ this.fileBlob.markError();
617
+ }
618
+ goBack() {
619
+ // Return to wherever the operator came from — the review queue, the list, a cabinet-filtered view, etc.
620
+ // — instead of always the list. The Angular Router stamps an incrementing navigationId on history.state;
621
+ // it is > 1 once any in-app navigation has happened, and 1 on a direct deep-link / refresh (no in-app
622
+ // history to pop). Fall back to the list in that case so Back never leaves the app.
623
+ const navId = this.location.getState()?.navigationId;
624
+ if (navId && navId > 1) {
625
+ this.location.back();
626
+ }
627
+ else {
628
+ this.router.navigate(['/documents/list']);
629
+ }
630
+ }
631
+ // #354: open this sub-document's source (container) document.
632
+ openParentDocument() {
633
+ const originDocumentId = this.document()?.originDocumentId;
634
+ if (!originDocumentId)
635
+ return;
636
+ this.router.navigate(['/documents', originDocumentId]);
637
+ }
638
+ // #411: open a suspected-duplicate candidate so the operator can compare it before allowing / discarding.
639
+ openDocument(documentId) {
640
+ this.router.navigate(['/documents', documentId]);
641
+ }
642
+ // #354: list the sibling sub-documents (all those derived from the same source, including this one),
643
+ // reusing the list's originDocumentId provenance filter via a deep-link query param.
644
+ viewSiblingDocuments() {
645
+ const originDocumentId = this.document()?.originDocumentId;
646
+ if (!originDocumentId)
647
+ return;
648
+ this.router.navigate(['/documents/list'], { queryParams: { originDocumentId } });
649
+ }
650
+ delete() {
651
+ const doc = this.document();
652
+ if (!doc)
653
+ return;
654
+ this.confirmation
655
+ .warn('::Document:AreYouSureToDelete', '::AreYouSure')
656
+ .pipe(takeUntilDestroyed(this.destroyRef))
657
+ .subscribe(status => {
658
+ if (status !== Confirmation.Status.confirm)
659
+ return;
660
+ this.documentService.delete(doc.id)
661
+ .pipe(takeUntilDestroyed(this.destroyRef))
662
+ .subscribe({
663
+ next: () => {
664
+ this.toaster.success('::Document:DeletedSuccessfully', '::Success');
665
+ this.router.navigate(['/documents/list']);
666
+ },
667
+ });
668
+ });
669
+ }
670
+ // #263 "rerecognize": rerun AI automatic classification on the existing Markdown, cascading to field
671
+ // re-extraction without rerunning OCR.
672
+ // This is an overwriting operation, replacing current type and manually edited field values, so confirm
673
+ // first. Reload after success to reflect Processing state.
674
+ rerecognize() {
675
+ const doc = this.document();
676
+ if (!doc || this.isRerecognizing())
677
+ return;
678
+ this.confirmation
679
+ .warn('::Document:Rerecognize:Confirm', '::AreYouSure')
680
+ .pipe(takeUntilDestroyed(this.destroyRef))
681
+ .subscribe(status => {
682
+ if (status !== Confirmation.Status.confirm)
683
+ return;
684
+ this.isRerecognizing.set(true);
685
+ this.documentService.rerecognize(doc.id)
686
+ .pipe(takeUntilDestroyed(this.destroyRef))
687
+ .subscribe({
688
+ next: () => {
689
+ this.isRerecognizing.set(false);
690
+ this.toaster.success('::Document:RerecognizeQueued', '::Success');
691
+ this.loadDocument();
692
+ },
693
+ error: () => {
694
+ this.isRerecognizing.set(false);
695
+ this.toaster.error('::Document:RerecognizeFailed', '::Error');
696
+ },
697
+ });
698
+ });
699
+ }
700
+ // #289 "field re-extraction only": rerun only the field-extraction pipeline on the existing
701
+ // classification, without reclassification or OCR.
702
+ // Safe leaf operation that overwrites only field values, including manual corrections. Confirm first.
703
+ // Lifecycle-neutral: does not move already Ready documents backward.
704
+ reextractFields() {
705
+ const doc = this.document();
706
+ if (!doc || this.isReextracting())
707
+ return;
708
+ this.confirmation
709
+ .warn('::Document:ReextractFields:Confirm', '::AreYouSure')
710
+ .pipe(takeUntilDestroyed(this.destroyRef))
711
+ .subscribe(status => {
712
+ if (status !== Confirmation.Status.confirm)
713
+ return;
714
+ this.isReextracting.set(true);
715
+ this.documentService.reextractFields(doc.id)
716
+ .pipe(takeUntilDestroyed(this.destroyRef))
717
+ .subscribe({
718
+ next: () => {
719
+ this.isReextracting.set(false);
720
+ this.toaster.success('::Document:ReextractFieldsQueued', '::Success');
721
+ this.loadDocument();
722
+ },
723
+ error: () => {
724
+ this.isReextracting.set(false);
725
+ this.toaster.error('::Document:ReextractFieldsFailed', '::Error');
726
+ },
727
+ });
728
+ });
729
+ }
730
+ // #395: manual confirm / assign document type. Authoritative override that clears
731
+ // UnresolvedClassification and cascades field extraction (backend confirmClassification). Defaults the
732
+ // picker to the document's current low-confidence type when present so the operator usually just confirms.
733
+ openClassifyDialog() {
734
+ const doc = this.document();
735
+ if (!doc)
736
+ return;
737
+ this.selectedTypeId.set(this.documentTypes().find(t => t.typeCode === doc.documentTypeCode)?.id ?? '');
738
+ this.showClassifyDialog.set(true);
739
+ }
740
+ closeClassifyDialog() {
741
+ this.showClassifyDialog.set(false);
742
+ this.selectedTypeId.set('');
743
+ }
744
+ submitClassify() {
745
+ const doc = this.document();
746
+ if (!doc || !this.selectedTypeId() || this.isConfirmingClassification())
747
+ return;
748
+ this.isConfirmingClassification.set(true);
749
+ this.documentService.confirmClassification(doc.id, { documentTypeId: this.selectedTypeId() })
750
+ .pipe(takeUntilDestroyed(this.destroyRef))
751
+ .subscribe({
752
+ next: () => {
753
+ this.isConfirmingClassification.set(false);
754
+ this.closeClassifyDialog();
755
+ this.toaster.success('::Document:ClassificationConfirmed', '::Success');
756
+ this.loadDocument();
757
+ },
758
+ error: () => {
759
+ this.isConfirmingClassification.set(false);
760
+ this.toaster.error('::Document:ConfirmFailed', '::Error');
761
+ },
762
+ });
763
+ }
764
+ // #395: reject the document (#237/#284 recoverable disposition). The rejection reason is required by the
765
+ // backend RejectReviewInput.Reason; reload after success so disposition/badge reflect the new state.
766
+ openRejectDialog() {
767
+ if (!this.document())
768
+ return;
769
+ this.rejectReason.set('');
770
+ this.showRejectDialog.set(true);
771
+ }
772
+ closeRejectDialog() {
773
+ this.showRejectDialog.set(false);
774
+ this.rejectReason.set('');
775
+ }
776
+ submitReject() {
777
+ const doc = this.document();
778
+ if (!doc || this.isRejecting())
779
+ return;
780
+ const reason = this.rejectReason().trim();
781
+ if (!reason) {
782
+ this.toaster.warn('::Document:Review:RejectReasonRequired');
783
+ return;
784
+ }
785
+ this.isRejecting.set(true);
786
+ this.documentService.rejectReview(doc.id, { reason })
787
+ .pipe(takeUntilDestroyed(this.destroyRef))
788
+ .subscribe({
789
+ next: () => {
790
+ this.isRejecting.set(false);
791
+ this.closeRejectDialog();
792
+ this.toaster.success('::Document:Review:RejectedSuccessfully', '::Success');
793
+ this.loadDocument();
794
+ },
795
+ error: () => {
796
+ this.isRejecting.set(false);
797
+ this.toaster.error('::Document:Review:ActionFailed', '::Error');
798
+ },
799
+ });
800
+ }
801
+ // #411: operator decides a suspected duplicate is not a duplicate (or is an acceptable re-upload). The backend
802
+ // sets the durable DuplicateAllowed override, clears the blocking reason, and re-derives lifecycle (releasing the
803
+ // document to Ready + DocumentReadyEto when nothing else blocks). Reload so the badge / banner reflect the change.
804
+ allowDuplicate() {
805
+ const doc = this.document();
806
+ if (!doc || this.isAllowingDuplicate())
807
+ return;
808
+ this.isAllowingDuplicate.set(true);
809
+ this.documentService.allowDuplicate(doc.id)
810
+ .pipe(takeUntilDestroyed(this.destroyRef))
811
+ .subscribe({
812
+ next: () => {
813
+ this.isAllowingDuplicate.set(false);
814
+ this.toaster.success('::Document:Review:DuplicateAllowed', '::Success');
815
+ this.loadDocument();
816
+ },
817
+ error: () => {
818
+ this.isAllowingDuplicate.set(false);
819
+ this.toaster.error('::Document:Review:ActionFailed', '::Error');
820
+ },
821
+ });
822
+ }
823
+ getStatusBadgeClass(status) {
824
+ switch (status) {
825
+ case DocumentLifecycleStatus.Uploaded: return 'badge bg-secondary';
826
+ case DocumentLifecycleStatus.Processing: return 'badge bg-warning text-dark';
827
+ case DocumentLifecycleStatus.Ready: return 'badge bg-success';
828
+ case DocumentLifecycleStatus.Failed: return 'badge bg-danger';
829
+ default: return 'badge bg-secondary';
830
+ }
831
+ }
832
+ // #284: header badge shows only lifecycle on the availability axis. Review state is expressed by the
833
+ // banner plus detail-area reviewReasonDetails and is not duplicated.
834
+ getDocumentStatusBadgeClass(doc) {
835
+ return this.getStatusBadgeClass(doc.lifecycleStatus);
836
+ }
837
+ getStatusLabel(status) {
838
+ switch (status) {
839
+ case DocumentLifecycleStatus.Uploaded: return '::Document:Status:Uploaded';
840
+ case DocumentLifecycleStatus.Processing: return '::Document:Status:Processing';
841
+ case DocumentLifecycleStatus.Ready: return '::Document:Status:Ready';
842
+ case DocumentLifecycleStatus.Failed: return '::Document:Status:Failed';
843
+ default: return '::Document:Status:Unknown';
844
+ }
845
+ }
846
+ getDocumentStatusLabel(doc) {
847
+ return this.getStatusLabel(doc.lifecycleStatus);
848
+ }
849
+ // #284: review reason to localization key, used for detail-area reviewReasonDetails rendering.
850
+ reviewReasonLabel(reason) {
851
+ switch (reason) {
852
+ case DocumentReviewReasons.UnresolvedClassification:
853
+ return '::Document:ReviewReason:UnresolvedClassification';
854
+ case DocumentReviewReasons.MissingRequiredFields:
855
+ return '::Document:ReviewReason:MissingRequiredFields';
856
+ case DocumentReviewReasons.SegmentationIncomplete:
857
+ return '::Document:ReviewReason:SegmentationIncomplete';
858
+ case DocumentReviewReasons.DuplicateSuspected:
859
+ return '::Document:ReviewReason:DuplicateSuspected';
860
+ default:
861
+ return '::Document:NeedsReview';
862
+ }
863
+ }
864
+ // #412: per-reason remediation hint shown in the banner, so the operator knows what each reason expects
865
+ // without inferring it from the (reason-neutral) headline. Each reason has exactly one remedy: assign a
866
+ // type, fill the fields below, re-classify, or release/delete the duplicate.
867
+ reviewReasonHint(reason) {
868
+ switch (reason) {
869
+ case DocumentReviewReasons.UnresolvedClassification:
870
+ return '::Document:Review:Hint:UnresolvedClassification';
871
+ case DocumentReviewReasons.MissingRequiredFields:
872
+ return '::Document:Review:Hint:MissingRequiredFields';
873
+ case DocumentReviewReasons.SegmentationIncomplete:
874
+ return '::Document:Review:Hint:SegmentationIncomplete';
875
+ case DocumentReviewReasons.DuplicateSuspected:
876
+ return '::Document:Review:Hint:DuplicateSuspected';
877
+ default:
878
+ return '::Document:NeedsReview';
879
+ }
880
+ }
881
+ // #412: "Complete fields" CTA — jump to the extracted-fields card and open the edit form so the missing
882
+ // required values can be filled. Filling them clears MissingRequiredFields server-side on the next save.
883
+ completeFields() {
884
+ if (this.canEditFields && this.fieldDefinitions().length > 0 && !this.isEditingFields()) {
885
+ this.startEditFields();
886
+ }
887
+ document.getElementById('extracted-fields-card')
888
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' });
889
+ }
890
+ getRunStatusBadgeClass(status) {
891
+ switch (status) {
892
+ case PipelineRunStatus.Pending: return 'badge bg-secondary';
893
+ case PipelineRunStatus.Running: return 'badge bg-warning text-dark';
894
+ case PipelineRunStatus.Succeeded: return 'badge bg-success';
895
+ case PipelineRunStatus.Failed: return 'badge bg-danger';
896
+ case PipelineRunStatus.Skipped: return 'badge bg-light text-dark border';
897
+ default: return 'badge bg-light text-muted border';
898
+ }
899
+ }
900
+ getRunStatusLabel(status) {
901
+ switch (status) {
902
+ case PipelineRunStatus.Pending: return '::Document:Pipeline:Status:Pending';
903
+ case PipelineRunStatus.Running: return '::Document:Pipeline:Status:Running';
904
+ case PipelineRunStatus.Succeeded: return '::Document:Pipeline:Status:Succeeded';
905
+ case PipelineRunStatus.Failed: return '::Document:Pipeline:Status:Failed';
906
+ case PipelineRunStatus.Skipped: return '::Document:Pipeline:Status:Skipped';
907
+ default: return '::Document:Pipeline:Status:NotStarted';
908
+ }
909
+ }
910
+ isRunInProgress(status) {
911
+ return status === PipelineRunStatus.Pending || status === PipelineRunStatus.Running;
912
+ }
913
+ isRetryable(run) {
914
+ return !!run && run.status === PipelineRunStatus.Failed;
915
+ }
916
+ retryPipeline(pipelineCode) {
917
+ if (this.retryingPipeline() !== null)
918
+ return;
919
+ this.retryingPipeline.set(pipelineCode);
920
+ this.documentService.retryPipeline(this.documentId, { pipelineCode })
921
+ .pipe(takeUntilDestroyed(this.destroyRef))
922
+ .subscribe({
923
+ next: () => {
924
+ this.retryingPipeline.set(null);
925
+ this.toaster.success('::Document:Pipeline:RetryQueued', '::Success');
926
+ this.loadDocument();
927
+ },
928
+ error: () => {
929
+ this.retryingPipeline.set(null);
930
+ this.toaster.error('::Document:Pipeline:RetryFailed', '::Error');
931
+ },
932
+ });
933
+ }
934
+ getElapsedMs(run) {
935
+ if (!run.startedAt)
936
+ return null;
937
+ const start = new Date(run.startedAt).getTime();
938
+ if (Number.isNaN(start))
939
+ return null;
940
+ const end = run.completedAt ? new Date(run.completedAt).getTime() : Date.now();
941
+ if (Number.isNaN(end) || end < start)
942
+ return null;
943
+ return end - start;
944
+ }
945
+ formatFieldValue(value) {
946
+ return formatExtractedFieldValue(value);
947
+ }
948
+ startEditFields() {
949
+ this.extractedFieldFormFields.set(this.createExtractedFieldFormFields());
950
+ this.isEditingFields.set(true);
951
+ }
952
+ cancelEditFields() {
953
+ this.isEditingFields.set(false);
954
+ this.extractedFieldFormFields.set([]);
955
+ }
956
+ saveFields(formValue) {
957
+ const doc = this.document();
958
+ if (!doc)
959
+ return;
960
+ this.isSavingFields.set(true);
961
+ const fields = {};
962
+ for (const def of this.fieldDefinitions()) {
963
+ const key = def.name ?? '';
964
+ const value = formValue[key];
965
+ if (this.shouldOmitFieldValue(value))
966
+ continue;
967
+ fields[key] = this.coerceValue(def, value);
968
+ }
969
+ this.documentService.updateExtractedFields(doc.id, { fields })
970
+ .pipe(takeUntilDestroyed(this.destroyRef))
971
+ .subscribe({
972
+ next: updated => {
973
+ this.document.set(updated);
974
+ this.isSavingFields.set(false);
975
+ this.isEditingFields.set(false);
976
+ this.extractedFieldFormFields.set([]);
977
+ this.toaster.success('::Document:FieldsUpdated', '::Success');
978
+ },
979
+ error: () => {
980
+ this.isSavingFields.set(false);
981
+ this.toaster.error('::Document:UpdateFailed', '::Error');
982
+ },
983
+ });
984
+ }
985
+ createExtractedFieldFormFields() {
986
+ const values = this.document()?.extractedFields ?? {};
987
+ return this.fieldDefinitions().map(def => {
988
+ const config = {
989
+ key: def.name ?? '',
990
+ label: `${def.displayName} (${def.name})`,
991
+ // Multi-value fields (#212, text only) use a textarea with one value per line; single-value fields
992
+ // choose input type by DataType.
993
+ type: def.allowMultiple ? 'textarea' : this.toFormFieldType(def.dataType),
994
+ value: this.toFormInitialValue(def, values[def.name ?? '']),
995
+ required: def.isRequired,
996
+ order: def.displayOrder,
997
+ gridSize: 12,
998
+ validators: def.isRequired
999
+ ? [{ type: 'required', message: '::FieldDefinition:Required' }]
1000
+ : [],
1001
+ };
1002
+ if (def.allowMultiple) {
1003
+ config.placeholder = this.localization.instant('::FieldDefinition:AllowMultipleEditHint');
1004
+ }
1005
+ else if (def.dataType === FieldDataType.Number) {
1006
+ config.step = 'any';
1007
+ }
1008
+ else if (def.dataType === FieldDataType.Boolean) {
1009
+ config.options = {
1010
+ defaultValues: [
1011
+ { key: 'true', value: 'true' },
1012
+ { key: 'false', value: 'false' },
1013
+ ],
1014
+ };
1015
+ }
1016
+ return config;
1017
+ });
1018
+ }
1019
+ toFormFieldType(dataType) {
1020
+ switch (dataType) {
1021
+ // Long text, such as summaries or descriptions, uses a multiline editor. The default branch of
1022
+ // toFormInitialValue already fills it back as a string unchanged.
1023
+ case FieldDataType.LongText:
1024
+ return 'textarea';
1025
+ case FieldDataType.Number:
1026
+ return 'number';
1027
+ case FieldDataType.Boolean:
1028
+ return 'select';
1029
+ case FieldDataType.Date:
1030
+ return 'date';
1031
+ case FieldDataType.DateTime:
1032
+ return 'datetime-local';
1033
+ default:
1034
+ return 'text';
1035
+ }
1036
+ }
1037
+ toFormInitialValue(def, value) {
1038
+ // Multi-value fields (#212): output array becomes one textarea line per value. Non-arrays, including
1039
+ // null or unextracted values, become empty.
1040
+ if (def.allowMultiple) {
1041
+ return Array.isArray(value) ? value.map(v => String(v)).join('\n') : '';
1042
+ }
1043
+ if (value === null || value === undefined)
1044
+ return '';
1045
+ switch (def.dataType) {
1046
+ case FieldDataType.Number:
1047
+ return this.toNumberInputValue(value);
1048
+ case FieldDataType.Boolean:
1049
+ return this.parseBoolean(value) ? 'true' : 'false';
1050
+ case FieldDataType.Date:
1051
+ return this.toDateInputValue(value);
1052
+ case FieldDataType.DateTime:
1053
+ return this.toDateTimeLocalInputValue(value);
1054
+ default:
1055
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
1056
+ }
1057
+ }
1058
+ shouldOmitFieldValue(value) {
1059
+ return value === null ||
1060
+ value === undefined ||
1061
+ (typeof value === 'string' && value.trim() === '');
1062
+ }
1063
+ // Convert to the corresponding JSON type by field DataType. Date/DateTime/Text are always stored as
1064
+ // strings.
1065
+ coerceValue(def, value) {
1066
+ // Multi-value fields (#212): textarea one value per line, trimmed and with empty lines removed,
1067
+ // becomes string[], symmetric with backend UpdateExtractedFieldsAsync receiving arrays.
1068
+ if (def.allowMultiple) {
1069
+ return String(value ?? '')
1070
+ .split(/\r?\n/)
1071
+ .map(s => s.trim())
1072
+ .filter(s => s.length > 0);
1073
+ }
1074
+ switch (def.dataType) {
1075
+ case FieldDataType.Number: {
1076
+ const n = typeof value === 'number' ? value : Number(value);
1077
+ return !Number.isNaN(n) ? n : value;
1078
+ }
1079
+ case FieldDataType.Boolean:
1080
+ return this.parseBoolean(value);
1081
+ default:
1082
+ return value;
1083
+ }
1084
+ }
1085
+ toNumberInputValue(value) {
1086
+ const raw = String(value).trim();
1087
+ if (raw === '')
1088
+ return '';
1089
+ const n = Number(raw);
1090
+ return Number.isNaN(n) ? '' : raw;
1091
+ }
1092
+ parseBoolean(value) {
1093
+ if (typeof value === 'boolean')
1094
+ return value;
1095
+ if (typeof value === 'number')
1096
+ return value !== 0;
1097
+ const normalized = String(value).trim().toLowerCase();
1098
+ return normalized === 'true' || normalized === '1' || normalized === 'yes';
1099
+ }
1100
+ toDateInputValue(value) {
1101
+ const raw = String(value);
1102
+ return /^\d{4}-\d{2}-\d{2}/.test(raw) ? raw.slice(0, 10) : raw;
1103
+ }
1104
+ toDateTimeLocalInputValue(value) {
1105
+ const raw = String(value);
1106
+ if (!raw)
1107
+ return '';
1108
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(raw)) {
1109
+ return raw.slice(0, 16);
1110
+ }
1111
+ const parsed = new Date(raw);
1112
+ if (Number.isNaN(parsed.getTime()))
1113
+ return raw;
1114
+ const pad = (n) => String(n).padStart(2, '0');
1115
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}` +
1116
+ `T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
1117
+ }
1118
+ formatElapsed(run) {
1119
+ const ms = this.getElapsedMs(run);
1120
+ if (ms == null)
1121
+ return '';
1122
+ if (ms < 1000)
1123
+ return `${ms} ms`;
1124
+ const seconds = ms / 1000;
1125
+ if (seconds < 60)
1126
+ return `${seconds.toFixed(1)} s`;
1127
+ const minutes = Math.floor(seconds / 60);
1128
+ const remSeconds = Math.round(seconds - minutes * 60);
1129
+ return `${minutes}m ${remSeconds}s`;
1130
+ }
1131
+ pickLatestRun(runs, pipelineCode) {
1132
+ const matches = runs.filter(r => r.pipelineCode === pipelineCode);
1133
+ if (matches.length === 0)
1134
+ return null;
1135
+ return matches.reduce((prev, curr) => ((curr.attemptNumber ?? 0) > (prev.attemptNumber ?? 0) ? curr : prev));
1136
+ }
1137
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: DocumentDetailComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1138
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: DocumentDetailComponent, isStandalone: true, selector: "lib-document-detail", providers: [DocumentFileBlobService], ngImport: i0, template: "<div class=\"container-fluid py-4\">\n\n <!-- Back + header -->\n <div class=\"d-flex align-items-center mb-3 gap-2\">\n <button class=\"btn btn-outline-secondary btn-sm\" (click)=\"goBack()\">\n <i class=\"fas fa-arrow-left me-1\"></i>\n {{ '::Back' | abpLocalization }}\n </button>\n @if (document()) {\n <span class=\"ms-2 fw-semibold text-truncate\" style=\"max-width: 480px;\" [title]=\"document()!.title || document()!.fileOrigin?.originalFileName\">\n {{ document()!.title || document()!.fileOrigin?.originalFileName }}\n </span>\n <span [class]=\"getDocumentStatusBadgeClass(document()!)\" class=\"ms-2\">\n @if (isProcessing()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n }\n {{ getDocumentStatusLabel(document()!) | abpLocalization }}\n </span>\n }\n <div class=\"ms-auto d-flex gap-2\">\n <!-- Display label is \"\u91CD\u65B0\u5206\u7C7B / Re-classify\": it re-runs classification on the already-extracted text,\n NOT OCR (the #263 internal name stays \"rerecognize\"). The tooltip states the original file is not\n re-scanned, to kill the \"re-OCR from the original file\" misconception. -->\n @if (canRerecognize()) {\n <button\n class=\"btn btn-outline-primary btn-sm\"\n (click)=\"rerecognize()\"\n [disabled]=\"isRerecognizing()\"\n title=\"{{ '::Document:Rerecognize:Hint' | abpLocalization }}\"\n >\n @if (isRerecognizing()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:Rerecognizing' | abpLocalization }}\n } @else {\n <i class=\"fas fa-wand-magic-sparkles me-1\"></i>\n {{ '::Document:Rerecognize' | abpLocalization }}\n }\n </button>\n }\n @if (canReextractFields()) {\n <button\n class=\"btn btn-outline-secondary btn-sm\"\n (click)=\"reextractFields()\"\n [disabled]=\"isReextracting()\"\n title=\"{{ '::Document:ReextractFields' | abpLocalization }}\"\n >\n @if (isReextracting()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:ReextractFieldsInProgress' | abpLocalization }}\n } @else {\n <i class=\"fas fa-list-check me-1\"></i>\n {{ '::Document:ReextractFields' | abpLocalization }}\n }\n </button>\n }\n @if (canDelete && document()) {\n <button\n class=\"btn btn-outline-danger btn-sm\"\n (click)=\"delete()\"\n title=\"{{ '::Delete' | abpLocalization }}\"\n >\n <i class=\"fas fa-trash me-1\"></i>\n {{ '::Delete' | abpLocalization }}\n </button>\n }\n <button\n class=\"btn btn-outline-secondary btn-sm\"\n (click)=\"refresh()\"\n [disabled]=\"isLoading()\"\n title=\"{{ '::Refresh' | abpLocalization }}\"\n >\n <i class=\"fas fa-sync-alt\" [class.fa-spin]=\"isLoading()\"></i>\n </button>\n </div>\n </div>\n\n <!-- Loading state -->\n @if (isLoading()) {\n <div class=\"text-center py-5\">\n <div class=\"spinner-border text-primary\" role=\"status\"></div>\n </div>\n }\n\n @if (document()) {\n <!-- Sub-document provenance banner (#306/#354): this document was derived from a container document.\n Offer navigation back to the source and to its sibling sub-documents. -->\n @if (isSubDocument()) {\n <div class=\"alert alert-info d-flex flex-wrap align-items-center gap-2 mb-3\" role=\"alert\">\n <i class=\"fas fa-sitemap me-1\"></i>\n <span>\n {{ '::Document:SubDocumentOf' | abpLocalization }}\n @if (parentDocument(); as parent) {\n <strong>{{ parent.title || parent.fileOrigin?.originalFileName || document()!.originDocumentId }}</strong>\n } @else if (parentLookupFailed()) {\n <!-- Source removed (soft / permanently deleted) or cross-layer: show an explicit \"unavailable\" label\n instead of a raw id, and drop the dead \"view parent\" link below. The sub-document stays fully usable. -->\n <strong>{{ '::Document:SourceDocumentUnavailable' | abpLocalization }}</strong>\n } @else {\n <strong>{{ document()!.originDocumentId }}</strong>\n }\n </span>\n <div class=\"ms-auto d-flex gap-2\">\n @if (parentDocument()) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary\" (click)=\"openParentDocument()\">\n <i class=\"fas fa-arrow-up me-1\"></i>{{ '::Document:ViewParentDocument' | abpLocalization }}\n </button>\n }\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"viewSiblingDocuments()\">\n <i class=\"fas fa-sitemap me-1\"></i>{{ '::Document:ViewSiblingDocuments' | abpLocalization }}\n </button>\n </div>\n </div>\n }\n\n <!-- Review / processing banner. #395/#412: contextual remediation actions live here. The banner is\n reason-aware \u2014 the active review reasons drive both the badges and which CTA appears: classification\n is confirmed/assigned via the dialog, missing fields are completed in the fields card below, a\n suspected duplicate is released, and the document can always be rejected. -->\n @if (needsReview()) {\n <div class=\"alert alert-warning mb-3\" role=\"alert\">\n <div class=\"d-flex align-items-center flex-wrap gap-2\">\n <i class=\"fas fa-user-edit me-2\"></i>\n <span>{{ '::Document:PendingReviewMessage' | abpLocalization }}</span>\n <!-- #412: surface the concrete reason(s) right here, so the operator no longer has to read them off\n the metadata list in the right column. Blocking reasons are red, advisory ones amber. -->\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <span class=\"badge\"\n [class.bg-danger]=\"detail.isBlocking\"\n [class.bg-warning]=\"!detail.isBlocking\"\n [class.text-dark]=\"!detail.isBlocking\">\n {{ reviewReasonLabel(detail.reason) | abpLocalization }}\n </span>\n }\n @if (canEditFields) {\n <div class=\"ms-auto d-flex gap-2\">\n @if (needsClassification()) {\n <button type=\"button\" class=\"btn btn-sm btn-warning\" (click)=\"openClassifyDialog()\">\n <i class=\"fas fa-check me-1\"></i>\n {{ '::Document:ConfirmClassification' | abpLocalization }}\n </button>\n }\n <!-- #412: missing required fields are completed below \u2014 jump there and open the editor. -->\n @if (needsFieldCompletion()) {\n <button type=\"button\" class=\"btn btn-sm btn-warning\" (click)=\"completeFields()\">\n <i class=\"fas fa-pen me-1\"></i>\n {{ '::Document:Review:CompleteFields' | abpLocalization }}\n </button>\n }\n <!-- #411: resolve a suspected duplicate \u2014 \"Allow\" releases it (not a duplicate / acceptable re-upload);\n confirming the duplicate is the Delete action in the header toolbar. -->\n @if (needsDuplicateReview()) {\n <button type=\"button\" class=\"btn btn-sm btn-success\" (click)=\"allowDuplicate()\" [disabled]=\"isAllowingDuplicate()\">\n <i class=\"fas fa-check-double me-1\"></i>\n {{ '::Document:Review:AllowDuplicate' | abpLocalization }}\n </button>\n }\n <button type=\"button\" class=\"btn btn-sm btn-outline-danger\" (click)=\"openRejectDialog()\">\n <i class=\"fas fa-ban me-1\"></i>\n {{ '::Document:Review:Reject' | abpLocalization }}\n </button>\n </div>\n }\n </div>\n <!-- #412: one remediation hint per active reason, so each reason states its own expected action\n instead of leaning on the single (reason-neutral) headline above. -->\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <div class=\"small text-muted mt-2\">\n {{ reviewReasonHint(detail.reason) | abpLocalization }}\n @if (detail.missingFieldNames?.length) {\n <span class=\"fw-semibold\">{{ detail.missingFieldNames!.join('\u3001') }}</span>\n }\n </div>\n }\n </div>\n } @else if (isProcessing()) {\n <div class=\"alert alert-warning d-flex align-items-center mb-3\" role=\"alert\">\n <div class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></div>\n <span>{{ '::Document:ProcessingMessage' | abpLocalization }}</span>\n </div>\n }\n\n <div class=\"row g-3\">\n <!-- LEFT: Tab area: Markdown preview / source / original file (#274) -->\n <div class=\"col-lg-6\">\n <div class=\"card shadow-sm h-100 d-flex flex-column\">\n <div class=\"card-header p-0 pt-2\">\n <ul class=\"nav nav-tabs card-header-tabs mx-2 mb-0\">\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'preview'\"\n (click)=\"selectTab('preview')\">\n <i class=\"fas fa-eye me-1\"></i>{{ '::Document:Tab:Preview' | abpLocalization }}\n </button>\n </li>\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'source'\"\n (click)=\"selectTab('source')\">\n <i class=\"fas fa-code me-1\"></i>{{ '::Document:Tab:Source' | abpLocalization }}\n </button>\n </li>\n <!-- Sub-documents have no source blob (#306/#346); hide the Original File tab so the UI never\n calls GetBlobAsync for them (Extract:DocumentNoSourceBlob). -->\n @if (hasSourceFile()) {\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'file'\"\n (click)=\"selectTab('file')\">\n <i class=\"fas fa-file me-1\"></i>{{ '::Document:OriginalFile' | abpLocalization }}\n </button>\n </li>\n }\n </ul>\n </div>\n <div class=\"card-body overflow-auto\" style=\"min-height: 400px; max-height: 80vh;\">\n @switch (activeTab()) {\n @case ('preview') {\n @if (document()!.markdown) {\n <div class=\"markdown-body\" [innerHTML]=\"renderedMarkdown()\"></div>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-align-left fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:Markdown:Empty' | abpLocalization }}</p>\n </div>\n }\n }\n @case ('source') {\n @if (document()!.markdown) {\n <pre class=\"ocr-text mb-0\">{{ document()!.markdown }}</pre>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <p class=\"mb-0\">{{ '::Document:Markdown:Empty' | abpLocalization }}</p>\n </div>\n }\n }\n @case ('file') {\n @if (fileBlob.isLoading()) {\n <div class=\"text-center py-5\">\n <div class=\"spinner-border text-primary\" role=\"status\"></div>\n </div>\n } @else if (fileBlob.hasError()) {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-exclamation-triangle fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:PreviewUnavailable' | abpLocalization }}</p>\n </div>\n } @else if (isImage() && fileBlob.blobUrl()) {\n <div class=\"text-center\">\n <img\n [src]=\"fileBlob.blobUrl()\"\n (error)=\"onPreviewError()\"\n alt=\"{{ document()!.fileOrigin?.originalFileName }}\"\n class=\"img-fluid preview-image\"\n style=\"max-height: 70vh; object-fit: contain;\"\n />\n </div>\n } @else if (isPdf() && fileBlob.safeResourceUrl()) {\n <iframe\n [src]=\"fileBlob.safeResourceUrl()\"\n [title]=\"document()!.fileOrigin?.originalFileName\"\n class=\"w-100 border rounded\"\n style=\"min-height: 70vh;\"\n ></iframe>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-image fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:PreviewUnavailable' | abpLocalization }}</p>\n </div>\n }\n }\n }\n </div>\n <!-- Source-file footer (name / type / download): only for documents that actually have a source\n blob. A sub-document (#306/#346) has none, so hiding this avoids an empty footer and a Download\n button that would fail with Extract:DocumentNoSourceBlob. -->\n @if (hasSourceFile()) {\n <div class=\"card-footer text-muted small d-flex align-items-center gap-2\">\n <i class=\"fas fa-file\"></i>\n <span class=\"text-truncate\">{{ document()!.fileOrigin?.originalFileName }}</span>\n <span>\u00B7</span>\n <span class=\"flex-shrink-0\">{{ document()!.fileOrigin?.contentType }}</span>\n <button type=\"button\" (click)=\"downloadFile()\" [disabled]=\"fileBlob.isLoading()\"\n class=\"btn btn-sm btn-link p-0 ms-auto flex-shrink-0\">\n @if (fileBlob.isLoading()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fas fa-download me-1\"></i>\n }\n {{ '::Document:DownloadFile' | abpLocalization }}\n </button>\n </div>\n }\n </div>\n </div>\n\n <!-- RIGHT: Document metadata, fields, pipelines -->\n <div class=\"col-lg-6 d-flex flex-column\">\n <div class=\"d-flex flex-column gap-3\">\n <div class=\"card shadow-sm\">\n <div class=\"card-body\">\n <dl class=\"row mb-0 small\">\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:Type' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (documentTypeDisplayName(); as typeName) {\n <span class=\"badge bg-info text-dark\">{{ typeName }}</span>\n } @else {\n <span class=\"text-muted\">\u2014</span>\n }\n </dd>\n\n @if (canViewCabinets) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:Cabinet' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (isEditingCabinet()) {\n <div class=\"d-flex align-items-center gap-2 flex-wrap\">\n <select class=\"form-select form-select-sm w-auto\"\n [ngModel]=\"selectedCabinetId()\"\n (ngModelChange)=\"selectedCabinetId.set($event)\"\n [disabled]=\"isSavingCabinet()\">\n <option value=\"\">{{ '::Document:Unfiled' | abpLocalization }}</option>\n @for (c of cabinets(); track c.id) {\n <option [value]=\"c.id\">{{ c.name }}</option>\n }\n </select>\n <button type=\"button\" class=\"btn btn-sm btn-primary\"\n (click)=\"saveCabinet()\" [disabled]=\"isSavingCabinet()\">\n {{ '::Save' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\"\n (click)=\"cancelEditCabinet()\" [disabled]=\"isSavingCabinet()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n </div>\n } @else {\n @if (cabinetName(); as name) {\n <span class=\"badge bg-body-tertiary border\">\n <i class=\"fas fa-folder text-warning me-1\"></i>{{ name }}\n </span>\n } @else {\n <span class=\"text-muted\">{{ '::Document:Unfiled' | abpLocalization }}</span>\n }\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 ms-2\"\n (click)=\"startEditCabinet()\">\n {{ '::Edit' | abpLocalization }}\n </button>\n }\n </dd>\n }\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:ClassificationConfidence' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if ((document()!.classificationConfidence ?? 0) > 0) {\n {{ ((document()!.classificationConfidence ?? 0) * 100).toFixed(0) }}%\n } @else {\n \u2014\n }\n </dd>\n\n @if (document()!.reviewDisposition === DocumentReviewDisposition.Confirmed || document()!.reviewDisposition === DocumentReviewDisposition.Rejected) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:ReviewStatus' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (document()!.reviewDisposition === DocumentReviewDisposition.Confirmed) {\n <span class=\"badge bg-success\">\n {{ '::Document:ReviewDisposition:Confirmed' | abpLocalization }}\n </span>\n } @else {\n <span class=\"badge bg-danger\">\n {{ '::Document:ReviewDisposition:Rejected' | abpLocalization }}\n </span>\n }\n </dd>\n }\n\n @if (document()!.reviewReasonDetails?.length) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:NeedsReview' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <div class=\"small mb-1\">\n <span class=\"badge\"\n [class.bg-danger]=\"detail.isBlocking\"\n [class.bg-warning]=\"!detail.isBlocking\"\n [class.text-dark]=\"!detail.isBlocking\">\n {{ reviewReasonLabel(detail.reason) | abpLocalization }}\n </span>\n @if (detail.missingFieldNames?.length) {\n <span class=\"text-muted ms-1\">{{ detail.missingFieldNames!.join('\u3001') }}</span>\n }\n <!-- #411: suspected-duplicate candidates \u2014 open each to compare before allowing / discarding. -->\n @if (detail.duplicateCandidates?.length) {\n <div class=\"text-muted ms-1 mt-1\">\n {{ '::Document:ReviewReason:DuplicateCandidates' | abpLocalization }}\n @for (candidate of detail.duplicateCandidates; track candidate.id) {\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 ms-2 align-baseline text-start\"\n (click)=\"openDocument(candidate.id!)\">\n {{ candidate.title || candidate.fileName || candidate.id }}\n @if (candidate.creationTime) {\n <span class=\"text-muted\">\u00B7 {{ candidate.creationTime | date:'yyyy-MM-dd' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n </dd>\n }\n\n @if (document()!.rejectionReason) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:RejectionReason' | abpLocalization }}</dt>\n <dd class=\"col-sm-7 text-muted fst-italic small\">{{ document()!.rejectionReason }}</dd>\n }\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:UploadedBy' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">{{ document()!.fileOrigin?.uploadedByUserName }}</dd>\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:UploadedAt' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">{{ document()!.creationTime | date:'yyyy-MM-dd HH:mm' }}</dd>\n </dl>\n </div>\n </div>\n\n @if (showFieldsCard()) {\n <div class=\"card shadow-sm\" id=\"extracted-fields-card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h6 class=\"mb-0\">\n <i class=\"fas fa-table-list me-2\"></i>\n {{ '::Document:ExtractedFields' | abpLocalization }}\n </h6>\n @if (canEditFields && fieldDefinitions().length > 0 && !isEditingFields()) {\n <button class=\"btn btn-outline-primary btn-sm\" (click)=\"startEditFields()\">\n <i class=\"fas fa-pen me-1\"></i>{{ '::Edit' | abpLocalization }}\n </button>\n }\n </div>\n <div class=\"card-body\">\n @if (!isEditingFields()) {\n @if (extractedFieldEntries().length > 0) {\n <dl class=\"row mb-0 small\">\n @for (entry of extractedFieldEntries(); track entry.key) {\n <dt class=\"col-sm-5 text-muted text-truncate\" [title]=\"entry.key\">\n {{ entry.label }}\n </dt>\n <dd class=\"col-sm-7 mb-2\">\n @if (entry.isMarkdown) {\n <!-- #418: LongText values render as Markdown, sanitized on bind by Angular's\n DomSanitizer \u2014 the same pipeline as the left-column preview. -->\n <div class=\"markdown-body\" [innerHTML]=\"entry.renderedHtml\"></div>\n } @else {\n <span [title]=\"entry.value\">{{ entry.value }}</span>\n }\n </dd>\n }\n </dl>\n } @else {\n <p class=\"text-muted small mb-0\">{{ '::Document:NoFieldValues' | abpLocalization }}</p>\n }\n } @else {\n <abp-dynamic-form\n [fields]=\"extractedFieldFormFields()\"\n [submitInProgress]=\"isSavingFields()\"\n (onSubmit)=\"saveFields($event)\"\n >\n <div actions class=\"d-flex justify-content-end gap-2\">\n <button\n type=\"button\"\n class=\"btn btn-secondary btn-sm\"\n (click)=\"cancelEditFields()\"\n [disabled]=\"isSavingFields()\"\n >\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"submit\"\n class=\"btn btn-primary btn-sm\"\n [disabled]=\"isSavingFields()\"\n >\n @if (isSavingFields()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Save' | abpLocalization }}\n </button>\n </div>\n </abp-dynamic-form>\n }\n </div>\n </div>\n }\n\n <div class=\"card shadow-sm\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"fas fa-stream me-2\"></i>\n {{ '::Document:Pipelines' | abpLocalization }}\n </h6>\n </div>\n <ul class=\"list-group list-group-flush small\">\n @for (row of pipelineRows(); track row.pipelineCode) {\n <li class=\"list-group-item\">\n <div class=\"d-flex align-items-center flex-wrap gap-2\">\n <span class=\"fw-semibold\">\n @if (row.isKnown) {\n {{ row.labelKey | abpLocalization }}\n } @else {\n {{ row.pipelineCode }}\n }\n </span>\n <span [class]=\"row.statusBadgeClass\">\n @if (row.inProgress) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n }\n {{ row.statusLabel | abpLocalization }}\n </span>\n @if (row.run && (row.run.attemptNumber ?? 0) > 1) {\n <span class=\"badge bg-body-tertiary text-muted border\">\n {{ '::Document:Pipeline:Attempt' | abpLocalization }}\n #{{ row.run.attemptNumber }}\n </span>\n }\n @if (row.elapsedDisplay !== null) {\n <span class=\"text-muted ms-auto\">\n <i class=\"fas fa-clock me-1\"></i>{{ row.elapsedDisplay }}\n </span>\n }\n @if (row.retryable) {\n <button\n class=\"btn btn-outline-primary btn-sm\"\n [class.ms-auto]=\"row.elapsedDisplay === null\"\n [disabled]=\"retryingPipeline() !== null\"\n (click)=\"retryPipeline(row.pipelineCode)\">\n @if (retryingPipeline() === row.pipelineCode) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:Pipeline:Retrying' | abpLocalization }}\n } @else {\n <i class=\"fas fa-redo me-1\"></i>\n {{ '::Document:Pipeline:Retry' | abpLocalization }}\n }\n </button>\n }\n </div>\n @if (row.run) {\n <div class=\"text-muted small mt-1\">\n @if (row.run.startedAt) {\n <span>\n <i class=\"fas fa-play me-1\"></i>{{ row.run.startedAt | date:'yyyy-MM-dd HH:mm:ss' }}\n </span>\n }\n @if (row.run.completedAt) {\n <span class=\"ms-3\">\n <i class=\"fas fa-flag-checkered me-1\"></i>{{ row.run.completedAt | date:'yyyy-MM-dd HH:mm:ss' }}\n </span>\n }\n </div>\n }\n @if (row.run?.statusMessage) {\n <div class=\"alert mt-2 mb-0 py-2 small\"\n [class.alert-danger]=\"row.run!.status === PipelineRunStatus.Failed\"\n [class.alert-secondary]=\"row.run!.status !== PipelineRunStatus.Failed\">\n {{ row.run!.statusMessage }}\n </div>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </div>\n </div>\n }\n</div>\n\n<!-- #395: Manual confirm / assign classification modal (relocated from the removed review queue). -->\n@if (showClassifyDialog()) {\n <div class=\"modal d-block\" tabindex=\"-1\" style=\"background:rgba(0,0,0,.4);\" (click)=\"closeClassifyDialog()\">\n <div class=\"modal-dialog modal-dialog-centered\" (click)=\"$event.stopPropagation()\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"fas fa-user-edit me-2\"></i>\n {{ '::Document:ConfirmClassification' | abpLocalization }}\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeClassifyDialog()\"></button>\n </div>\n <div class=\"modal-body\">\n <p class=\"text-muted small mb-3\">\n {{ document()?.title || document()?.fileOrigin?.originalFileName }}\n </p>\n <label class=\"form-label fw-semibold\">{{ '::Document:SelectDocumentType' | abpLocalization }}</label>\n <select\n class=\"form-select\"\n [ngModel]=\"selectedTypeId()\"\n (ngModelChange)=\"selectedTypeId.set($event)\"\n >\n <option value=\"\" disabled>{{ '::Document:SelectDocumentType' | abpLocalization }}</option>\n @for (t of documentTypes(); track t.id) {\n <option [value]=\"t.id\">{{ t.displayName }} ({{ t.typeCode }})</option>\n }\n </select>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" (click)=\"closeClassifyDialog()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"button\"\n class=\"btn btn-primary\"\n [disabled]=\"!selectedTypeId() || isConfirmingClassification()\"\n (click)=\"submitClassify()\"\n >\n @if (isConfirmingClassification()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Document:Confirm' | abpLocalization }}\n </button>\n </div>\n </div>\n </div>\n </div>\n}\n\n<!-- #395: Reject modal (relocated from the removed review queue). -->\n@if (showRejectDialog()) {\n <div class=\"modal d-block\" tabindex=\"-1\" style=\"background:rgba(0,0,0,.4);\" (click)=\"closeRejectDialog()\">\n <div class=\"modal-dialog modal-dialog-centered\" (click)=\"$event.stopPropagation()\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"fas fa-ban me-2 text-danger\"></i>\n {{ '::Document:Review:Reject' | abpLocalization }}\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeRejectDialog()\"></button>\n </div>\n <div class=\"modal-body\">\n <p class=\"text-muted small mb-3\">\n {{ document()?.title || document()?.fileOrigin?.originalFileName }}\n </p>\n <label class=\"form-label fw-semibold\">\n {{ '::Document:Review:RejectReason' | abpLocalization }} <span class=\"text-danger\">*</span>\n </label>\n <textarea\n class=\"form-control\"\n rows=\"3\"\n maxlength=\"2048\"\n [ngModel]=\"rejectReason()\"\n (ngModelChange)=\"rejectReason.set($event)\"\n placeholder=\"{{ '::Document:Review:RejectReasonHint' | abpLocalization }}\"\n ></textarea>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" (click)=\"closeRejectDialog()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"button\"\n class=\"btn btn-danger\"\n [disabled]=\"isRejecting()\"\n (click)=\"submitReject()\"\n >\n @if (isRejecting()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Document:Review:Reject' | abpLocalization }}\n </button>\n </div>\n </div>\n </div>\n </div>\n}\n", styles: [".preview-image{max-width:100%;border-radius:.25rem}.ocr-text{font-size:.8rem;white-space:pre-wrap;word-break:break-word;background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.75rem;border-radius:.25rem;border:1px solid var(--bs-border-color, #dee2e6)}:host ::ng-deep .markdown-body{font-size:.9rem;line-height:1.6;word-wrap:break-word}:host ::ng-deep .markdown-body h1,:host ::ng-deep .markdown-body h2,:host ::ng-deep .markdown-body h3,:host ::ng-deep .markdown-body h4,:host ::ng-deep .markdown-body h5,:host ::ng-deep .markdown-body h6{margin-top:1rem;margin-bottom:.5rem;font-weight:600;line-height:1.25}:host ::ng-deep .markdown-body h1{font-size:1.5rem}:host ::ng-deep .markdown-body h2{font-size:1.3rem}:host ::ng-deep .markdown-body h3{font-size:1.15rem}:host ::ng-deep .markdown-body h4,:host ::ng-deep .markdown-body h5,:host ::ng-deep .markdown-body h6{font-size:1rem}:host ::ng-deep .markdown-body p{margin-bottom:.75rem}:host ::ng-deep .markdown-body ul,:host ::ng-deep .markdown-body ol{padding-left:1.5rem;margin-bottom:.75rem}:host ::ng-deep .markdown-body code{background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.1rem .3rem;border-radius:.2rem;font-size:.85em}:host ::ng-deep .markdown-body pre{background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.75rem;border-radius:.25rem;overflow-x:auto;margin-bottom:.75rem}:host ::ng-deep .markdown-body pre code{background-color:transparent;padding:0}:host ::ng-deep .markdown-body table{border-collapse:collapse;width:100%;margin-bottom:.75rem;font-size:.85rem}:host ::ng-deep .markdown-body table th,:host ::ng-deep .markdown-body table td{border:1px solid var(--bs-border-color, #dee2e6);padding:.4rem .6rem;text-align:left}:host ::ng-deep .markdown-body table th{background-color:var(--bs-tertiary-bg, #f8f9fa);font-weight:600}:host ::ng-deep .markdown-body img{max-width:100%;height:auto}:host ::ng-deep .markdown-body blockquote{border-left:3px solid var(--bs-border-color, #dee2e6);padding-left:.75rem;margin-bottom:.75rem;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .markdown-body a{color:var(--bs-primary, #0d6efd)}:host ::ng-deep .markdown-body>:first-child{margin-top:0}:host ::ng-deep .markdown-body>:last-child{margin-bottom:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: DynamicFormComponent, selector: "abp-dynamic-form", inputs: ["fields", "values", "submitButtonText", "submitInProgress", "showCancelButton"], outputs: ["onSubmit", "formCancel"], exportAs: ["abpDynamicForm"] }, { kind: "pipe", type: i2.DatePipe, name: "date" }, { kind: "pipe", type: LocalizationPipe, name: "abpLocalization" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1139
+ }
1140
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: DocumentDetailComponent, decorators: [{
1141
+ type: Component,
1142
+ args: [{ selector: 'lib-document-detail', imports: [CommonModule, FormsModule, DynamicFormComponent, LocalizationPipe], providers: [DocumentFileBlobService], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"container-fluid py-4\">\n\n <!-- Back + header -->\n <div class=\"d-flex align-items-center mb-3 gap-2\">\n <button class=\"btn btn-outline-secondary btn-sm\" (click)=\"goBack()\">\n <i class=\"fas fa-arrow-left me-1\"></i>\n {{ '::Back' | abpLocalization }}\n </button>\n @if (document()) {\n <span class=\"ms-2 fw-semibold text-truncate\" style=\"max-width: 480px;\" [title]=\"document()!.title || document()!.fileOrigin?.originalFileName\">\n {{ document()!.title || document()!.fileOrigin?.originalFileName }}\n </span>\n <span [class]=\"getDocumentStatusBadgeClass(document()!)\" class=\"ms-2\">\n @if (isProcessing()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n }\n {{ getDocumentStatusLabel(document()!) | abpLocalization }}\n </span>\n }\n <div class=\"ms-auto d-flex gap-2\">\n <!-- Display label is \"\u91CD\u65B0\u5206\u7C7B / Re-classify\": it re-runs classification on the already-extracted text,\n NOT OCR (the #263 internal name stays \"rerecognize\"). The tooltip states the original file is not\n re-scanned, to kill the \"re-OCR from the original file\" misconception. -->\n @if (canRerecognize()) {\n <button\n class=\"btn btn-outline-primary btn-sm\"\n (click)=\"rerecognize()\"\n [disabled]=\"isRerecognizing()\"\n title=\"{{ '::Document:Rerecognize:Hint' | abpLocalization }}\"\n >\n @if (isRerecognizing()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:Rerecognizing' | abpLocalization }}\n } @else {\n <i class=\"fas fa-wand-magic-sparkles me-1\"></i>\n {{ '::Document:Rerecognize' | abpLocalization }}\n }\n </button>\n }\n @if (canReextractFields()) {\n <button\n class=\"btn btn-outline-secondary btn-sm\"\n (click)=\"reextractFields()\"\n [disabled]=\"isReextracting()\"\n title=\"{{ '::Document:ReextractFields' | abpLocalization }}\"\n >\n @if (isReextracting()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:ReextractFieldsInProgress' | abpLocalization }}\n } @else {\n <i class=\"fas fa-list-check me-1\"></i>\n {{ '::Document:ReextractFields' | abpLocalization }}\n }\n </button>\n }\n @if (canDelete && document()) {\n <button\n class=\"btn btn-outline-danger btn-sm\"\n (click)=\"delete()\"\n title=\"{{ '::Delete' | abpLocalization }}\"\n >\n <i class=\"fas fa-trash me-1\"></i>\n {{ '::Delete' | abpLocalization }}\n </button>\n }\n <button\n class=\"btn btn-outline-secondary btn-sm\"\n (click)=\"refresh()\"\n [disabled]=\"isLoading()\"\n title=\"{{ '::Refresh' | abpLocalization }}\"\n >\n <i class=\"fas fa-sync-alt\" [class.fa-spin]=\"isLoading()\"></i>\n </button>\n </div>\n </div>\n\n <!-- Loading state -->\n @if (isLoading()) {\n <div class=\"text-center py-5\">\n <div class=\"spinner-border text-primary\" role=\"status\"></div>\n </div>\n }\n\n @if (document()) {\n <!-- Sub-document provenance banner (#306/#354): this document was derived from a container document.\n Offer navigation back to the source and to its sibling sub-documents. -->\n @if (isSubDocument()) {\n <div class=\"alert alert-info d-flex flex-wrap align-items-center gap-2 mb-3\" role=\"alert\">\n <i class=\"fas fa-sitemap me-1\"></i>\n <span>\n {{ '::Document:SubDocumentOf' | abpLocalization }}\n @if (parentDocument(); as parent) {\n <strong>{{ parent.title || parent.fileOrigin?.originalFileName || document()!.originDocumentId }}</strong>\n } @else if (parentLookupFailed()) {\n <!-- Source removed (soft / permanently deleted) or cross-layer: show an explicit \"unavailable\" label\n instead of a raw id, and drop the dead \"view parent\" link below. The sub-document stays fully usable. -->\n <strong>{{ '::Document:SourceDocumentUnavailable' | abpLocalization }}</strong>\n } @else {\n <strong>{{ document()!.originDocumentId }}</strong>\n }\n </span>\n <div class=\"ms-auto d-flex gap-2\">\n @if (parentDocument()) {\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary\" (click)=\"openParentDocument()\">\n <i class=\"fas fa-arrow-up me-1\"></i>{{ '::Document:ViewParentDocument' | abpLocalization }}\n </button>\n }\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" (click)=\"viewSiblingDocuments()\">\n <i class=\"fas fa-sitemap me-1\"></i>{{ '::Document:ViewSiblingDocuments' | abpLocalization }}\n </button>\n </div>\n </div>\n }\n\n <!-- Review / processing banner. #395/#412: contextual remediation actions live here. The banner is\n reason-aware \u2014 the active review reasons drive both the badges and which CTA appears: classification\n is confirmed/assigned via the dialog, missing fields are completed in the fields card below, a\n suspected duplicate is released, and the document can always be rejected. -->\n @if (needsReview()) {\n <div class=\"alert alert-warning mb-3\" role=\"alert\">\n <div class=\"d-flex align-items-center flex-wrap gap-2\">\n <i class=\"fas fa-user-edit me-2\"></i>\n <span>{{ '::Document:PendingReviewMessage' | abpLocalization }}</span>\n <!-- #412: surface the concrete reason(s) right here, so the operator no longer has to read them off\n the metadata list in the right column. Blocking reasons are red, advisory ones amber. -->\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <span class=\"badge\"\n [class.bg-danger]=\"detail.isBlocking\"\n [class.bg-warning]=\"!detail.isBlocking\"\n [class.text-dark]=\"!detail.isBlocking\">\n {{ reviewReasonLabel(detail.reason) | abpLocalization }}\n </span>\n }\n @if (canEditFields) {\n <div class=\"ms-auto d-flex gap-2\">\n @if (needsClassification()) {\n <button type=\"button\" class=\"btn btn-sm btn-warning\" (click)=\"openClassifyDialog()\">\n <i class=\"fas fa-check me-1\"></i>\n {{ '::Document:ConfirmClassification' | abpLocalization }}\n </button>\n }\n <!-- #412: missing required fields are completed below \u2014 jump there and open the editor. -->\n @if (needsFieldCompletion()) {\n <button type=\"button\" class=\"btn btn-sm btn-warning\" (click)=\"completeFields()\">\n <i class=\"fas fa-pen me-1\"></i>\n {{ '::Document:Review:CompleteFields' | abpLocalization }}\n </button>\n }\n <!-- #411: resolve a suspected duplicate \u2014 \"Allow\" releases it (not a duplicate / acceptable re-upload);\n confirming the duplicate is the Delete action in the header toolbar. -->\n @if (needsDuplicateReview()) {\n <button type=\"button\" class=\"btn btn-sm btn-success\" (click)=\"allowDuplicate()\" [disabled]=\"isAllowingDuplicate()\">\n <i class=\"fas fa-check-double me-1\"></i>\n {{ '::Document:Review:AllowDuplicate' | abpLocalization }}\n </button>\n }\n <button type=\"button\" class=\"btn btn-sm btn-outline-danger\" (click)=\"openRejectDialog()\">\n <i class=\"fas fa-ban me-1\"></i>\n {{ '::Document:Review:Reject' | abpLocalization }}\n </button>\n </div>\n }\n </div>\n <!-- #412: one remediation hint per active reason, so each reason states its own expected action\n instead of leaning on the single (reason-neutral) headline above. -->\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <div class=\"small text-muted mt-2\">\n {{ reviewReasonHint(detail.reason) | abpLocalization }}\n @if (detail.missingFieldNames?.length) {\n <span class=\"fw-semibold\">{{ detail.missingFieldNames!.join('\u3001') }}</span>\n }\n </div>\n }\n </div>\n } @else if (isProcessing()) {\n <div class=\"alert alert-warning d-flex align-items-center mb-3\" role=\"alert\">\n <div class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></div>\n <span>{{ '::Document:ProcessingMessage' | abpLocalization }}</span>\n </div>\n }\n\n <div class=\"row g-3\">\n <!-- LEFT: Tab area: Markdown preview / source / original file (#274) -->\n <div class=\"col-lg-6\">\n <div class=\"card shadow-sm h-100 d-flex flex-column\">\n <div class=\"card-header p-0 pt-2\">\n <ul class=\"nav nav-tabs card-header-tabs mx-2 mb-0\">\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'preview'\"\n (click)=\"selectTab('preview')\">\n <i class=\"fas fa-eye me-1\"></i>{{ '::Document:Tab:Preview' | abpLocalization }}\n </button>\n </li>\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'source'\"\n (click)=\"selectTab('source')\">\n <i class=\"fas fa-code me-1\"></i>{{ '::Document:Tab:Source' | abpLocalization }}\n </button>\n </li>\n <!-- Sub-documents have no source blob (#306/#346); hide the Original File tab so the UI never\n calls GetBlobAsync for them (Extract:DocumentNoSourceBlob). -->\n @if (hasSourceFile()) {\n <li class=\"nav-item\">\n <button class=\"nav-link\" [class.active]=\"activeTab() === 'file'\"\n (click)=\"selectTab('file')\">\n <i class=\"fas fa-file me-1\"></i>{{ '::Document:OriginalFile' | abpLocalization }}\n </button>\n </li>\n }\n </ul>\n </div>\n <div class=\"card-body overflow-auto\" style=\"min-height: 400px; max-height: 80vh;\">\n @switch (activeTab()) {\n @case ('preview') {\n @if (document()!.markdown) {\n <div class=\"markdown-body\" [innerHTML]=\"renderedMarkdown()\"></div>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-align-left fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:Markdown:Empty' | abpLocalization }}</p>\n </div>\n }\n }\n @case ('source') {\n @if (document()!.markdown) {\n <pre class=\"ocr-text mb-0\">{{ document()!.markdown }}</pre>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <p class=\"mb-0\">{{ '::Document:Markdown:Empty' | abpLocalization }}</p>\n </div>\n }\n }\n @case ('file') {\n @if (fileBlob.isLoading()) {\n <div class=\"text-center py-5\">\n <div class=\"spinner-border text-primary\" role=\"status\"></div>\n </div>\n } @else if (fileBlob.hasError()) {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-exclamation-triangle fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:PreviewUnavailable' | abpLocalization }}</p>\n </div>\n } @else if (isImage() && fileBlob.blobUrl()) {\n <div class=\"text-center\">\n <img\n [src]=\"fileBlob.blobUrl()\"\n (error)=\"onPreviewError()\"\n alt=\"{{ document()!.fileOrigin?.originalFileName }}\"\n class=\"img-fluid preview-image\"\n style=\"max-height: 70vh; object-fit: contain;\"\n />\n </div>\n } @else if (isPdf() && fileBlob.safeResourceUrl()) {\n <iframe\n [src]=\"fileBlob.safeResourceUrl()\"\n [title]=\"document()!.fileOrigin?.originalFileName\"\n class=\"w-100 border rounded\"\n style=\"min-height: 70vh;\"\n ></iframe>\n } @else {\n <div class=\"text-center text-muted py-5\">\n <i class=\"fas fa-image fa-3x mb-3 d-block\"></i>\n <p class=\"mb-0\">{{ '::Document:PreviewUnavailable' | abpLocalization }}</p>\n </div>\n }\n }\n }\n </div>\n <!-- Source-file footer (name / type / download): only for documents that actually have a source\n blob. A sub-document (#306/#346) has none, so hiding this avoids an empty footer and a Download\n button that would fail with Extract:DocumentNoSourceBlob. -->\n @if (hasSourceFile()) {\n <div class=\"card-footer text-muted small d-flex align-items-center gap-2\">\n <i class=\"fas fa-file\"></i>\n <span class=\"text-truncate\">{{ document()!.fileOrigin?.originalFileName }}</span>\n <span>\u00B7</span>\n <span class=\"flex-shrink-0\">{{ document()!.fileOrigin?.contentType }}</span>\n <button type=\"button\" (click)=\"downloadFile()\" [disabled]=\"fileBlob.isLoading()\"\n class=\"btn btn-sm btn-link p-0 ms-auto flex-shrink-0\">\n @if (fileBlob.isLoading()) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n } @else {\n <i class=\"fas fa-download me-1\"></i>\n }\n {{ '::Document:DownloadFile' | abpLocalization }}\n </button>\n </div>\n }\n </div>\n </div>\n\n <!-- RIGHT: Document metadata, fields, pipelines -->\n <div class=\"col-lg-6 d-flex flex-column\">\n <div class=\"d-flex flex-column gap-3\">\n <div class=\"card shadow-sm\">\n <div class=\"card-body\">\n <dl class=\"row mb-0 small\">\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:Type' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (documentTypeDisplayName(); as typeName) {\n <span class=\"badge bg-info text-dark\">{{ typeName }}</span>\n } @else {\n <span class=\"text-muted\">\u2014</span>\n }\n </dd>\n\n @if (canViewCabinets) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:Cabinet' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (isEditingCabinet()) {\n <div class=\"d-flex align-items-center gap-2 flex-wrap\">\n <select class=\"form-select form-select-sm w-auto\"\n [ngModel]=\"selectedCabinetId()\"\n (ngModelChange)=\"selectedCabinetId.set($event)\"\n [disabled]=\"isSavingCabinet()\">\n <option value=\"\">{{ '::Document:Unfiled' | abpLocalization }}</option>\n @for (c of cabinets(); track c.id) {\n <option [value]=\"c.id\">{{ c.name }}</option>\n }\n </select>\n <button type=\"button\" class=\"btn btn-sm btn-primary\"\n (click)=\"saveCabinet()\" [disabled]=\"isSavingCabinet()\">\n {{ '::Save' | abpLocalization }}\n </button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary\"\n (click)=\"cancelEditCabinet()\" [disabled]=\"isSavingCabinet()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n </div>\n } @else {\n @if (cabinetName(); as name) {\n <span class=\"badge bg-body-tertiary border\">\n <i class=\"fas fa-folder text-warning me-1\"></i>{{ name }}\n </span>\n } @else {\n <span class=\"text-muted\">{{ '::Document:Unfiled' | abpLocalization }}</span>\n }\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 ms-2\"\n (click)=\"startEditCabinet()\">\n {{ '::Edit' | abpLocalization }}\n </button>\n }\n </dd>\n }\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:ClassificationConfidence' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if ((document()!.classificationConfidence ?? 0) > 0) {\n {{ ((document()!.classificationConfidence ?? 0) * 100).toFixed(0) }}%\n } @else {\n \u2014\n }\n </dd>\n\n @if (document()!.reviewDisposition === DocumentReviewDisposition.Confirmed || document()!.reviewDisposition === DocumentReviewDisposition.Rejected) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:ReviewStatus' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @if (document()!.reviewDisposition === DocumentReviewDisposition.Confirmed) {\n <span class=\"badge bg-success\">\n {{ '::Document:ReviewDisposition:Confirmed' | abpLocalization }}\n </span>\n } @else {\n <span class=\"badge bg-danger\">\n {{ '::Document:ReviewDisposition:Rejected' | abpLocalization }}\n </span>\n }\n </dd>\n }\n\n @if (document()!.reviewReasonDetails?.length) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:NeedsReview' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">\n @for (detail of document()!.reviewReasonDetails; track detail.reason) {\n <div class=\"small mb-1\">\n <span class=\"badge\"\n [class.bg-danger]=\"detail.isBlocking\"\n [class.bg-warning]=\"!detail.isBlocking\"\n [class.text-dark]=\"!detail.isBlocking\">\n {{ reviewReasonLabel(detail.reason) | abpLocalization }}\n </span>\n @if (detail.missingFieldNames?.length) {\n <span class=\"text-muted ms-1\">{{ detail.missingFieldNames!.join('\u3001') }}</span>\n }\n <!-- #411: suspected-duplicate candidates \u2014 open each to compare before allowing / discarding. -->\n @if (detail.duplicateCandidates?.length) {\n <div class=\"text-muted ms-1 mt-1\">\n {{ '::Document:ReviewReason:DuplicateCandidates' | abpLocalization }}\n @for (candidate of detail.duplicateCandidates; track candidate.id) {\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 ms-2 align-baseline text-start\"\n (click)=\"openDocument(candidate.id!)\">\n {{ candidate.title || candidate.fileName || candidate.id }}\n @if (candidate.creationTime) {\n <span class=\"text-muted\">\u00B7 {{ candidate.creationTime | date:'yyyy-MM-dd' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n </dd>\n }\n\n @if (document()!.rejectionReason) {\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:RejectionReason' | abpLocalization }}</dt>\n <dd class=\"col-sm-7 text-muted fst-italic small\">{{ document()!.rejectionReason }}</dd>\n }\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:UploadedBy' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">{{ document()!.fileOrigin?.uploadedByUserName }}</dd>\n\n <dt class=\"col-sm-5 text-muted\">{{ '::Document:UploadedAt' | abpLocalization }}</dt>\n <dd class=\"col-sm-7\">{{ document()!.creationTime | date:'yyyy-MM-dd HH:mm' }}</dd>\n </dl>\n </div>\n </div>\n\n @if (showFieldsCard()) {\n <div class=\"card shadow-sm\" id=\"extracted-fields-card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h6 class=\"mb-0\">\n <i class=\"fas fa-table-list me-2\"></i>\n {{ '::Document:ExtractedFields' | abpLocalization }}\n </h6>\n @if (canEditFields && fieldDefinitions().length > 0 && !isEditingFields()) {\n <button class=\"btn btn-outline-primary btn-sm\" (click)=\"startEditFields()\">\n <i class=\"fas fa-pen me-1\"></i>{{ '::Edit' | abpLocalization }}\n </button>\n }\n </div>\n <div class=\"card-body\">\n @if (!isEditingFields()) {\n @if (extractedFieldEntries().length > 0) {\n <dl class=\"row mb-0 small\">\n @for (entry of extractedFieldEntries(); track entry.key) {\n <dt class=\"col-sm-5 text-muted text-truncate\" [title]=\"entry.key\">\n {{ entry.label }}\n </dt>\n <dd class=\"col-sm-7 mb-2\">\n @if (entry.isMarkdown) {\n <!-- #418: LongText values render as Markdown, sanitized on bind by Angular's\n DomSanitizer \u2014 the same pipeline as the left-column preview. -->\n <div class=\"markdown-body\" [innerHTML]=\"entry.renderedHtml\"></div>\n } @else {\n <span [title]=\"entry.value\">{{ entry.value }}</span>\n }\n </dd>\n }\n </dl>\n } @else {\n <p class=\"text-muted small mb-0\">{{ '::Document:NoFieldValues' | abpLocalization }}</p>\n }\n } @else {\n <abp-dynamic-form\n [fields]=\"extractedFieldFormFields()\"\n [submitInProgress]=\"isSavingFields()\"\n (onSubmit)=\"saveFields($event)\"\n >\n <div actions class=\"d-flex justify-content-end gap-2\">\n <button\n type=\"button\"\n class=\"btn btn-secondary btn-sm\"\n (click)=\"cancelEditFields()\"\n [disabled]=\"isSavingFields()\"\n >\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"submit\"\n class=\"btn btn-primary btn-sm\"\n [disabled]=\"isSavingFields()\"\n >\n @if (isSavingFields()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Save' | abpLocalization }}\n </button>\n </div>\n </abp-dynamic-form>\n }\n </div>\n </div>\n }\n\n <div class=\"card shadow-sm\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"fas fa-stream me-2\"></i>\n {{ '::Document:Pipelines' | abpLocalization }}\n </h6>\n </div>\n <ul class=\"list-group list-group-flush small\">\n @for (row of pipelineRows(); track row.pipelineCode) {\n <li class=\"list-group-item\">\n <div class=\"d-flex align-items-center flex-wrap gap-2\">\n <span class=\"fw-semibold\">\n @if (row.isKnown) {\n {{ row.labelKey | abpLocalization }}\n } @else {\n {{ row.pipelineCode }}\n }\n </span>\n <span [class]=\"row.statusBadgeClass\">\n @if (row.inProgress) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n }\n {{ row.statusLabel | abpLocalization }}\n </span>\n @if (row.run && (row.run.attemptNumber ?? 0) > 1) {\n <span class=\"badge bg-body-tertiary text-muted border\">\n {{ '::Document:Pipeline:Attempt' | abpLocalization }}\n #{{ row.run.attemptNumber }}\n </span>\n }\n @if (row.elapsedDisplay !== null) {\n <span class=\"text-muted ms-auto\">\n <i class=\"fas fa-clock me-1\"></i>{{ row.elapsedDisplay }}\n </span>\n }\n @if (row.retryable) {\n <button\n class=\"btn btn-outline-primary btn-sm\"\n [class.ms-auto]=\"row.elapsedDisplay === null\"\n [disabled]=\"retryingPipeline() !== null\"\n (click)=\"retryPipeline(row.pipelineCode)\">\n @if (retryingPipeline() === row.pipelineCode) {\n <span class=\"spinner-border spinner-border-sm me-1\" role=\"status\"></span>\n {{ '::Document:Pipeline:Retrying' | abpLocalization }}\n } @else {\n <i class=\"fas fa-redo me-1\"></i>\n {{ '::Document:Pipeline:Retry' | abpLocalization }}\n }\n </button>\n }\n </div>\n @if (row.run) {\n <div class=\"text-muted small mt-1\">\n @if (row.run.startedAt) {\n <span>\n <i class=\"fas fa-play me-1\"></i>{{ row.run.startedAt | date:'yyyy-MM-dd HH:mm:ss' }}\n </span>\n }\n @if (row.run.completedAt) {\n <span class=\"ms-3\">\n <i class=\"fas fa-flag-checkered me-1\"></i>{{ row.run.completedAt | date:'yyyy-MM-dd HH:mm:ss' }}\n </span>\n }\n </div>\n }\n @if (row.run?.statusMessage) {\n <div class=\"alert mt-2 mb-0 py-2 small\"\n [class.alert-danger]=\"row.run!.status === PipelineRunStatus.Failed\"\n [class.alert-secondary]=\"row.run!.status !== PipelineRunStatus.Failed\">\n {{ row.run!.statusMessage }}\n </div>\n }\n </li>\n }\n </ul>\n </div>\n </div>\n </div>\n </div>\n }\n</div>\n\n<!-- #395: Manual confirm / assign classification modal (relocated from the removed review queue). -->\n@if (showClassifyDialog()) {\n <div class=\"modal d-block\" tabindex=\"-1\" style=\"background:rgba(0,0,0,.4);\" (click)=\"closeClassifyDialog()\">\n <div class=\"modal-dialog modal-dialog-centered\" (click)=\"$event.stopPropagation()\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"fas fa-user-edit me-2\"></i>\n {{ '::Document:ConfirmClassification' | abpLocalization }}\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeClassifyDialog()\"></button>\n </div>\n <div class=\"modal-body\">\n <p class=\"text-muted small mb-3\">\n {{ document()?.title || document()?.fileOrigin?.originalFileName }}\n </p>\n <label class=\"form-label fw-semibold\">{{ '::Document:SelectDocumentType' | abpLocalization }}</label>\n <select\n class=\"form-select\"\n [ngModel]=\"selectedTypeId()\"\n (ngModelChange)=\"selectedTypeId.set($event)\"\n >\n <option value=\"\" disabled>{{ '::Document:SelectDocumentType' | abpLocalization }}</option>\n @for (t of documentTypes(); track t.id) {\n <option [value]=\"t.id\">{{ t.displayName }} ({{ t.typeCode }})</option>\n }\n </select>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" (click)=\"closeClassifyDialog()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"button\"\n class=\"btn btn-primary\"\n [disabled]=\"!selectedTypeId() || isConfirmingClassification()\"\n (click)=\"submitClassify()\"\n >\n @if (isConfirmingClassification()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Document:Confirm' | abpLocalization }}\n </button>\n </div>\n </div>\n </div>\n </div>\n}\n\n<!-- #395: Reject modal (relocated from the removed review queue). -->\n@if (showRejectDialog()) {\n <div class=\"modal d-block\" tabindex=\"-1\" style=\"background:rgba(0,0,0,.4);\" (click)=\"closeRejectDialog()\">\n <div class=\"modal-dialog modal-dialog-centered\" (click)=\"$event.stopPropagation()\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"fas fa-ban me-2 text-danger\"></i>\n {{ '::Document:Review:Reject' | abpLocalization }}\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeRejectDialog()\"></button>\n </div>\n <div class=\"modal-body\">\n <p class=\"text-muted small mb-3\">\n {{ document()?.title || document()?.fileOrigin?.originalFileName }}\n </p>\n <label class=\"form-label fw-semibold\">\n {{ '::Document:Review:RejectReason' | abpLocalization }} <span class=\"text-danger\">*</span>\n </label>\n <textarea\n class=\"form-control\"\n rows=\"3\"\n maxlength=\"2048\"\n [ngModel]=\"rejectReason()\"\n (ngModelChange)=\"rejectReason.set($event)\"\n placeholder=\"{{ '::Document:Review:RejectReasonHint' | abpLocalization }}\"\n ></textarea>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" (click)=\"closeRejectDialog()\">\n {{ '::Cancel' | abpLocalization }}\n </button>\n <button\n type=\"button\"\n class=\"btn btn-danger\"\n [disabled]=\"isRejecting()\"\n (click)=\"submitReject()\"\n >\n @if (isRejecting()) {\n <span class=\"spinner-border spinner-border-sm me-1\"></span>\n }\n {{ '::Document:Review:Reject' | abpLocalization }}\n </button>\n </div>\n </div>\n </div>\n </div>\n}\n", styles: [".preview-image{max-width:100%;border-radius:.25rem}.ocr-text{font-size:.8rem;white-space:pre-wrap;word-break:break-word;background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.75rem;border-radius:.25rem;border:1px solid var(--bs-border-color, #dee2e6)}:host ::ng-deep .markdown-body{font-size:.9rem;line-height:1.6;word-wrap:break-word}:host ::ng-deep .markdown-body h1,:host ::ng-deep .markdown-body h2,:host ::ng-deep .markdown-body h3,:host ::ng-deep .markdown-body h4,:host ::ng-deep .markdown-body h5,:host ::ng-deep .markdown-body h6{margin-top:1rem;margin-bottom:.5rem;font-weight:600;line-height:1.25}:host ::ng-deep .markdown-body h1{font-size:1.5rem}:host ::ng-deep .markdown-body h2{font-size:1.3rem}:host ::ng-deep .markdown-body h3{font-size:1.15rem}:host ::ng-deep .markdown-body h4,:host ::ng-deep .markdown-body h5,:host ::ng-deep .markdown-body h6{font-size:1rem}:host ::ng-deep .markdown-body p{margin-bottom:.75rem}:host ::ng-deep .markdown-body ul,:host ::ng-deep .markdown-body ol{padding-left:1.5rem;margin-bottom:.75rem}:host ::ng-deep .markdown-body code{background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.1rem .3rem;border-radius:.2rem;font-size:.85em}:host ::ng-deep .markdown-body pre{background-color:var(--bs-tertiary-bg, #f8f9fa);padding:.75rem;border-radius:.25rem;overflow-x:auto;margin-bottom:.75rem}:host ::ng-deep .markdown-body pre code{background-color:transparent;padding:0}:host ::ng-deep .markdown-body table{border-collapse:collapse;width:100%;margin-bottom:.75rem;font-size:.85rem}:host ::ng-deep .markdown-body table th,:host ::ng-deep .markdown-body table td{border:1px solid var(--bs-border-color, #dee2e6);padding:.4rem .6rem;text-align:left}:host ::ng-deep .markdown-body table th{background-color:var(--bs-tertiary-bg, #f8f9fa);font-weight:600}:host ::ng-deep .markdown-body img{max-width:100%;height:auto}:host ::ng-deep .markdown-body blockquote{border-left:3px solid var(--bs-border-color, #dee2e6);padding-left:.75rem;margin-bottom:.75rem;color:var(--bs-secondary-color, #6c757d)}:host ::ng-deep .markdown-body a{color:var(--bs-primary, #0d6efd)}:host ::ng-deep .markdown-body>:first-child{margin-top:0}:host ::ng-deep .markdown-body>:last-child{margin-bottom:0}\n"] }]
1143
+ }], ctorParameters: () => [] });
1144
+
1145
+ export { DocumentDetailComponent };
1146
+ //# sourceMappingURL=dignite-vault-extract-documents-document-detail.component-DHs42DWJ.mjs.map