mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Merge branch 'dev' into feature-autocolor
This commit is contained in:
		@@ -13,6 +13,16 @@
 | 
			
		||||
			"root": "",
 | 
			
		||||
			"sourceRoot": "src",
 | 
			
		||||
			"prefix": "app",
 | 
			
		||||
			"i18n": {
 | 
			
		||||
				"sourceLocale": "en-US",
 | 
			
		||||
				"locales": {
 | 
			
		||||
					"de": "src/locale/messages.de.xlf",
 | 
			
		||||
					"nl-NL": "src/locale/messages.nl_NL.xlf",
 | 
			
		||||
					"fr": "src/locale/messages.fr.xlf",
 | 
			
		||||
					"en-GB": "src/locale/messages.en_GB.xlf",
 | 
			
		||||
					"pt-BR": "src/locale/messages.pt_BR.xlf"
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
			"architect": {
 | 
			
		||||
				"build": {
 | 
			
		||||
					"builder": "@angular-devkit/build-angular:browser",
 | 
			
		||||
@@ -23,15 +33,25 @@
 | 
			
		||||
						"main": "src/main.ts",
 | 
			
		||||
						"polyfills": "src/polyfills.ts",
 | 
			
		||||
						"tsConfig": "tsconfig.app.json",
 | 
			
		||||
						"localize": true,
 | 
			
		||||
						"aot": true,
 | 
			
		||||
						"assets": [
 | 
			
		||||
							"src/favicon.ico",
 | 
			
		||||
							"src/assets"
 | 
			
		||||
							"src/apple-touch-icon.png",
 | 
			
		||||
							"src/assets",
 | 
			
		||||
							"src/manifest.webmanifest", {
 | 
			
		||||
								"glob": "pdf.worker.min.js",
 | 
			
		||||
								"input": "node_modules/pdfjs-dist/build/",
 | 
			
		||||
								"output": "/assets/js/"
 | 
			
		||||
							}
 | 
			
		||||
						],
 | 
			
		||||
						"styles": [
 | 
			
		||||
							"src/styles.scss"
 | 
			
		||||
						],
 | 
			
		||||
						"scripts": []
 | 
			
		||||
						"scripts": [],
 | 
			
		||||
						"allowedCommonJsDependencies": [
 | 
			
		||||
							"ng2-pdf-viewer"
 | 
			
		||||
						]
 | 
			
		||||
					},
 | 
			
		||||
					"configurations": {
 | 
			
		||||
						"production": {
 | 
			
		||||
@@ -41,6 +61,7 @@
 | 
			
		||||
									"with": "src/environments/environment.prod.ts"
 | 
			
		||||
								}
 | 
			
		||||
							],
 | 
			
		||||
							"outputPath": "../src/documents/static/frontend/",
 | 
			
		||||
							"optimization": true,
 | 
			
		||||
							"outputHashing": "none",
 | 
			
		||||
							"sourceMap": false,
 | 
			
		||||
@@ -61,13 +82,16 @@
 | 
			
		||||
									"maximumError": "10kb"
 | 
			
		||||
								}
 | 
			
		||||
							]
 | 
			
		||||
						},
 | 
			
		||||
						"en-US": {
 | 
			
		||||
							"localize": ["en-US"]
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				},
 | 
			
		||||
				"serve": {
 | 
			
		||||
					"builder": "@angular-devkit/build-angular:dev-server",
 | 
			
		||||
					"options": {
 | 
			
		||||
						"browserTarget": "paperless-ui:build"
 | 
			
		||||
						"browserTarget": "paperless-ui:build:en-US"
 | 
			
		||||
					},
 | 
			
		||||
					"configurations": {
 | 
			
		||||
						"production": {
 | 
			
		||||
@@ -90,7 +114,9 @@
 | 
			
		||||
						"karmaConfig": "karma.conf.js",
 | 
			
		||||
						"assets": [
 | 
			
		||||
							"src/favicon.ico",
 | 
			
		||||
							"src/assets"
 | 
			
		||||
							"src/apple-touch-icon.png",
 | 
			
		||||
							"src/assets",
 | 
			
		||||
							"src/manifest.webmanifest"
 | 
			
		||||
						],
 | 
			
		||||
						"styles": [
 | 
			
		||||
							"src/styles.scss"
 | 
			
		||||
@@ -127,4 +153,4 @@
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	"defaultProject": "paperless-ui"
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2062
									
								
								src-ui/messages.xlf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2062
									
								
								src-ui/messages.xlf
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										96
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										96
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -331,6 +331,12 @@
 | 
			
		||||
            "ms": "^2.1.1"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "ini": {
 | 
			
		||||
          "version": "1.3.5",
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
 | 
			
		||||
          "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
 | 
			
		||||
          "dev": true
 | 
			
		||||
        },
 | 
			
		||||
        "uuid": {
 | 
			
		||||
          "version": "8.3.0",
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
 | 
			
		||||
@@ -2049,9 +2055,17 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "@ng-bootstrap/ng-bootstrap": {
 | 
			
		||||
      "version": "8.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-8.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-v77Gfd8xHH+exq0WqIqVRlxbUEHdA/2+RUJenUP2IDTQN9E1rWl7O461/kosr+0XPuxPArHQJxhh/WsCYckcNg==",
 | 
			
		||||
      "version": "8.0.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-8.0.4.tgz",
 | 
			
		||||
      "integrity": "sha512-EdxTwOPOtlvfnwrglPniulmzdnXdXH3lTGaGAY1HrYRvdtGg6wicRvl+BvwVE/3Qik5NPkOWMVghUHpv3evIYg==",
 | 
			
		||||
      "requires": {
 | 
			
		||||
        "tslib": "^2.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "@ng-select/ng-select": {
 | 
			
		||||
      "version": "5.0.9",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-5.0.9.tgz",
 | 
			
		||||
      "integrity": "sha512-YZeSAiS8/Nx/eHZJPmOOYL8YmcvSq+dr1P8WIrsKmRA7mueorBpPc5xlUj+nLQbpLtsiQvdWDQspf/ykOvD/lA==",
 | 
			
		||||
      "requires": {
 | 
			
		||||
        "tslib": "^2.0.0"
 | 
			
		||||
      }
 | 
			
		||||
@@ -2170,6 +2184,14 @@
 | 
			
		||||
        "pacote": "9.5.12",
 | 
			
		||||
        "semver": "7.3.2",
 | 
			
		||||
        "semver-intersect": "1.4.0"
 | 
			
		||||
      },
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "ini": {
 | 
			
		||||
          "version": "1.3.5",
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
 | 
			
		||||
          "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
 | 
			
		||||
          "dev": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "@types/glob": {
 | 
			
		||||
@@ -2215,6 +2237,11 @@
 | 
			
		||||
      "integrity": "sha512-UV1/ZJMC+HcP902wWdpC43cAcGu0IQk/I5bXjP2aSuCjsk3cE74mDvFrLKga7oDC170ugOAYBwfT4DSQW3akDA==",
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "@types/pdfjs-dist": {
 | 
			
		||||
      "version": "2.1.7",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/pdfjs-dist/-/pdfjs-dist-2.1.7.tgz",
 | 
			
		||||
      "integrity": "sha512-nQIwcPUhkAIyn7x9NS0lR/qxYfd5unRtfGkMjvpgF4Sh28IXftRymaNmFKTTdejDNY25NDGSIyjwj/BRwAPexg=="
 | 
			
		||||
    },
 | 
			
		||||
    "@types/q": {
 | 
			
		||||
      "version": "1.5.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
 | 
			
		||||
@@ -3023,6 +3050,16 @@
 | 
			
		||||
      "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "bindings": {
 | 
			
		||||
      "version": "1.5.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
 | 
			
		||||
      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "optional": true,
 | 
			
		||||
      "requires": {
 | 
			
		||||
        "file-uri-to-path": "1.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "blob": {
 | 
			
		||||
      "version": "0.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
 | 
			
		||||
@@ -5508,6 +5545,18 @@
 | 
			
		||||
        "schema-utils": "^2.6.5"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "file-saver": {
 | 
			
		||||
      "version": "2.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
 | 
			
		||||
      "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
 | 
			
		||||
    },
 | 
			
		||||
    "file-uri-to-path": {
 | 
			
		||||
      "version": "1.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "optional": true
 | 
			
		||||
    },
 | 
			
		||||
    "fill-range": {
 | 
			
		||||
      "version": "7.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
 | 
			
		||||
@@ -8208,6 +8257,13 @@
 | 
			
		||||
      "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
 | 
			
		||||
      "dev": true
 | 
			
		||||
    },
 | 
			
		||||
    "nan": {
 | 
			
		||||
      "version": "2.14.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
 | 
			
		||||
      "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "optional": true
 | 
			
		||||
    },
 | 
			
		||||
    "nanomatch": {
 | 
			
		||||
      "version": "1.2.13",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
 | 
			
		||||
@@ -8260,6 +8316,23 @@
 | 
			
		||||
        "moment": "2.18.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "ng2-pdf-viewer": {
 | 
			
		||||
      "version": "6.3.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ng2-pdf-viewer/-/ng2-pdf-viewer-6.3.2.tgz",
 | 
			
		||||
      "integrity": "sha512-H2tBhDd+Lq6CUzK2g54HsCcZDR2wTn1sDjYqKY3yF0Ydasl2R5ppCKynZBU/zge4EKvmHglJI120FbQMpJKDYQ==",
 | 
			
		||||
      "requires": {
 | 
			
		||||
        "@types/pdfjs-dist": "^2.1.4",
 | 
			
		||||
        "pdfjs-dist": "^2.4.456",
 | 
			
		||||
        "tslib": "^1.10.0"
 | 
			
		||||
      },
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "tslib": {
 | 
			
		||||
          "version": "1.14.1",
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
 | 
			
		||||
          "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "ngx-cookie-service": {
 | 
			
		||||
      "version": "10.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-10.1.1.tgz",
 | 
			
		||||
@@ -9270,6 +9343,11 @@
 | 
			
		||||
        "sha.js": "^2.4.8"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "pdfjs-dist": {
 | 
			
		||||
      "version": "2.5.207",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.5.207.tgz",
 | 
			
		||||
      "integrity": "sha512-xGDUhnCYPfHy+unMXCLCJtlpZaaZ17Ew3WIL0tnSgKFUZXHAPD49GO9xScyszSsQMoutNDgRb+rfBXIaX/lJbw=="
 | 
			
		||||
    },
 | 
			
		||||
    "performance-now": {
 | 
			
		||||
      "version": "2.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
 | 
			
		||||
@@ -13228,7 +13306,11 @@
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
 | 
			
		||||
          "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
 | 
			
		||||
          "dev": true,
 | 
			
		||||
          "optional": true
 | 
			
		||||
          "optional": true,
 | 
			
		||||
          "requires": {
 | 
			
		||||
            "bindings": "^1.5.0",
 | 
			
		||||
            "nan": "^2.12.1"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "glob-parent": {
 | 
			
		||||
          "version": "3.1.0",
 | 
			
		||||
@@ -13832,7 +13914,11 @@
 | 
			
		||||
          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
 | 
			
		||||
          "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
 | 
			
		||||
          "dev": true,
 | 
			
		||||
          "optional": true
 | 
			
		||||
          "optional": true,
 | 
			
		||||
          "requires": {
 | 
			
		||||
            "bindings": "^1.5.0",
 | 
			
		||||
            "nan": "^2.12.1"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "glob-parent": {
 | 
			
		||||
          "version": "3.1.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -20,9 +20,12 @@
 | 
			
		||||
    "@angular/platform-browser": "~10.1.5",
 | 
			
		||||
    "@angular/platform-browser-dynamic": "~10.1.5",
 | 
			
		||||
    "@angular/router": "~10.1.5",
 | 
			
		||||
    "@ng-bootstrap/ng-bootstrap": "^8.0.0",
 | 
			
		||||
    "@ng-bootstrap/ng-bootstrap": "^8.0.4",
 | 
			
		||||
    "@ng-select/ng-select": "^5.0.9",
 | 
			
		||||
    "bootstrap": "^4.5.0",
 | 
			
		||||
    "file-saver": "^2.0.5",
 | 
			
		||||
    "ng-bootstrap": "^1.6.3",
 | 
			
		||||
    "ng2-pdf-viewer": "^6.3.2",
 | 
			
		||||
    "ngx-cookie-service": "^10.1.1",
 | 
			
		||||
    "ngx-file-drop": "^10.0.0",
 | 
			
		||||
    "ngx-infinite-scroll": "^9.1.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,70 @@
 | 
			
		||||
import { Component } from '@angular/core';
 | 
			
		||||
import { SettingsService, SETTINGS_KEYS } from './services/settings.service';
 | 
			
		||||
import { Component, OnDestroy, OnInit } from '@angular/core';
 | 
			
		||||
import { Router } from '@angular/router';
 | 
			
		||||
import { Subscription } from 'rxjs';
 | 
			
		||||
import { ConsumerStatusService } from './services/consumer-status.service';
 | 
			
		||||
import { ToastService } from './services/toast.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-root',
 | 
			
		||||
  templateUrl: './app.component.html',
 | 
			
		||||
  styleUrls: ['./app.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class AppComponent {
 | 
			
		||||
  
 | 
			
		||||
  constructor () {
 | 
			
		||||
export class AppComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  newDocumentSubscription: Subscription;
 | 
			
		||||
  successSubscription: Subscription;
 | 
			
		||||
  failedSubscription: Subscription;
 | 
			
		||||
 | 
			
		||||
  constructor (private settings: SettingsService, private consumerStatusService: ConsumerStatusService, private toastService: ToastService, private router: Router) {
 | 
			
		||||
    let anyWindow = (window as any)
 | 
			
		||||
    anyWindow.pdfWorkerSrc = '/assets/js/pdf.worker.min.js';
 | 
			
		||||
    this.settings.updateDarkModeSettings()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy(): void {
 | 
			
		||||
    this.consumerStatusService.disconnect()
 | 
			
		||||
    if (this.successSubscription) {
 | 
			
		||||
      this.successSubscription.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
    if (this.failedSubscription) {
 | 
			
		||||
      this.failedSubscription.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
    if (this.newDocumentSubscription) {
 | 
			
		||||
      this.newDocumentSubscription.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private showNotification(key) {
 | 
			
		||||
    if (this.router.url == '/dashboard' && this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)) {
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
    return this.settings.get(key)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.consumerStatusService.connect()
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    this.successSubscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => {
 | 
			
		||||
      if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
 | 
			
		||||
        this.toastService.show({title: $localize`Document added`, delay: 10000, content: $localize`Document ${status.filename} was added to paperless.`, actionName: $localize`Open document`, action: () => {
 | 
			
		||||
          this.router.navigate(['documents', status.documentId])
 | 
			
		||||
        }})
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    this.failedSubscription = this.consumerStatusService.onDocumentConsumptionFailed().subscribe(status => {
 | 
			
		||||
      if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
 | 
			
		||||
        this.toastService.showError($localize`Could not add ${status.filename}\: ${status.message}`)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    this.newDocumentSubscription = this.consumerStatusService.onDocumentDetected().subscribe(status => {
 | 
			
		||||
      if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)) {
 | 
			
		||||
        this.toastService.show({title: $localize`New document detected`, delay: 5000, content: $localize`Document ${status.filename} is being processed by paperless.`})
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import { NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AppRoutingModule } from './app-routing.module';
 | 
			
		||||
import { AppComponent } from './app.component';
 | 
			
		||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { NgbDateAdapter, NgbDateParserFormatter, NgbModule } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
 | 
			
		||||
import { DocumentListComponent } from './components/document-list/document-list.component';
 | 
			
		||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component';
 | 
			
		||||
@@ -13,11 +13,10 @@ import { DocumentTypeListComponent } from './components/manage/document-type-lis
 | 
			
		||||
import { LogsComponent } from './components/manage/logs/logs.component';
 | 
			
		||||
import { SettingsComponent } from './components/manage/settings/settings.component';
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 | 
			
		||||
import { DatePipe } from '@angular/common';
 | 
			
		||||
import { SafePipe } from './pipes/safe.pipe';
 | 
			
		||||
import { DatePipe, registerLocaleData } from '@angular/common';
 | 
			
		||||
import { NotFoundComponent } from './components/not-found/not-found.component';
 | 
			
		||||
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component';
 | 
			
		||||
import { DeleteDialogComponent } from './components/common/delete-dialog/delete-dialog.component';
 | 
			
		||||
import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component';
 | 
			
		||||
import { CorrespondentEditDialogComponent } from './components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
 | 
			
		||||
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
 | 
			
		||||
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
 | 
			
		||||
@@ -27,16 +26,19 @@ import { ResultHighlightComponent } from './components/search/result-highlight/r
 | 
			
		||||
import { PageHeaderComponent } from './components/common/page-header/page-header.component';
 | 
			
		||||
import { AppFrameComponent } from './components/app-frame/app-frame.component';
 | 
			
		||||
import { ToastsComponent } from './components/common/toasts/toasts.component';
 | 
			
		||||
import { FilterEditorComponent } from './components/filter-editor/filter-editor.component';
 | 
			
		||||
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component';
 | 
			
		||||
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component';
 | 
			
		||||
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
 | 
			
		||||
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component';
 | 
			
		||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component';
 | 
			
		||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component';
 | 
			
		||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component';
 | 
			
		||||
import { NgxFileDropModule } from 'ngx-file-drop';
 | 
			
		||||
import { TextComponent } from './components/common/input/text/text.component';
 | 
			
		||||
import { SelectComponent } from './components/common/input/select/select.component';
 | 
			
		||||
import { CheckComponent } from './components/common/input/check/check.component';
 | 
			
		||||
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component';
 | 
			
		||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
 | 
			
		||||
import { DateTimeComponent } from './components/common/input/date-time/date-time.component';
 | 
			
		||||
import { TagsComponent } from './components/common/input/tags/tags.component';
 | 
			
		||||
import { SortableDirective } from './directives/sortable.directive';
 | 
			
		||||
import { CookieService } from 'ngx-cookie-service';
 | 
			
		||||
@@ -45,6 +47,34 @@ import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-v
 | 
			
		||||
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component';
 | 
			
		||||
import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component';
 | 
			
		||||
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component';
 | 
			
		||||
import { PdfViewerModule } from 'ng2-pdf-viewer';
 | 
			
		||||
import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component';
 | 
			
		||||
import { YesNoPipe } from './pipes/yes-no.pipe';
 | 
			
		||||
import { FileSizePipe } from './pipes/file-size.pipe';
 | 
			
		||||
import { FilterPipe } from './pipes/filter.pipe';
 | 
			
		||||
import { DocumentTitlePipe } from './pipes/document-title.pipe';
 | 
			
		||||
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
 | 
			
		||||
import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component';
 | 
			
		||||
import { NgSelectModule } from '@ng-select/ng-select';
 | 
			
		||||
import { NumberComponent } from './components/common/input/number/number.component';
 | 
			
		||||
import { SafePipe } from './pipes/safe.pipe';
 | 
			
		||||
import { CustomDatePipe } from './pipes/custom-date.pipe';
 | 
			
		||||
import { DateComponent } from './components/common/input/date/date.component';
 | 
			
		||||
import { ISODateTimeAdapter } from './utils/ngb-iso-date-time-adapter';
 | 
			
		||||
import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter';
 | 
			
		||||
import { ApiVersionInterceptor } from './interceptors/api-version.interceptor';
 | 
			
		||||
 | 
			
		||||
import localeFr from '@angular/common/locales/fr';
 | 
			
		||||
import localeNl from '@angular/common/locales/nl';
 | 
			
		||||
import localeDe from '@angular/common/locales/de';
 | 
			
		||||
import localePt from '@angular/common/locales/pt-PT';
 | 
			
		||||
import localeEnGb from '@angular/common/locales/en-GB';
 | 
			
		||||
 | 
			
		||||
registerLocaleData(localeFr)
 | 
			
		||||
registerLocaleData(localeNl)
 | 
			
		||||
registerLocaleData(localeDe)
 | 
			
		||||
registerLocaleData(localePt, "pt-BR")
 | 
			
		||||
registerLocaleData(localeEnGb)
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
  declarations: [
 | 
			
		||||
@@ -57,10 +87,9 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
 | 
			
		||||
    DocumentTypeListComponent,
 | 
			
		||||
    LogsComponent,
 | 
			
		||||
    SettingsComponent,
 | 
			
		||||
    SafePipe,
 | 
			
		||||
    NotFoundComponent,
 | 
			
		||||
    CorrespondentEditDialogComponent,
 | 
			
		||||
    DeleteDialogComponent,
 | 
			
		||||
    ConfirmDialogComponent,
 | 
			
		||||
    TagEditDialogComponent,
 | 
			
		||||
    DocumentTypeEditDialogComponent,
 | 
			
		||||
    TagComponent,
 | 
			
		||||
@@ -70,19 +99,33 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
 | 
			
		||||
    AppFrameComponent,
 | 
			
		||||
    ToastsComponent,
 | 
			
		||||
    FilterEditorComponent,
 | 
			
		||||
    FilterableDropdownComponent,
 | 
			
		||||
    ToggleableDropdownButtonComponent,
 | 
			
		||||
    DateDropdownComponent,
 | 
			
		||||
    DocumentCardLargeComponent,
 | 
			
		||||
    DocumentCardSmallComponent,
 | 
			
		||||
    BulkEditorComponent,
 | 
			
		||||
    TextComponent,
 | 
			
		||||
    SelectComponent,
 | 
			
		||||
    CheckComponent,
 | 
			
		||||
    SaveViewConfigDialogComponent,
 | 
			
		||||
    DateTimeComponent,
 | 
			
		||||
    TagsComponent,
 | 
			
		||||
    SortableDirective,
 | 
			
		||||
    SavedViewWidgetComponent,
 | 
			
		||||
    StatisticsWidgetComponent,
 | 
			
		||||
    UploadFileWidgetComponent,
 | 
			
		||||
    WidgetFrameComponent
 | 
			
		||||
    WidgetFrameComponent,
 | 
			
		||||
    WelcomeWidgetComponent,
 | 
			
		||||
    YesNoPipe,
 | 
			
		||||
    FileSizePipe,
 | 
			
		||||
    FilterPipe,
 | 
			
		||||
    DocumentTitlePipe,
 | 
			
		||||
    MetadataCollapseComponent,
 | 
			
		||||
    SelectDialogComponent,
 | 
			
		||||
    NumberComponent,
 | 
			
		||||
    SafePipe,
 | 
			
		||||
    CustomDatePipe,
 | 
			
		||||
    DateComponent
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    BrowserModule,
 | 
			
		||||
@@ -92,7 +135,9 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
 | 
			
		||||
    FormsModule,
 | 
			
		||||
    ReactiveFormsModule,
 | 
			
		||||
    NgxFileDropModule,
 | 
			
		||||
    InfiniteScrollModule
 | 
			
		||||
    InfiniteScrollModule,
 | 
			
		||||
    PdfViewerModule,
 | 
			
		||||
    NgSelectModule
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [
 | 
			
		||||
    DatePipe,
 | 
			
		||||
@@ -100,7 +145,15 @@ import { WidgetFrameComponent } from './components/dashboard/widgets/widget-fram
 | 
			
		||||
      provide: HTTP_INTERCEPTORS,
 | 
			
		||||
      useClass: CsrfInterceptor,
 | 
			
		||||
      multi: true
 | 
			
		||||
    }
 | 
			
		||||
    },{
 | 
			
		||||
      provide: HTTP_INTERCEPTORS,
 | 
			
		||||
      useClass: ApiVersionInterceptor,
 | 
			
		||||
      multi: true
 | 
			
		||||
    },
 | 
			
		||||
    FilterPipe,
 | 
			
		||||
    DocumentTitlePipe,
 | 
			
		||||
    {provide: NgbDateAdapter, useClass: ISODateTimeAdapter},
 | 
			
		||||
    {provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter}
 | 
			
		||||
  ],
 | 
			
		||||
  bootstrap: [AppComponent]
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -1,159 +1,186 @@
 | 
			
		||||
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow">
 | 
			
		||||
  <span class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#">
 | 
			
		||||
    <img src="assets/logo-dark-notext.svg" height="18px" class="mr-2">
 | 
			
		||||
    Paperless-ng
 | 
			
		||||
  </span>
 | 
			
		||||
  <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-toggle="collapse"
 | 
			
		||||
  <button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
 | 
			
		||||
    data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
 | 
			
		||||
    (click)="isMenuCollapsed = !isMenuCollapsed">
 | 
			
		||||
    <span class="navbar-toggler-icon"></span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <form (ngSubmit)="search()" class="w-100 m-1">
 | 
			
		||||
    <input class="form-control form-control-dark" type="text" placeholder="Search" aria-label="Search"
 | 
			
		||||
      [formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)">
 | 
			
		||||
  </form>
 | 
			
		||||
  <a class="navbar-brand col-auto col-md-3 col-lg-2 mr-0 px-3 py-3 order-sm-0" routerLink="/dashboard">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1rem" class="mr-2" fill="currentColor">
 | 
			
		||||
      <path d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z" transform="translate(0 0)"/>
 | 
			
		||||
    </svg>
 | 
			
		||||
    <ng-container i18n="app title">Paperless-ng</ng-container>
 | 
			
		||||
  </a>
 | 
			
		||||
  <div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 pl-md-4 mr-sm-auto order-3 order-sm-1">
 | 
			
		||||
    <form (ngSubmit)="search()" class="form-inline flex-grow-1">
 | 
			
		||||
      <input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
 | 
			
		||||
        [formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)" i18n-placeholder>
 | 
			
		||||
      <svg width="1em" height="1em">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#search"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
  <ul ngbNav class="order-sm-3">
 | 
			
		||||
    <li ngbDropdown class="nav-item dropdown">
 | 
			
		||||
      <button class="btn text-light" id="userDropdown" ngbDropdownToggle>
 | 
			
		||||
        <span *ngIf="displayName" class="navbar-text small mr-2 text-light d-none d-sm-inline">
 | 
			
		||||
          {{displayName}}
 | 
			
		||||
        </span>
 | 
			
		||||
        <svg width="1.3em" height="1.3em">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
      <div ngbDropdownMenu class="dropdown-menu-right shadow mr-2" aria-labelledby="userDropdown">
 | 
			
		||||
        <div *ngIf="displayName" class="d-sm-none">
 | 
			
		||||
          <p class="small mb-0 px-3" i18n>Logged in as {{displayName}}</p>
 | 
			
		||||
          <div class="dropdown-divider"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()">
 | 
			
		||||
          <svg class="sidebaricon mr-2" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#gear"/>
 | 
			
		||||
          </svg><ng-container i18n>Settings</ng-container>
 | 
			
		||||
        </a>
 | 
			
		||||
        <a ngbDropdownItem class="nav-link" href="accounts/logout/">
 | 
			
		||||
          <svg class="sidebaricon mr-2" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#door-open"/>
 | 
			
		||||
          </svg><ng-container i18n>Logout</ng-container>
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    </li>
 | 
			
		||||
  </ul>
 | 
			
		||||
</nav>
 | 
			
		||||
 | 
			
		||||
<div class="container-fluid">
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse" [ngbCollapse]="isMenuCollapsed">
 | 
			
		||||
      <div class="sidebar-sticky pt-3">
 | 
			
		||||
      <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
 | 
			
		||||
        <ul class="nav flex-column">
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#house"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Dashboard
 | 
			
		||||
              </svg> <ng-container i18n>Dashboard</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#files"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Documents
 | 
			
		||||
              </svg> <ng-container i18n>Documents</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='viewConfigService.getSideBarConfigs().length > 0'>
 | 
			
		||||
          <span>Saved views</span>
 | 
			
		||||
        <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'>
 | 
			
		||||
          <ng-container i18n>Saved views</ng-container>
 | 
			
		||||
        </h6>
 | 
			
		||||
        <ul class="nav flex-column mb-2">
 | 
			
		||||
          <li class="nav-item w-100" *ngFor='let config of viewConfigService.getSideBarConfigs()'>
 | 
			
		||||
            <a class="nav-link text-truncate" routerLink="view/{{config.id}}" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
          <li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
 | 
			
		||||
            <a class="nav-link text-truncate" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#funnel"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              {{config.title}}
 | 
			
		||||
              </svg> {{view.name}}
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'>
 | 
			
		||||
          <span>Open documents</span>
 | 
			
		||||
        <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'>
 | 
			
		||||
          <ng-container i18n>Open documents</ng-container>
 | 
			
		||||
        </h6>
 | 
			
		||||
        <ul class="nav flex-column mb-2">
 | 
			
		||||
          <li class="nav-item w-100" *ngFor='let d of openDocuments'>
 | 
			
		||||
            <a class="nav-link text-truncate" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#file-text"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              {{d.title}}
 | 
			
		||||
              </svg> {{d.title | documentTitle}}
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item w-100" *ngIf="openDocuments.length > 1">
 | 
			
		||||
            <a class="nav-link text-truncate" [routerLink]="" (click)="closeAll()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#x"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Close all
 | 
			
		||||
              </svg> <ng-container i18n>Close all</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
 | 
			
		||||
          <span>Manage</span>
 | 
			
		||||
        <h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted">
 | 
			
		||||
          <ng-container i18n>Manage</ng-container>
 | 
			
		||||
        </h6>
 | 
			
		||||
        <ul class="nav flex-column mb-2">
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#person"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Correspondents
 | 
			
		||||
              </svg> <ng-container i18n>Correspondents</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#tags"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Tags
 | 
			
		||||
              </svg> <ng-container i18n>Tags</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#hash"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Document types
 | 
			
		||||
              </svg> <ng-container i18n>Document types</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#text-left"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Logs
 | 
			
		||||
              </svg> <ng-container i18n>Logs</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#gear"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Settings
 | 
			
		||||
              </svg> <ng-container i18n>Settings</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" href="admin/">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#toggles"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Admin
 | 
			
		||||
              </svg> <ng-container i18n>Admin</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
 | 
			
		||||
        <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
 | 
			
		||||
          <span>Misc</span>
 | 
			
		||||
        <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
 | 
			
		||||
          <ng-container i18n>Info</ng-container>
 | 
			
		||||
        </h6>
 | 
			
		||||
        <ul class="nav flex-column mb-2">
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" href="https://paperless-ng.readthedocs.io/en/latest/">
 | 
			
		||||
            <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Documentation
 | 
			
		||||
              </svg> <ng-container i18n>Documentation</ng-container>
 | 
			
		||||
            </a>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" href="https://github.com/jonaswinkler/paperless-ng">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#link"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              GitHub
 | 
			
		||||
            </a>
 | 
			
		||||
            <div class="d-flex w-100 flex-wrap">
 | 
			
		||||
              <a class="nav-link pr-0 pb-0" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng">
 | 
			
		||||
                <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="sidebaricon bi bi-github" viewBox="0 0 16 16">
 | 
			
		||||
                  <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
 | 
			
		||||
                </svg> <ng-container i18n>GitHub</ng-container>
 | 
			
		||||
              </a>
 | 
			
		||||
              <a class="nav-link-additional small text-muted ml-3" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng/discussions/categories/feature-requests" title="Suggest an idea">
 | 
			
		||||
                <svg xmlns="http://www.w3.org/2000/svg" width=".9rem" height=".9rem" fill="currentColor" class="bi bi-lightbulb pr-1" viewBox="0 0 16 16">
 | 
			
		||||
                  <path d="M2 6a6 6 0 1 1 10.174 4.31c-.203.196-.359.4-.453.619l-.762 1.769A.5.5 0 0 1 10.5 13a.5.5 0 0 1 0 1 .5.5 0 0 1 0 1l-.224.447a1 1 0 0 1-.894.553H6.618a1 1 0 0 1-.894-.553L5.5 15a.5.5 0 0 1 0-1 .5.5 0 0 1 0-1 .5.5 0 0 1-.46-.302l-.761-1.77a1.964 1.964 0 0 0-.453-.618A5.984 5.984 0 0 1 2 6zm6-5a5 5 0 0 0-3.479 8.592c.263.254.514.564.676.941L5.83 12h4.342l.632-1.467c.162-.377.413-.687.676-.941A5 5 0 0 0 8 1z"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
                <ng-container i18n>Suggest an idea</ng-container>
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <a class="nav-link" href="accounts/logout/">
 | 
			
		||||
              <svg class="sidebaricon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#door-open"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Logout
 | 
			
		||||
            </a>
 | 
			
		||||
          <li class="nav-item mt-2">
 | 
			
		||||
            <div class="px-3 py-2 text-muted small">
 | 
			
		||||
              {{versionString}}
 | 
			
		||||
            </div>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,36 +1,31 @@
 | 
			
		||||
 | 
			
		||||
@import "/src/theme";
 | 
			
		||||
 | 
			
		||||
  /*
 | 
			
		||||
/*
 | 
			
		||||
 * Sidebar
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
 .sidebar {
 | 
			
		||||
.sidebar {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  z-index: 100; /* Behind the navbar */
 | 
			
		||||
  padding: 48px 0 0; /* Height of navbar */
 | 
			
		||||
  padding: 50px 0 0; /* Height of navbar */
 | 
			
		||||
  box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 767.98px) {
 | 
			
		||||
  .sidebar {
 | 
			
		||||
    top: 3rem;
 | 
			
		||||
    top: 3.5rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar-sticky {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  /* height: calc(100vh - 48px); */
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-top: .5rem;
 | 
			
		||||
  padding-top: 0.5rem;
 | 
			
		||||
  overflow-x: hidden;
 | 
			
		||||
  overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
 | 
			
		||||
  min-height: min-content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@supports ((position: -webkit-sticky) or (position: sticky)) {
 | 
			
		||||
  .sidebar-sticky {
 | 
			
		||||
    position: -webkit-sticky;
 | 
			
		||||
@@ -53,36 +48,99 @@
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar .nav-link:hover .sidebaricon,
 | 
			
		||||
.sidebar .nav-link.active .sidebaricon {
 | 
			
		||||
.sidebar .nav-link.active .sidebaricon,
 | 
			
		||||
.sidebar .nav-link:hover .sidebaricon {
 | 
			
		||||
  color: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sidebar-heading {
 | 
			
		||||
  font-size: .75rem;
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav {
 | 
			
		||||
  flex-wrap: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav-item .nav-link-additional {
 | 
			
		||||
  margin-top: 0.2rem;
 | 
			
		||||
  margin-left: 0.25rem;
 | 
			
		||||
  padding-top: 0.5rem;
 | 
			
		||||
 | 
			
		||||
  svg {
 | 
			
		||||
    margin-bottom: 2px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Navbar
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
 .navbar-brand {
 | 
			
		||||
  padding-top: .75rem;
 | 
			
		||||
  padding-bottom: .75rem;
 | 
			
		||||
.navbar-brand {
 | 
			
		||||
  padding-top: 0.75rem;
 | 
			
		||||
  padding-bottom: 0.75rem;
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
  background-color: rgba(0, 0, 0, .25);
 | 
			
		||||
  box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navbar .navbar-toggler {
 | 
			
		||||
  top: .25rem;
 | 
			
		||||
  right: 1rem;
 | 
			
		||||
.dropdown.show .dropdown-toggle,
 | 
			
		||||
.dropdown-toggle:hover {
 | 
			
		||||
  opacity: 0.7;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navbar .form-control {
 | 
			
		||||
  padding: .75rem 1rem;
 | 
			
		||||
  border-width: 0;
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
.dropdown-toggle::after {
 | 
			
		||||
  margin-left: 0.4em;
 | 
			
		||||
  vertical-align: 0.155em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navbar .dropdown-menu {
 | 
			
		||||
  font-size: 0.875rem; // body size
 | 
			
		||||
 | 
			
		||||
  a svg {
 | 
			
		||||
    opacity: 0.6;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navbar .search-form-container {
 | 
			
		||||
  max-width: 550px;
 | 
			
		||||
 | 
			
		||||
  form {
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  svg {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0.6rem;
 | 
			
		||||
    color: rgba(255, 255, 255, 0.6);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:focus-within {
 | 
			
		||||
    svg {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .form-control::placeholder {
 | 
			
		||||
      color: rgba(255, 255, 255, 0);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .form-control {
 | 
			
		||||
    color: rgba(255, 255, 255, 0.3);
 | 
			
		||||
    background-color: rgba(0, 0, 0, 0.15);
 | 
			
		||||
    padding-left: 1.8rem;
 | 
			
		||||
    border-color: rgba(255, 255, 255, 0.2);
 | 
			
		||||
    transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
 | 
			
		||||
    max-width: 600px;
 | 
			
		||||
    min-width: 300px; // 1/2 max
 | 
			
		||||
 | 
			
		||||
    &::placeholder {
 | 
			
		||||
      color: rgba(255, 255, 255, 0.4);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      background-color: #fff;
 | 
			
		||||
      color: #212529;
 | 
			
		||||
      flex-grow: 1;
 | 
			
		||||
      padding-left: 0.5rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,10 +5,12 @@ import { from, Observable, Subscription } from 'rxjs';
 | 
			
		||||
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
 | 
			
		||||
import { SearchService } from 'src/app/services/rest/search.service';
 | 
			
		||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
 | 
			
		||||
import { environment } from 'src/environments/environment';
 | 
			
		||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component';
 | 
			
		||||
  
 | 
			
		||||
import { Meta } from '@angular/platform-browser';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-app-frame',
 | 
			
		||||
  templateUrl: './app-frame.component.html',
 | 
			
		||||
@@ -21,10 +23,14 @@ export class AppFrameComponent implements OnInit, OnDestroy {
 | 
			
		||||
    private activatedRoute: ActivatedRoute,
 | 
			
		||||
    private openDocumentsService: OpenDocumentsService,
 | 
			
		||||
    private searchService: SearchService,
 | 
			
		||||
    public viewConfigService: SavedViewConfigService
 | 
			
		||||
    public savedViewService: SavedViewService,
 | 
			
		||||
    private meta: Meta
 | 
			
		||||
    ) {
 | 
			
		||||
      
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  versionString = `${environment.appTitle} ${environment.version}`
 | 
			
		||||
 | 
			
		||||
  isMenuCollapsed: boolean = true
 | 
			
		||||
 | 
			
		||||
  closeMenu() {
 | 
			
		||||
@@ -52,7 +58,7 @@ export class AppFrameComponent implements OnInit, OnDestroy {
 | 
			
		||||
        term.length < 2 ? from([[]]) : this.searchService.autocomplete(term)
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  itemSelected(event) {
 | 
			
		||||
    event.preventDefault()
 | 
			
		||||
    let currentSearch: string = this.searchField.value
 | 
			
		||||
@@ -90,7 +96,22 @@ export class AppFrameComponent implements OnInit, OnDestroy {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    this.openDocumentsSubscription.unsubscribe()
 | 
			
		||||
    if (this.openDocumentsSubscription) {
 | 
			
		||||
      this.openDocumentsSubscription.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get displayName() {
 | 
			
		||||
    // TODO: taken from dashboard component, is this the best way to pass around username?
 | 
			
		||||
    let tagFullName = this.meta.getTag('name=full_name')
 | 
			
		||||
    let tagUsername = this.meta.getTag('name=username')
 | 
			
		||||
    if (tagFullName && tagFullName.content) {
 | 
			
		||||
      return tagFullName.content
 | 
			
		||||
    } else if (tagUsername && tagUsername.content) {
 | 
			
		||||
      return tagUsername.content
 | 
			
		||||
    } else {
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,17 @@
 | 
			
		||||
    <div class="modal-header">
 | 
			
		||||
      <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
      <button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
 | 
			
		||||
        <span aria-hidden="true">×</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="modal-body">
 | 
			
		||||
      <p *ngIf="messageBold"><b>{{messageBold}}</b></p>
 | 
			
		||||
      <p *ngIf="message">{{message}}</p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="modal-footer">
 | 
			
		||||
      <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
 | 
			
		||||
      <button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
 | 
			
		||||
        {{btnCaption}}
 | 
			
		||||
        <span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { ConfirmDialogComponent } from './confirm-dialog.component';
 | 
			
		||||
 | 
			
		||||
describe('ConfirmDialogComponent', () => {
 | 
			
		||||
  let component: ConfirmDialogComponent;
 | 
			
		||||
  let fixture: ComponentFixture<ConfirmDialogComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ ConfirmDialogComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(ConfirmDialogComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,55 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-confirm-dialog',
 | 
			
		||||
  templateUrl: './confirm-dialog.component.html',
 | 
			
		||||
  styleUrls: ['./confirm-dialog.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class ConfirmDialogComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(public activeModal: NgbActiveModal) { }
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  public confirmClicked = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Confirmation`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  messageBold
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  message
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  btnClass = "btn-primary"
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  btnCaption = $localize`Confirm`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  buttonsEnabled = true
 | 
			
		||||
  
 | 
			
		||||
  confirmButtonEnabled = true
 | 
			
		||||
  seconds = 0
 | 
			
		||||
 | 
			
		||||
  delayConfirm(seconds: number) {
 | 
			
		||||
    this.confirmButtonEnabled = false
 | 
			
		||||
    this.seconds = seconds
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      if (this.seconds <= 1) {
 | 
			
		||||
        this.confirmButtonEnabled = true
 | 
			
		||||
      } else {
 | 
			
		||||
        this.delayConfirm(seconds - 1)
 | 
			
		||||
      }
 | 
			
		||||
    }, 1000)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  cancelClicked() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,62 @@
 | 
			
		||||
  <div class="btn-group w-100" ngbDropdown role="group">
 | 
			
		||||
  <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'">
 | 
			
		||||
    {{title}}
 | 
			
		||||
  </button>
 | 
			
		||||
  <div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
 | 
			
		||||
    <div class="list-group list-group-flush">
 | 
			
		||||
        <button *ngFor="let qf of quickFilters" class="list-group-item small list-goup list-group-item-action d-flex p-2 pl-3" role="menuitem" (click)="setDateQuickFilter(qf.id)">
 | 
			
		||||
          {{qf.name}}
 | 
			
		||||
        </button>
 | 
			
		||||
        <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
          <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
            <div i18n>After</div>
 | 
			
		||||
            <a *ngIf="dateAfter" class="btn btn-link p-0 m-0" (click)="clearAfter()">
 | 
			
		||||
              <svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
                <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              <small i18n>Clear</small>
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="input-group input-group-sm">
 | 
			
		||||
            <input class="form-control" [placeholder]="datePlaceHolder" id="dateAfter" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()"
 | 
			
		||||
                    [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
 | 
			
		||||
            <div class="input-group-append">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
 | 
			
		||||
                <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
 | 
			
		||||
                  <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
          <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
            <div i18n>Before</div>
 | 
			
		||||
            <a *ngIf="dateBefore" class="btn btn-link p-0 m-0" (click)="clearBefore()">
 | 
			
		||||
              <svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
                <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              <small i18n>Clear</small>
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="input-group input-group-sm">
 | 
			
		||||
            <input class="form-control" [placeholder]="datePlaceHolder" id="dateBefore" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()"
 | 
			
		||||
                    [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
 | 
			
		||||
            <div class="input-group-append">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
 | 
			
		||||
                <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
 | 
			
		||||
                  <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
.date-dropdown {
 | 
			
		||||
  min-width: 250px;
 | 
			
		||||
 | 
			
		||||
  .btn-link {
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,20 +1,20 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { DeleteDialogComponent } from './delete-dialog.component';
 | 
			
		||||
import { DateDropdownComponent } from './date-dropdown.component';
 | 
			
		||||
 | 
			
		||||
describe('DeleteDialogComponent', () => {
 | 
			
		||||
  let component: DeleteDialogComponent;
 | 
			
		||||
  let fixture: ComponentFixture<DeleteDialogComponent>;
 | 
			
		||||
describe('DateDropdownComponent', () => {
 | 
			
		||||
  let component: DateDropdownComponent;
 | 
			
		||||
  let fixture: ComponentFixture<DateDropdownComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ DeleteDialogComponent ]
 | 
			
		||||
      declarations: [ DateDropdownComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(DeleteDialogComponent);
 | 
			
		||||
    fixture = TestBed.createComponent(DateDropdownComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
@@ -0,0 +1,123 @@
 | 
			
		||||
import { formatDate } from '@angular/common';
 | 
			
		||||
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
 | 
			
		||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { Subject, Subscription } from 'rxjs';
 | 
			
		||||
import { debounceTime } from 'rxjs/operators';
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service';
 | 
			
		||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter';
 | 
			
		||||
 | 
			
		||||
export interface DateSelection {
 | 
			
		||||
  before?: string
 | 
			
		||||
  after?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const LAST_7_DAYS = 0
 | 
			
		||||
const LAST_MONTH = 1
 | 
			
		||||
const LAST_3_MONTHS = 2
 | 
			
		||||
const LAST_YEAR = 3
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-date-dropdown',
 | 
			
		||||
  templateUrl: './date-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./date-dropdown.component.scss'],
 | 
			
		||||
  providers: [
 | 
			
		||||
    {provide: NgbDateAdapter, useClass: ISODateAdapter},
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
export class DateDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  constructor(settings: SettingsService) {
 | 
			
		||||
    this.datePlaceHolder = settings.getLocalizedDateInputFormat()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  quickFilters = [
 | 
			
		||||
    {id: LAST_7_DAYS, name: $localize`Last 7 days`},
 | 
			
		||||
    {id: LAST_MONTH, name: $localize`Last month`},
 | 
			
		||||
    {id: LAST_3_MONTHS, name: $localize`Last 3 months`},
 | 
			
		||||
    {id: LAST_YEAR, name: $localize`Last year`}
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  datePlaceHolder: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  dateBefore: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  dateBeforeChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  dateAfter: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  dateAfterChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  datesSet = new EventEmitter<DateSelection>()
 | 
			
		||||
 | 
			
		||||
  private datesSetDebounce$ = new Subject()
 | 
			
		||||
 | 
			
		||||
  private sub: Subscription
 | 
			
		||||
 | 
			
		||||
  ngOnInit() {
 | 
			
		||||
    this.sub = this.datesSetDebounce$.pipe(
 | 
			
		||||
      debounceTime(400)
 | 
			
		||||
    ).subscribe(() => {
 | 
			
		||||
      this.onChange()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    if (this.sub) {
 | 
			
		||||
      this.sub.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setDateQuickFilter(qf: number) {
 | 
			
		||||
    this.dateBefore = null
 | 
			
		||||
    let date = new Date()
 | 
			
		||||
    switch (qf) {
 | 
			
		||||
      case LAST_7_DAYS:
 | 
			
		||||
        date.setDate(date.getDate() - 7)
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
      case LAST_MONTH:
 | 
			
		||||
        date.setMonth(date.getMonth() - 1)
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
      case LAST_3_MONTHS:
 | 
			
		||||
        date.setMonth(date.getMonth() - 3)
 | 
			
		||||
        break
 | 
			
		||||
 | 
			
		||||
      case LAST_YEAR:
 | 
			
		||||
        date.setFullYear(date.getFullYear() - 1)
 | 
			
		||||
        break
 | 
			
		||||
 | 
			
		||||
      }
 | 
			
		||||
    this.dateAfter = formatDate(date, 'yyyy-MM-dd', "en-us", "UTC")
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChange() {
 | 
			
		||||
    this.dateAfterChange.emit(this.dateAfter)
 | 
			
		||||
    this.dateBeforeChange.emit(this.dateBefore)
 | 
			
		||||
    this.datesSet.emit({after: this.dateAfter, before: this.dateBefore})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChangeDebounce() {
 | 
			
		||||
    this.datesSetDebounce$.next({after: this.dateAfter, before: this.dateBefore})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearBefore() {
 | 
			
		||||
    this.dateBefore = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAfter() {
 | 
			
		||||
    this.dateAfter = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
    <div class="modal-header">
 | 
			
		||||
      <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
      <button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
 | 
			
		||||
        <span aria-hidden="true">×</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="modal-body">
 | 
			
		||||
      <p><b>{{message}}</b></p>
 | 
			
		||||
      <p *ngIf="message2">{{message2}}</p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="modal-footer">
 | 
			
		||||
      <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
 | 
			
		||||
      <button type="button" class="btn btn-danger" (click)="deleteClicked.emit()">Delete</button>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -1,31 +0,0 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-delete-dialog',
 | 
			
		||||
  templateUrl: './delete-dialog.component.html',
 | 
			
		||||
  styleUrls: ['./delete-dialog.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class DeleteDialogComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(public activeModal: NgbActiveModal) { }
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  public deleteClicked = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = "Delete confirmation"
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  message = "Do you really want to delete this?"
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  message2
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  cancelClicked() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,10 +2,11 @@ import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { FormGroup } from '@angular/forms';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { MATCHING_ALGORITHMS } from 'src/app/data/matching-model';
 | 
			
		||||
import { map } from 'rxjs/operators';
 | 
			
		||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model';
 | 
			
		||||
import { ObjectWithId } from 'src/app/data/object-with-id';
 | 
			
		||||
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service';
 | 
			
		||||
import { Toast, ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
 | 
			
		||||
@Directive()
 | 
			
		||||
export abstract class EditDialogComponent<T extends ObjectWithId> implements OnInit {
 | 
			
		||||
@@ -13,8 +14,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
 | 
			
		||||
  constructor(
 | 
			
		||||
    private service: AbstractPaperlessService<T>,
 | 
			
		||||
    private activeModal: NgbActiveModal,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private entityName: string) { }
 | 
			
		||||
    private toastService: ToastService) { }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  dialogMode: string = 'create'
 | 
			
		||||
@@ -25,6 +25,12 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
 | 
			
		||||
  @Output()
 | 
			
		||||
  success = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  networkActive = false
 | 
			
		||||
 | 
			
		||||
  closeEnabled = false
 | 
			
		||||
 | 
			
		||||
  error = null
 | 
			
		||||
 | 
			
		||||
  abstract getForm(): FormGroup
 | 
			
		||||
 | 
			
		||||
  objectForm: FormGroup = this.getForm()
 | 
			
		||||
@@ -33,14 +39,31 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
 | 
			
		||||
    if (this.object != null) {
 | 
			
		||||
      this.objectForm.patchValue(this.object)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.closeEnabled = true
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getCreateTitle() {
 | 
			
		||||
    return $localize`Create new item`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getEditTitle() {
 | 
			
		||||
    return $localize`Edit item`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSaveErrorMessage(error: string) {
 | 
			
		||||
    return $localize`Could not save element: ${error}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTitle() {
 | 
			
		||||
    switch (this.dialogMode) {
 | 
			
		||||
      case 'create':
 | 
			
		||||
        return "Create new " + this.entityName
 | 
			
		||||
        return this.getCreateTitle()
 | 
			
		||||
      case 'edit':
 | 
			
		||||
        return "Edit " + this.entityName
 | 
			
		||||
        return this.getEditTitle()
 | 
			
		||||
      default:
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
@@ -50,6 +73,10 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
 | 
			
		||||
    return MATCHING_ALGORITHMS
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get patternRequired(): boolean {
 | 
			
		||||
    return this.objectForm?.value.matching_algorithm !== MATCH_AUTO
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  save() {
 | 
			
		||||
    var newObject = Object.assign(Object.assign({}, this.object), this.objectForm.value)
 | 
			
		||||
    var serverResponse: Observable<T>
 | 
			
		||||
@@ -62,11 +89,13 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
 | 
			
		||||
      default:
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
    this.networkActive = true
 | 
			
		||||
    serverResponse.subscribe(result => {
 | 
			
		||||
      this.activeModal.close()
 | 
			
		||||
      this.success.emit(result)
 | 
			
		||||
    }, error => {
 | 
			
		||||
      this.toastService.showToast(Toast.makeError(`Could not save ${this.entityName}: ${error.error.name}`))
 | 
			
		||||
      this.error = error.error
 | 
			
		||||
      this.networkActive = false
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,33 @@
 | 
			
		||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
 | 
			
		||||
  <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'">
 | 
			
		||||
    <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
      <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
 | 
			
		||||
    </svg>
 | 
			
		||||
    <div class="d-none d-sm-inline"> {{title}}</div>
 | 
			
		||||
    <ng-container *ngIf="!editing && selectionModel.selectionSize() > 0">
 | 
			
		||||
      <div class="badge bg-secondary text-light rounded-pill badge-corner">
 | 
			
		||||
        {{selectionModel.selectionSize()}}
 | 
			
		||||
      </div>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
  </button>
 | 
			
		||||
  <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
 | 
			
		||||
    <div class="list-group list-group-flush">
 | 
			
		||||
      <div class="list-group-item">
 | 
			
		||||
        <div class="input-group input-group-sm">
 | 
			
		||||
          <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div *ngIf="selectionModel.items" class="items">
 | 
			
		||||
        <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText">
 | 
			
		||||
          <app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)"></app-toggleable-dropdown-button>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
      </div>
 | 
			
		||||
      <button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!selectionModel.isDirty()">
 | 
			
		||||
        <small class="ml-1" [ngClass]="{'font-weight-bold': selectionModel.isDirty()}" i18n>Apply</small>
 | 
			
		||||
        <svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
.badge-corner {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: -8px;
 | 
			
		||||
  right: -8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-menu {
 | 
			
		||||
  min-width: 250px;
 | 
			
		||||
 | 
			
		||||
  .items {
 | 
			
		||||
    max-height: 400px;
 | 
			
		||||
    overflow-y: scroll;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { FilterableDropodownComponent } from './filterable-dropdown.component';
 | 
			
		||||
 | 
			
		||||
describe('FilterableDropodownComponent', () => {
 | 
			
		||||
  let component: FilterableDropodownComponent;
 | 
			
		||||
  let fixture: ComponentFixture<FilterableDropodownComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ FilterableDropodownComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(FilterableDropodownComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,272 @@
 | 
			
		||||
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core';
 | 
			
		||||
import { FilterPipe } from  'src/app/pipes/filter.pipe';
 | 
			
		||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component';
 | 
			
		||||
import { MatchingModel } from 'src/app/data/matching-model';
 | 
			
		||||
import { Subject } from 'rxjs';
 | 
			
		||||
 | 
			
		||||
export interface ChangedItems {
 | 
			
		||||
  itemsToAdd: MatchingModel[],
 | 
			
		||||
  itemsToRemove: MatchingModel[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class FilterableDropdownSelectionModel {
 | 
			
		||||
 | 
			
		||||
  changed = new Subject<FilterableDropdownSelectionModel>()
 | 
			
		||||
 | 
			
		||||
  multiple = false
 | 
			
		||||
 | 
			
		||||
  items: MatchingModel[] = []
 | 
			
		||||
 | 
			
		||||
  get itemsSorted(): MatchingModel[] {
 | 
			
		||||
    // TODO: this is getting called very often
 | 
			
		||||
    return this.items.sort((a,b) => {
 | 
			
		||||
      if (a.id == null && b.id != null) {
 | 
			
		||||
        return -1
 | 
			
		||||
      } else if (a.id != null && b.id == null) {
 | 
			
		||||
        return 1
 | 
			
		||||
      } else if (this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(b.id) != ToggleableItemState.NotSelected) {
 | 
			
		||||
        return 1
 | 
			
		||||
      } else if (this.getNonTemporary(a.id) != ToggleableItemState.NotSelected && this.getNonTemporary(b.id) == ToggleableItemState.NotSelected) {
 | 
			
		||||
        return -1
 | 
			
		||||
      } else {
 | 
			
		||||
        return a.name.localeCompare(b.name)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private selectionStates = new Map<number, ToggleableItemState>()
 | 
			
		||||
 | 
			
		||||
  private temporarySelectionStates = new Map<number, ToggleableItemState>()
 | 
			
		||||
 | 
			
		||||
  getSelectedItems() {
 | 
			
		||||
    return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set(id: number, state: ToggleableItemState, fireEvent = true) {
 | 
			
		||||
    if (state == ToggleableItemState.NotSelected) {
 | 
			
		||||
      this.temporarySelectionStates.delete(id)
 | 
			
		||||
    } else {
 | 
			
		||||
      this.temporarySelectionStates.set(id, state)
 | 
			
		||||
    }
 | 
			
		||||
    if (fireEvent) {
 | 
			
		||||
      this.changed.next(this)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggle(id: number, fireEvent = true) {
 | 
			
		||||
    let state = this.temporarySelectionStates.get(id)
 | 
			
		||||
    if (state == null || state != ToggleableItemState.Selected) {
 | 
			
		||||
      this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
 | 
			
		||||
    } else if (state == ToggleableItemState.Selected) {
 | 
			
		||||
      this.temporarySelectionStates.delete(id)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!this.multiple) {
 | 
			
		||||
      for (let key of this.temporarySelectionStates.keys()) {
 | 
			
		||||
        if (key != id) {
 | 
			
		||||
          this.temporarySelectionStates.delete(key)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!id) {
 | 
			
		||||
      for (let key of this.temporarySelectionStates.keys()) {
 | 
			
		||||
        if (key) {
 | 
			
		||||
          this.temporarySelectionStates.delete(key)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      this.temporarySelectionStates.delete(null)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (fireEvent) {
 | 
			
		||||
      this.changed.next(this)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private getNonTemporary(id: number) {
 | 
			
		||||
    return this.selectionStates.get(id) || ToggleableItemState.NotSelected
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get(id: number) {
 | 
			
		||||
    return this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  selectionSize() {
 | 
			
		||||
    return this.getSelectedItems().length
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clear(fireEvent = true) {
 | 
			
		||||
    this.temporarySelectionStates.clear()
 | 
			
		||||
    if (fireEvent) {
 | 
			
		||||
      this.changed.next(this)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isDirty() {
 | 
			
		||||
    if (!Array.from(this.temporarySelectionStates.keys()).every(id => this.temporarySelectionStates.get(id) == this.selectionStates.get(id))) {
 | 
			
		||||
      return true
 | 
			
		||||
    } else if (!Array.from(this.selectionStates.keys()).every(id => this.selectionStates.get(id) == this.temporarySelectionStates.get(id))) {
 | 
			
		||||
      return true
 | 
			
		||||
    } else {
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isNoneSelected() {
 | 
			
		||||
    return this.selectionSize() == 1 && this.get(null) == ToggleableItemState.Selected
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init(map) {
 | 
			
		||||
    this.temporarySelectionStates = map
 | 
			
		||||
    this.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  apply() {
 | 
			
		||||
    this.selectionStates.clear()
 | 
			
		||||
    this.temporarySelectionStates.forEach((value, key) => {
 | 
			
		||||
      this.selectionStates.set(key, value)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reset() {
 | 
			
		||||
    this.temporarySelectionStates.clear()
 | 
			
		||||
    this.selectionStates.forEach((value, key) => {
 | 
			
		||||
      this.temporarySelectionStates.set(key, value)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  diff(): ChangedItems {
 | 
			
		||||
    return {
 | 
			
		||||
      itemsToAdd: this.items.filter(item => this.temporarySelectionStates.get(item.id) == ToggleableItemState.Selected && this.selectionStates.get(item.id) != ToggleableItemState.Selected),
 | 
			
		||||
      itemsToRemove: this.items.filter(item => !this.temporarySelectionStates.has(item.id) && this.selectionStates.has(item.id)),
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-filterable-dropdown',
 | 
			
		||||
  templateUrl: './filterable-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./filterable-dropdown.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class FilterableDropdownComponent {
 | 
			
		||||
 | 
			
		||||
  @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
 | 
			
		||||
  @ViewChild('dropdown') dropdown: NgbDropdown
 | 
			
		||||
 | 
			
		||||
  filterText: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set items(items: MatchingModel[]) {
 | 
			
		||||
    if (items) {
 | 
			
		||||
      this._selectionModel.items = Array.from(items)
 | 
			
		||||
      this._selectionModel.items.unshift({
 | 
			
		||||
        name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
 | 
			
		||||
        id: null
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get items(): MatchingModel[] {
 | 
			
		||||
    return this._selectionModel.items
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _selectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set selectionModel(model: FilterableDropdownSelectionModel) {
 | 
			
		||||
    if (this.selectionModel) {
 | 
			
		||||
      this.selectionModel.changed.complete()
 | 
			
		||||
      model.items = this.selectionModel.items
 | 
			
		||||
      model.multiple = this.selectionModel.multiple
 | 
			
		||||
    }
 | 
			
		||||
    model.changed.subscribe(updatedModel => {
 | 
			
		||||
      this.selectionModelChange.next(updatedModel)
 | 
			
		||||
    })
 | 
			
		||||
    this._selectionModel = model
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get selectionModel(): FilterableDropdownSelectionModel {
 | 
			
		||||
    return this._selectionModel
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set multiple(value: boolean) {
 | 
			
		||||
    this.selectionModel.multiple = value
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get multiple() {
 | 
			
		||||
    return this.selectionModel.multiple
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  filterPlaceholder: string = ""
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  icon: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  allowSelectNone: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  editing = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  applyOnClose = false
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  apply = new EventEmitter<ChangedItems>()
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  open = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  constructor(private filterPipe: FilterPipe) {
 | 
			
		||||
    this.selectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  applyClicked() {
 | 
			
		||||
    if (this.selectionModel.isDirty()) {
 | 
			
		||||
      this.dropdown.close()
 | 
			
		||||
      if (!this.applyOnClose) {
 | 
			
		||||
        this.apply.emit(this.selectionModel.diff())
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dropdownOpenChange(open: boolean): void {
 | 
			
		||||
    if (open) {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        this.listFilterTextInput.nativeElement.focus();
 | 
			
		||||
      }, 0)
 | 
			
		||||
      if (this.editing) {
 | 
			
		||||
        this.selectionModel.reset()
 | 
			
		||||
      }
 | 
			
		||||
      this.open.next()
 | 
			
		||||
    } else {
 | 
			
		||||
      this.filterText = ''
 | 
			
		||||
      if (this.applyOnClose && this.selectionModel.isDirty()) {
 | 
			
		||||
        this.apply.emit(this.selectionModel.diff())
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  listFilterEnter(): void {
 | 
			
		||||
    let filtered = this.filterPipe.transform(this.items, this.filterText)
 | 
			
		||||
    if (filtered.length == 1) {
 | 
			
		||||
      this.selectionModel.toggle(filtered[0].id)
 | 
			
		||||
      if (this.editing) {
 | 
			
		||||
        this.applyClicked()
 | 
			
		||||
      } else {
 | 
			
		||||
        this.dropdown.close()
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,20 @@
 | 
			
		||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem()">
 | 
			
		||||
  <div class="selected-icon mr-1">
 | 
			
		||||
    <ng-container *ngIf="isChecked()">
 | 
			
		||||
      <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
 | 
			
		||||
        <path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
    <ng-container *ngIf="isPartiallyChecked()">
 | 
			
		||||
      <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-dash" viewBox="0 0 16 16">
 | 
			
		||||
        <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
    
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="mr-1">
 | 
			
		||||
    <app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag>
 | 
			
		||||
    <ng-template #displayName><small>{{item.name}}</small></ng-template>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div>
 | 
			
		||||
</button>
 | 
			
		||||
@@ -0,0 +1,4 @@
 | 
			
		||||
.selected-icon {
 | 
			
		||||
  min-width: 1em;
 | 
			
		||||
  min-height: 1em;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { ToggleableDropdownButtonComponent } from './toggleable-dropdown-button.component';
 | 
			
		||||
 | 
			
		||||
describe('ToggleableDropdownButtonComponent', () => {
 | 
			
		||||
  let component: ToggleableDropdownButtonComponent;
 | 
			
		||||
  let fixture: ComponentFixture<ToggleableDropdownButtonComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ ToggleableDropdownButtonComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(ToggleableDropdownButtonComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core';
 | 
			
		||||
import { MatchingModel } from 'src/app/data/matching-model';
 | 
			
		||||
 | 
			
		||||
export interface ToggleableItem {
 | 
			
		||||
  item: MatchingModel,
 | 
			
		||||
  state: ToggleableItemState,
 | 
			
		||||
  count: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum ToggleableItemState {
 | 
			
		||||
  NotSelected = 0,
 | 
			
		||||
  Selected = 1,
 | 
			
		||||
  PartiallySelected = 2
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-toggleable-dropdown-button',
 | 
			
		||||
  templateUrl: './toggleable-dropdown-button.component.html',
 | 
			
		||||
  styleUrls: ['./toggleable-dropdown-button.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class ToggleableDropdownButtonComponent {
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  item: MatchingModel
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  state: ToggleableItemState
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  count: number
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  toggle = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  get isTag(): boolean {
 | 
			
		||||
    return 'is_inbox_tag' in this.item
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleItem(): void {
 | 
			
		||||
    this.toggle.emit()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isChecked() {
 | 
			
		||||
    return this.state == ToggleableItemState.Selected
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isPartiallyChecked() {
 | 
			
		||||
    return this.state == ToggleableItemState.PartiallySelected
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +1,13 @@
 | 
			
		||||
import { Component, Directive, forwardRef, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor } from '@angular/forms';
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
 | 
			
		||||
@Directive()
 | 
			
		||||
export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
 | 
			
		||||
 | 
			
		||||
  @ViewChild("inputField")
 | 
			
		||||
  inputField: ElementRef
 | 
			
		||||
 | 
			
		||||
  constructor() { }
 | 
			
		||||
 | 
			
		||||
  onChange = (newValue: T) => {};
 | 
			
		||||
@@ -24,12 +27,21 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
 | 
			
		||||
    this.disabled = isDisabled;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  focus() {
 | 
			
		||||
    if (this.inputField && this.inputField.nativeElement) {
 | 
			
		||||
      this.inputField.nativeElement.focus()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled = false;
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  error: string
 | 
			
		||||
 | 
			
		||||
  value: T
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
<div class="form-row">
 | 
			
		||||
  <div class="form-group col">
 | 
			
		||||
      <label for="created_date">{{titleDate}}</label>
 | 
			
		||||
      <input type="date" class="form-control" id="created_date" [(ngModel)]="dateValue" (change)="dateOrTimeChanged()">
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="form-group col">
 | 
			
		||||
      <label for="created_time">{{titleTime}}</label>
 | 
			
		||||
      <input type="time" class="form-control" id="created_time" [(ngModel)]="timeValue" (change)="dateOrTimeChanged()">
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<!-- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> -->
 | 
			
		||||
@@ -1,62 +0,0 @@
 | 
			
		||||
import { formatDate } from '@angular/common';
 | 
			
		||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  providers: [{
 | 
			
		||||
    provide: NG_VALUE_ACCESSOR,
 | 
			
		||||
    useExisting: forwardRef(() => DateTimeComponent),
 | 
			
		||||
    multi: true
 | 
			
		||||
  }],
 | 
			
		||||
  selector: 'app-input-date-time',
 | 
			
		||||
  templateUrl: './date-time.component.html',
 | 
			
		||||
  styleUrls: ['./date-time.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class DateTimeComponent implements OnInit,ControlValueAccessor  {
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChange = (newValue: any) => {};
 | 
			
		||||
  
 | 
			
		||||
  onTouched = () => {};
 | 
			
		||||
 | 
			
		||||
  writeValue(newValue: any): void {
 | 
			
		||||
    this.dateValue = formatDate(newValue, 'yyyy-MM-dd', "en-US")
 | 
			
		||||
    this.timeValue = formatDate(newValue, 'HH:mm:ss', 'en-US')
 | 
			
		||||
  }
 | 
			
		||||
  registerOnChange(fn: any): void {
 | 
			
		||||
    this.onChange = fn;
 | 
			
		||||
  }
 | 
			
		||||
  registerOnTouched(fn: any): void {
 | 
			
		||||
    this.onTouched = fn;
 | 
			
		||||
  }
 | 
			
		||||
  setDisabledState?(isDisabled: boolean): void {
 | 
			
		||||
    this.disabled = isDisabled;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  titleDate: string = "Date"
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  titleTime: string = "Time"
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  hint: string
 | 
			
		||||
 | 
			
		||||
  timeValue
 | 
			
		||||
 | 
			
		||||
  dateValue
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dateOrTimeChanged() {
 | 
			
		||||
    this.onChange(formatDate(this.dateValue + "T" + this.timeValue,"yyyy-MM-ddTHH:mm:ssZZZZZ", "en-us", "UTC"))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,15 @@
 | 
			
		||||
<div class="form-group">
 | 
			
		||||
  <label [for]="inputId">{{title}}</label>
 | 
			
		||||
  <div class="input-group">
 | 
			
		||||
    <input [class.is-invalid]="error" class="form-control" [placeholder]="placeholder" [id]="inputId" (dateSelect)="onChange(value)" (change)="onChange(value)"
 | 
			
		||||
           name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel">
 | 
			
		||||
    <div class="input-group-append">
 | 
			
		||||
      <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button">
 | 
			
		||||
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
 | 
			
		||||
          <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="invalid-feedback" *ngIf="error" i18n>Invalid date.</div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,20 +1,20 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { DateTimeComponent } from './date-time.component';
 | 
			
		||||
import { DateComponent } from './date.component';
 | 
			
		||||
 | 
			
		||||
describe('DateTimeComponent', () => {
 | 
			
		||||
  let component: DateTimeComponent;
 | 
			
		||||
  let fixture: ComponentFixture<DateTimeComponent>;
 | 
			
		||||
describe('DateComponent', () => {
 | 
			
		||||
  let component: DateComponent;
 | 
			
		||||
  let fixture: ComponentFixture<DateComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ DateTimeComponent ]
 | 
			
		||||
      declarations: [ DateComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(DateTimeComponent);
 | 
			
		||||
    fixture = TestBed.createComponent(DateComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { NgbDateAdapter, NgbDateParserFormatter, NgbDatepickerContent } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service';
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  providers: [{
 | 
			
		||||
    provide: NG_VALUE_ACCESSOR,
 | 
			
		||||
    useExisting: forwardRef(() => DateComponent),
 | 
			
		||||
    multi: true
 | 
			
		||||
  }],
 | 
			
		||||
  selector: 'app-input-date',
 | 
			
		||||
  templateUrl: './date.component.html',
 | 
			
		||||
  styleUrls: ['./date.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class DateComponent extends AbstractInputComponent<string> implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(private settings: SettingsService) {
 | 
			
		||||
    super()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    super.ngOnInit()
 | 
			
		||||
    this.placeholder = this.settings.getLocalizedDateInputFormat()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  placeholder: string
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
<div class="form-group">
 | 
			
		||||
  <label [for]="inputId">{{title}}</label>
 | 
			
		||||
  <div class="input-group" [class.is-invalid]="error">
 | 
			
		||||
    <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error">
 | 
			
		||||
    <div class="input-group-append">
 | 
			
		||||
      <button class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="invalid-feedback">
 | 
			
		||||
    {{error}}
 | 
			
		||||
  </div>
 | 
			
		||||
  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { NumberComponent } from './number.component';
 | 
			
		||||
 | 
			
		||||
describe('NumberComponent', () => {
 | 
			
		||||
  let component: NumberComponent;
 | 
			
		||||
  let fixture: ComponentFixture<NumberComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ NumberComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(NumberComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,39 @@
 | 
			
		||||
import { Component, forwardRef } from '@angular/core';
 | 
			
		||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  providers: [{
 | 
			
		||||
    provide: NG_VALUE_ACCESSOR,
 | 
			
		||||
    useExisting: forwardRef(() => NumberComponent),
 | 
			
		||||
    multi: true
 | 
			
		||||
  }],
 | 
			
		||||
  selector: 'app-input-number',
 | 
			
		||||
  templateUrl: './number.component.html',
 | 
			
		||||
  styleUrls: ['./number.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class NumberComponent extends AbstractInputComponent<number> {
 | 
			
		||||
 | 
			
		||||
  constructor(private documentService: DocumentService) {
 | 
			
		||||
    super()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  nextAsn() {
 | 
			
		||||
    if (this.value) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
    this.documentService.listFiltered(1, 1, "archive_serial_number", true, [{rule_type: FILTER_ASN_ISNULL, value: "false"}]).subscribe(
 | 
			
		||||
      results => {
 | 
			
		||||
        if (results.count > 0) {
 | 
			
		||||
          this.value = results.results[0].archive_serial_number + 1
 | 
			
		||||
        } else {
 | 
			
		||||
          this.value = 1
 | 
			
		||||
        }
 | 
			
		||||
        this.onChange(this.value)
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,11 +1,18 @@
 | 
			
		||||
<div class="form-group">
 | 
			
		||||
<div class="form-group paperless-input-select">
 | 
			
		||||
  <label [for]="inputId">{{title}}</label>
 | 
			
		||||
  <div [class.input-group]="showPlusButton()">
 | 
			
		||||
    <select class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()"
 | 
			
		||||
      [disabled]="disabled" [style.color]="textColor" [style.background]="backgroundColor">
 | 
			
		||||
      <option *ngIf="allowNull" [ngValue]="null" class="form-control">---</option>
 | 
			
		||||
      <option *ngFor="let i of items" [ngValue]="i.id" class="form-control">{{i.name}}</option>
 | 
			
		||||
    </select>
 | 
			
		||||
    <ng-select name="inputId" [(ngModel)]="value"
 | 
			
		||||
      [disabled]="disabled"
 | 
			
		||||
      [style.color]="textColor"
 | 
			
		||||
      [style.background]="backgroundColor"
 | 
			
		||||
      [clearable]="allowNull"
 | 
			
		||||
      [items]="items"
 | 
			
		||||
      bindLabel="name"
 | 
			
		||||
      bindValue="id"
 | 
			
		||||
      (change)="onChange(value)"
 | 
			
		||||
      (blur)="onTouched()">
 | 
			
		||||
    </ng-select>
 | 
			
		||||
 | 
			
		||||
    <div *ngIf="showPlusButton()" class="input-group-append">
 | 
			
		||||
      <button class="btn btn-outline-secondary" type="button" (click)="createNew.emit()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
@@ -15,4 +22,12 @@
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
 | 
			
		||||
</div>
 | 
			
		||||
  <small *ngIf="getSuggestions().length > 0">
 | 
			
		||||
    <span i18n>Suggestions:</span> 
 | 
			
		||||
    <ng-container *ngFor="let s of getSuggestions()">
 | 
			
		||||
      <a (click)="value = s.id; onChange(value)" [routerLink]="">{{s.name}}</a> 
 | 
			
		||||
    </ng-container>
 | 
			
		||||
    
 | 
			
		||||
    
 | 
			
		||||
  </small>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
// styles for ng-select child are in styles.scss
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
 | 
			
		||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input';
 | 
			
		||||
 | 
			
		||||
@@ -30,11 +30,22 @@ export class SelectComponent extends AbstractInputComponent<number> {
 | 
			
		||||
  @Input()
 | 
			
		||||
  allowNull: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  suggestions: number[]
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createNew = new EventEmitter()
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  showPlusButton(): boolean {
 | 
			
		||||
    return this.createNew.observers.length > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSuggestions() {
 | 
			
		||||
    if (this.suggestions && this.items) {
 | 
			
		||||
      return this.suggestions.filter(id => id != this.value).map(id => this.items.find(item => item.id == id))
 | 
			
		||||
    } else {
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,30 +1,46 @@
 | 
			
		||||
<div class="form-group">
 | 
			
		||||
  <label for="exampleFormControlTextarea1">Tags</label>
 | 
			
		||||
<div class="form-group paperless-input-select paperless-input-tags">
 | 
			
		||||
  <label for="tags" i18n>Tags</label>
 | 
			
		||||
 | 
			
		||||
  <div class="input-group">
 | 
			
		||||
    <div class="form-control tags-form-control" id="tags">
 | 
			
		||||
      <app-tag class="mr-2" *ngFor="let id of displayValue" [tag]="getTag(id)" (click)="removeTag(id)"></app-tag>
 | 
			
		||||
    </div>
 | 
			
		||||
  <div class="input-group flex-nowrap">
 | 
			
		||||
    <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
 | 
			
		||||
      [multiple]="true"
 | 
			
		||||
      [closeOnSelect]="false"
 | 
			
		||||
      [clearSearchOnAdd]="true"
 | 
			
		||||
      [hideSelected]="true"
 | 
			
		||||
      (change)="onChange(value)"
 | 
			
		||||
      (blur)="onTouched()">
 | 
			
		||||
 | 
			
		||||
    <div class="input-group-append" ngbDropdown placement="top-right">
 | 
			
		||||
      <button class="btn btn-outline-secondary" type="button" ngbDropdownToggle></button>
 | 
			
		||||
      <div ngbDropdownMenu class="scrollable-menu">
 | 
			
		||||
        <button type="button" *ngFor="let tag of tags" ngbDropdownItem (click)="addTag(tag.id)">
 | 
			
		||||
          <app-tag [tag]="tag"></app-tag>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
      <ng-template ng-label-tmp let-item="item">
 | 
			
		||||
        <span class="tag-wrap tag-wrap-delete" (click)="removeTag(item.id)">
 | 
			
		||||
          <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#x"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          <app-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></app-tag>
 | 
			
		||||
        </span>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
      <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
 | 
			
		||||
        <div class="tag-wrap">
 | 
			
		||||
          <app-tag *ngIf="item.id && tags" class="mr-2" [tag]="getTag(item.id)"></app-tag>
 | 
			
		||||
        </div>
 | 
			
		||||
      </ng-template>
 | 
			
		||||
    </ng-select>
 | 
			
		||||
 | 
			
		||||
    <div class="input-group-append">
 | 
			
		||||
 | 
			
		||||
      <button class="btn btn-outline-secondary" type="button" (click)="createTag()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#plus" />
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  </div>
 | 
			
		||||
  <small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
 | 
			
		||||
  <small *ngIf="getSuggestions().length > 0">
 | 
			
		||||
    <span i18n>Suggestions:</span> 
 | 
			
		||||
    <ng-container *ngFor="let tag of getSuggestions()">
 | 
			
		||||
      <a (click)="addTag(tag.id)" [routerLink]="">{{tag.name}}</a> 
 | 
			
		||||
    </ng-container>
 | 
			
		||||
    
 | 
			
		||||
    
 | 
			
		||||
  </small>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,12 @@
 | 
			
		||||
.tags-form-control {
 | 
			
		||||
  height: auto;
 | 
			
		||||
.selected-icon {
 | 
			
		||||
  min-width: 1em;
 | 
			
		||||
  min-height: 1em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tag-wrap {
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.scrollable-menu {
 | 
			
		||||
  height: auto;
 | 
			
		||||
  max-height: 300px;
 | 
			
		||||
  overflow-x: hidden;
 | 
			
		||||
}
 | 
			
		||||
.tag-wrap-delete {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,6 @@
 | 
			
		||||
import { ThrowStmt } from '@angular/compiler';
 | 
			
		||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { TagEditDialogComponent } from 'src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { TagService } from 'src/app/services/rest/tag.service';
 | 
			
		||||
@@ -23,14 +21,11 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  onChange = (newValue: number[]) => {};
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  onTouched = () => {};
 | 
			
		||||
 | 
			
		||||
  writeValue(newValue: number[]): void {
 | 
			
		||||
    this.value = newValue
 | 
			
		||||
    if (this.tags) {
 | 
			
		||||
      this.displayValue = newValue
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  registerOnChange(fn: any): void {
 | 
			
		||||
    this.onChange = fn;
 | 
			
		||||
@@ -45,7 +40,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.tagService.listAll().subscribe(result => {
 | 
			
		||||
      this.tags = result.results
 | 
			
		||||
      this.displayValue = this.value
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -55,42 +49,54 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
 | 
			
		||||
  @Input()
 | 
			
		||||
  hint
 | 
			
		||||
 | 
			
		||||
  value: number[]
 | 
			
		||||
  @Input()
 | 
			
		||||
  suggestions: number[]
 | 
			
		||||
 | 
			
		||||
  displayValue: number[] = []
 | 
			
		||||
  value: number[]
 | 
			
		||||
 | 
			
		||||
  tags: PaperlessTag[]
 | 
			
		||||
 | 
			
		||||
  getTag(id) {
 | 
			
		||||
    return this.tags.find(tag => tag.id == id)
 | 
			
		||||
    if (this.tags) {
 | 
			
		||||
      return this.tags.find(tag => tag.id == id)
 | 
			
		||||
    } else {
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeTag(id) {
 | 
			
		||||
    let index = this.displayValue.indexOf(id)
 | 
			
		||||
    let index = this.value.indexOf(id)
 | 
			
		||||
    if (index > -1) {
 | 
			
		||||
      this.displayValue.splice(index, 1)
 | 
			
		||||
      this.onChange(this.displayValue)
 | 
			
		||||
      let oldValue = this.value
 | 
			
		||||
      oldValue.splice(index, 1)
 | 
			
		||||
      this.value = [...oldValue]
 | 
			
		||||
      this.onChange(this.value)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addTag(id) {
 | 
			
		||||
    let index = this.displayValue.indexOf(id)
 | 
			
		||||
    if (index == -1) {
 | 
			
		||||
      this.displayValue.push(id)
 | 
			
		||||
      this.onChange(this.displayValue)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  createTag() {
 | 
			
		||||
    var modal = this.modalService.open(TagEditDialogComponent, {backdrop: 'static'})
 | 
			
		||||
    modal.componentInstance.dialogMode = 'create'
 | 
			
		||||
    modal.componentInstance.success.subscribe(newTag => {
 | 
			
		||||
      this.tagService.listAll().subscribe(tags => {
 | 
			
		||||
        this.tags = tags.results
 | 
			
		||||
        this.addTag(newTag.id)
 | 
			
		||||
        this.value = [...this.value, newTag.id]
 | 
			
		||||
        this.onChange(this.value)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSuggestions() {
 | 
			
		||||
    if (this.suggestions && this.tags) {
 | 
			
		||||
      return this.suggestions.filter(id => !this.value.includes(id)).map(id => this.tags.find(tag => tag.id == id))
 | 
			
		||||
    } else {
 | 
			
		||||
      return []
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addTag(id) {
 | 
			
		||||
    this.value = [...this.value, id]
 | 
			
		||||
    this.onChange(this.value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,8 @@
 | 
			
		||||
<div class="form-group">
 | 
			
		||||
  <label [for]="inputId">{{title}}</label>
 | 
			
		||||
  <input type="text" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
 | 
			
		||||
  <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
 | 
			
		||||
  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
 | 
			
		||||
  <div class="invalid-feedback">
 | 
			
		||||
    {{error}}
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
import { Component, forwardRef } from '@angular/core';
 | 
			
		||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
<div class="row pt-3 pb-1 mb-3 border-bottom align-items-center" >
 | 
			
		||||
<div class="row pt-3 pb-3 pb-md-1 mb-3 border-bottom align-items-center">
 | 
			
		||||
  <div class="col-md text-truncate">
 | 
			
		||||
    <p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p>
 | 
			
		||||
    <p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="btn-toolbar col-auto">
 | 
			
		||||
  <div class="btn-toolbar col col-md-auto">
 | 
			
		||||
    <ng-content></ng-content>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,29 @@
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { Component, Input } from '@angular/core';
 | 
			
		||||
import { Title } from '@angular/platform-browser';
 | 
			
		||||
import { environment } from 'src/environments/environment';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-page-header',
 | 
			
		||||
  templateUrl: './page-header.component.html',
 | 
			
		||||
  styleUrls: ['./page-header.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class PageHeaderComponent implements OnInit {
 | 
			
		||||
export class PageHeaderComponent {
 | 
			
		||||
 | 
			
		||||
  constructor() { }
 | 
			
		||||
  constructor(private titleService: Title) { }
 | 
			
		||||
 | 
			
		||||
  _title = ""
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string = ""
 | 
			
		||||
  set title(title: string) {
 | 
			
		||||
    this._title = title
 | 
			
		||||
    this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get title() {
 | 
			
		||||
    return this._title
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  subTitle: string = ""
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,15 @@
 | 
			
		||||
<div class="modal-header">
 | 
			
		||||
  <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
  <button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
 | 
			
		||||
    <span aria-hidden="true">×</span>
 | 
			
		||||
  </button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-body">
 | 
			
		||||
 | 
			
		||||
  <app-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></app-input-select>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
  <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button>
 | 
			
		||||
  <button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { SelectDialogComponent } from './select-dialog.component';
 | 
			
		||||
 | 
			
		||||
describe('SelectDialogComponent', () => {
 | 
			
		||||
  let component: SelectDialogComponent;
 | 
			
		||||
  let fixture: ComponentFixture<SelectDialogComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ SelectDialogComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(SelectDialogComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,34 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { ObjectWithId } from 'src/app/data/object-with-id';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-select-dialog',
 | 
			
		||||
  templateUrl: './select-dialog.component.html',
 | 
			
		||||
  styleUrls: ['./select-dialog.component.scss']
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export class SelectDialogComponent implements OnInit {
 | 
			
		||||
  constructor(public activeModal: NgbActiveModal) { }
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  public selectClicked = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Select`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  message = $localize`Please select an object`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  objects: ObjectWithId[] = []
 | 
			
		||||
 | 
			
		||||
  selected: number
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  cancelClicked() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
 
 | 
			
		||||
@@ -3,5 +3,6 @@
 | 
			
		||||
  [header]="toast.title" [autohide]="true" [delay]="toast.delay"
 | 
			
		||||
  [class]="toast.classname"
 | 
			
		||||
  (hide)="toastService.closeToast(toast)">
 | 
			
		||||
  {{toast.content}}
 | 
			
		||||
</ngb-toast>
 | 
			
		||||
  <p>{{toast.content}}</p>
 | 
			
		||||
  <p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
 | 
			
		||||
</ngb-toast>
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,5 +1,7 @@
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
 | 
			
		||||
import { Meta } from '@angular/platform-browser';
 | 
			
		||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -10,13 +12,36 @@ import { SavedViewConfigService } from 'src/app/services/saved-view-config.servi
 | 
			
		||||
export class DashboardComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    public savedViewConfigService: SavedViewConfigService) { }
 | 
			
		||||
    private savedViewService: SavedViewService,
 | 
			
		||||
    private meta: Meta
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  get displayName() {
 | 
			
		||||
    let tagFullName = this.meta.getTag('name=full_name')
 | 
			
		||||
    let tagUsername = this.meta.getTag('name=username')
 | 
			
		||||
    if (tagFullName && tagFullName.content) {
 | 
			
		||||
      return tagFullName.content
 | 
			
		||||
    } else if (tagUsername && tagUsername.content) {
 | 
			
		||||
      return tagUsername.content
 | 
			
		||||
    } else {
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  savedViews = []
 | 
			
		||||
  get subtitle() {
 | 
			
		||||
    if (this.displayName) {
 | 
			
		||||
      return $localize`Hello ${this.displayName}, welcome to Paperless-ng!`
 | 
			
		||||
    } else {
 | 
			
		||||
      return $localize`Welcome to Paperless-ng!`
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  savedViews: PaperlessSavedView[] = []
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.savedViews = this.savedViewConfigService.getDashboardConfigs()
 | 
			
		||||
    this.savedViewService.listAll().subscribe(results => {
 | 
			
		||||
      this.savedViews = results.results.filter(savedView => savedView.show_on_dashboard)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,21 @@
 | 
			
		||||
<app-widget-frame [title]="savedView.title">
 | 
			
		||||
<app-widget-frame [title]="savedView.name">
 | 
			
		||||
 | 
			
		||||
  <a header-buttons [routerLink]="" (click)="showAll()">Show all</a>
 | 
			
		||||
  <a header-buttons [routerLink]="" (click)="showAll()" i18n>Show all</a>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  <table content class="table table-sm table-hover table-borderless">
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <th>Created</th>
 | 
			
		||||
        <th scope="col">Title</th>
 | 
			
		||||
        <th i18n>Created</th>
 | 
			
		||||
        <th scope="col" i18n>Title</th>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      <tr *ngFor="let doc of documents" routerLink="/documents/{{doc.id}}">
 | 
			
		||||
        <td>{{doc.created | date}}</td>
 | 
			
		||||
        <td>{{doc.title}}<app-tag [tag]="t" *ngFor="let t of doc.tags" class="ml-1"></app-tag>
 | 
			
		||||
        <td>{{doc.created | customDate}}</td>
 | 
			
		||||
        <td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ml-1"></app-tag>
 | 
			
		||||
      </tr>
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,8 @@
 | 
			
		||||
table {
 | 
			
		||||
  overflow-wrap: anywhere;
 | 
			
		||||
  table-layout: fixed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th:first-child {
 | 
			
		||||
  width: 25%;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
 | 
			
		||||
import { Router } from '@angular/router';
 | 
			
		||||
import { Subscription } from 'rxjs';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
 | 
			
		||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
 | 
			
		||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -10,27 +12,45 @@ import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
  templateUrl: './saved-view-widget.component.html',
 | 
			
		||||
  styleUrls: ['./saved-view-widget.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class SavedViewWidgetComponent implements OnInit {
 | 
			
		||||
export class SavedViewWidgetComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private documentService: DocumentService,
 | 
			
		||||
    private router: Router,
 | 
			
		||||
    private list: DocumentListViewService) { }
 | 
			
		||||
  
 | 
			
		||||
    private list: DocumentListViewService,
 | 
			
		||||
    private consumerStatusService: ConsumerStatusService) { }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  savedView: SavedViewConfig
 | 
			
		||||
  savedView: PaperlessSavedView
 | 
			
		||||
 | 
			
		||||
  documents: PaperlessDocument[] = []
 | 
			
		||||
 | 
			
		||||
  subscription: Subscription
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.documentService.list(1,10,this.savedView.sortField,this.savedView.sortDirection,this.savedView.filterRules).subscribe(result => {
 | 
			
		||||
    this.reload()
 | 
			
		||||
    this.subscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => {
 | 
			
		||||
      this.reload()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy(): void {
 | 
			
		||||
    this.subscription.unsubscribe()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reload() {
 | 
			
		||||
    this.documentService.listFiltered(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => {
 | 
			
		||||
      this.documents = result.results
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showAll() {
 | 
			
		||||
    this.list.load(this.savedView)
 | 
			
		||||
    this.router.navigate(["documents"])
 | 
			
		||||
    if (this.savedView.show_in_sidebar) {
 | 
			
		||||
      this.router.navigate(['view', this.savedView.id])
 | 
			
		||||
    } else {
 | 
			
		||||
      this.list.loadSavedView(this.savedView, true)
 | 
			
		||||
      this.router.navigate(["documents"])
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
<app-widget-frame title="Statistics">
 | 
			
		||||
<app-widget-frame title="Statistics" i18n-title>
 | 
			
		||||
  <ng-container content>
 | 
			
		||||
    <p class="card-text">Documents in inbox: {{statistics.documents_inbox}}</p>
 | 
			
		||||
    <p class="card-text">Total documents: {{statistics.documents_total}}</p>
 | 
			
		||||
    <p class="card-text" i18n *ngIf="statistics?.documents_inbox != null">Documents in inbox: {{statistics?.documents_inbox}}</p>
 | 
			
		||||
    <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p>
 | 
			
		||||
  </ng-container>
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { HttpClient } from '@angular/common/http';
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { Observable } from 'rxjs';
 | 
			
		||||
import { Component, OnDestroy, OnInit } from '@angular/core';
 | 
			
		||||
import { Observable, Subscription } from 'rxjs';
 | 
			
		||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service';
 | 
			
		||||
import { environment } from 'src/environments/environment';
 | 
			
		||||
 | 
			
		||||
export interface Statistics {
 | 
			
		||||
@@ -14,20 +15,34 @@ export interface Statistics {
 | 
			
		||||
  templateUrl: './statistics-widget.component.html',
 | 
			
		||||
  styleUrls: ['./statistics-widget.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class StatisticsWidgetComponent implements OnInit {
 | 
			
		||||
export class StatisticsWidgetComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  constructor(private http: HttpClient) { }
 | 
			
		||||
  constructor(private http: HttpClient,
 | 
			
		||||
    private consumerStatusService: ConsumerStatusService) { }
 | 
			
		||||
 | 
			
		||||
  statistics: Statistics = {}
 | 
			
		||||
 | 
			
		||||
  getStatistics(): Observable<Statistics> {
 | 
			
		||||
  subscription: Subscription
 | 
			
		||||
  
 | 
			
		||||
  private getStatistics(): Observable<Statistics> {
 | 
			
		||||
    return this.http.get(`${environment.apiBaseUrl}statistics/`)
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
 | 
			
		||||
  reload() {
 | 
			
		||||
    this.getStatistics().subscribe(statistics => {
 | 
			
		||||
      this.statistics = statistics
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.reload()
 | 
			
		||||
    this.subscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => {
 | 
			
		||||
      this.reload()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy(): void {
 | 
			
		||||
    this.subscription.unsubscribe()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,52 @@
 | 
			
		||||
<app-widget-frame title="Upload new documents">
 | 
			
		||||
<app-widget-frame title="Upload new documents" i18n-title>
 | 
			
		||||
  <div header-buttons>
 | 
			
		||||
    <a *ngIf="getStatusSuccess().length > 0" (click)="dismissCompleted()" [routerLink]="" >
 | 
			
		||||
      <span i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span> 
 | 
			
		||||
      <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
 | 
			
		||||
        <path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
 | 
			
		||||
        <path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div content>
 | 
			
		||||
    <form>
 | 
			
		||||
      <ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
 | 
			
		||||
        (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
 | 
			
		||||
        multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true
 | 
			
		||||
        browseBtnClassName="btn btn-sm btn-outline-primary ml-2" i18n-dropZoneLabel i18n-browseBtnLabel>
 | 
			
		||||
      </ngx-file-drop>
 | 
			
		||||
    </form>
 | 
			
		||||
    <p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
 | 
			
		||||
    <div *ngFor="let status of getStatus()">
 | 
			
		||||
      <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div *ngIf="getStatusHidden().length" class="alerts-hidden">
 | 
			
		||||
      <p *ngIf="!alertsExpanded" class="mt-3 mb-0 text-center">
 | 
			
		||||
        <span i18n="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span>
 | 
			
		||||
         • 
 | 
			
		||||
        <a [routerLink]="" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a>
 | 
			
		||||
      </p>
 | 
			
		||||
      <div #hiddenAlerts="ngbCollapse" [(ngbCollapse)]="!alertsExpanded">
 | 
			
		||||
        <div *ngFor="let status of getStatusHidden()">
 | 
			
		||||
          <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
 | 
			
		||||
  <form content>
 | 
			
		||||
    <ngx-file-drop 
 | 
			
		||||
      dropZoneLabel="Drop documents here or" (onFileDrop)="dropped($event)"
 | 
			
		||||
      (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)"
 | 
			
		||||
      dropZoneClassName="bg-light card"
 | 
			
		||||
      multiple="true"
 | 
			
		||||
      contentClassName="justify-content-center d-flex align-items-center p-5"
 | 
			
		||||
      [showBrowseBtn]=true
 | 
			
		||||
      browseBtnClassName="btn btn-sm btn-outline-primary ml-2">
 | 
			
		||||
 | 
			
		||||
    </ngx-file-drop>
 | 
			
		||||
  </form>
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
<ng-template #consumerAlert let-status>
 | 
			
		||||
  <ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
 | 
			
		||||
    <h6 class="alert-heading">{{status.filename}}</h6>
 | 
			
		||||
    <p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
 | 
			
		||||
    <ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
 | 
			
		||||
    <div *ngIf="isFinished(status)">
 | 
			
		||||
      <button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
 | 
			
		||||
        <small i18n>Open document</small>
 | 
			
		||||
        <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
 | 
			
		||||
          <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ngb-alert>
 | 
			
		||||
</ng-template>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,35 @@
 | 
			
		||||
@import "/src/theme";
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-heading {
 | 
			
		||||
  font-size: 80%;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alerts-hidden {
 | 
			
		||||
  .btn {
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-open {
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
 | 
			
		||||
  svg {
 | 
			
		||||
    margin-top: -1px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::ng-deep .progress {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  height: auto;
 | 
			
		||||
  mix-blend-mode: soft-light;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,10 @@
 | 
			
		||||
import { HttpEventType } from '@angular/common/http';
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop';
 | 
			
		||||
import { ConsumerStatusService, FileStatus, FileStatusPhase } from 'src/app/services/consumer-status.service';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
import { Toast, ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
 | 
			
		||||
const MAX_ALERTS = 5
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-upload-file-widget',
 | 
			
		||||
@@ -9,33 +12,132 @@ import { Toast, ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
  styleUrls: ['./upload-file-widget.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class UploadFileWidgetComponent implements OnInit {
 | 
			
		||||
  alertsExpanded = false
 | 
			
		||||
 | 
			
		||||
  constructor(private documentService: DocumentService, private toastService: ToastService) { }
 | 
			
		||||
  constructor(
 | 
			
		||||
    private documentService: DocumentService,
 | 
			
		||||
    private consumerStatusService: ConsumerStatusService
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  getStatus() {
 | 
			
		||||
    return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusSummary() {
 | 
			
		||||
    let strings = []
 | 
			
		||||
    let countUploadingAndProcessing =  this.consumerStatusService.getConsumerStatusNotCompleted().length
 | 
			
		||||
    let countFailed = this.getStatusFailed().length
 | 
			
		||||
    let countSuccess = this.getStatusSuccess().length
 | 
			
		||||
    if (countUploadingAndProcessing > 0) {
 | 
			
		||||
      strings.push($localize`Processing: ${countUploadingAndProcessing}`)
 | 
			
		||||
    }
 | 
			
		||||
    if (countFailed > 0) {
 | 
			
		||||
      strings.push($localize`Failed: ${countFailed}`)
 | 
			
		||||
    }
 | 
			
		||||
    if (countSuccess > 0) {
 | 
			
		||||
      strings.push($localize`Added: ${countSuccess}`)
 | 
			
		||||
    }
 | 
			
		||||
    return strings.join($localize`:this string is used to separate processing, failed and added on the file upload widget:, `)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusHidden() {
 | 
			
		||||
    if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS) return []
 | 
			
		||||
    else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusUploading() {
 | 
			
		||||
    return this.consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusFailed() {
 | 
			
		||||
    return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusSuccess() {
 | 
			
		||||
    return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusCompleted() {
 | 
			
		||||
    return this.consumerStatusService.getConsumerStatusCompleted()
 | 
			
		||||
  }
 | 
			
		||||
  getTotalUploadProgress() {
 | 
			
		||||
    let current = 0
 | 
			
		||||
    let max = 0
 | 
			
		||||
 | 
			
		||||
    this.getStatusUploading().forEach(status => {
 | 
			
		||||
      current += status.currentPhaseProgress
 | 
			
		||||
      max += status.currentPhaseMaxProgress
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    return current / Math.max(max, 1)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isFinished(status: FileStatus) {
 | 
			
		||||
    return status.phase == FileStatusPhase.FAILED || status.phase == FileStatusPhase.SUCCESS
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusColor(status: FileStatus) {
 | 
			
		||||
    switch (status.phase) {
 | 
			
		||||
      case FileStatusPhase.PROCESSING:
 | 
			
		||||
      case FileStatusPhase.UPLOADING:
 | 
			
		||||
          return "primary"
 | 
			
		||||
      case FileStatusPhase.FAILED:
 | 
			
		||||
        return "danger"
 | 
			
		||||
      case FileStatusPhase.SUCCESS:
 | 
			
		||||
        return "success"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dismiss(status: FileStatus) {
 | 
			
		||||
    this.consumerStatusService.dismiss(status)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dismissCompleted() {
 | 
			
		||||
    this.consumerStatusService.dismissCompleted()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public fileOver(event){
 | 
			
		||||
    console.log(event);
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
 | 
			
		||||
  public fileLeave(event){
 | 
			
		||||
    console.log(event);
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
 | 
			
		||||
  public dropped(files: NgxFileDropEntry[]) {
 | 
			
		||||
    for (const droppedFile of files) {
 | 
			
		||||
      if (droppedFile.fileEntry.isFile) {
 | 
			
		||||
        const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
 | 
			
		||||
        console.log(fileEntry)
 | 
			
		||||
 | 
			
		||||
      const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
 | 
			
		||||
        fileEntry.file((file: File) => {
 | 
			
		||||
          console.log(file)
 | 
			
		||||
          const formData = new FormData()
 | 
			
		||||
          let formData = new FormData()
 | 
			
		||||
          formData.append('document', file, file.name)
 | 
			
		||||
          this.documentService.uploadDocument(formData).subscribe(result => {
 | 
			
		||||
            this.toastService.showToast(Toast.make("Information", "The document has been uploaded and will be processed by the consumer shortly."))
 | 
			
		||||
          let status = this.consumerStatusService.newFileUpload(file.name)
 | 
			
		||||
 | 
			
		||||
          status.message = $localize`Connecting...`
 | 
			
		||||
 | 
			
		||||
          this.documentService.uploadDocument(formData).subscribe(event => {
 | 
			
		||||
            if (event.type == HttpEventType.UploadProgress) {
 | 
			
		||||
              status.updateProgress(FileStatusPhase.UPLOADING, event.loaded, event.total)
 | 
			
		||||
              status.message = $localize`Uploading...`
 | 
			
		||||
            } else if (event.type == HttpEventType.Response) {
 | 
			
		||||
              status.taskId = event.body["task_id"]
 | 
			
		||||
              status.message = $localize`Upload complete, waiting...`
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
          }, error => {
 | 
			
		||||
            this.toastService.showToast(Toast.makeError("An error has occured while uploading the document. Sorry!"))
 | 
			
		||||
            switch (error.status) {
 | 
			
		||||
              case 400: {
 | 
			
		||||
                this.consumerStatusService.fail(status, error.error.document)
 | 
			
		||||
                break;
 | 
			
		||||
              }
 | 
			
		||||
              default: {
 | 
			
		||||
                this.consumerStatusService.fail(status, $localize`HTTP error: ${error.status} ${error.statusText}`)
 | 
			
		||||
                break;
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
          })
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
<app-widget-frame title="First steps" i18n-title>
 | 
			
		||||
 | 
			
		||||
  <ng-container content>
 | 
			
		||||
    <img src="assets/save-filter.png" class="float-right">
 | 
			
		||||
    <p i18n>Paperless is running! :)</p>
 | 
			
		||||
    <p i18n>You can start uploading documents by dropping them in the file upload box to the right or by dropping them in the configured consumption folder and they'll start showing up in the documents list.
 | 
			
		||||
      After you've added some metadata to your documents, use the filtering mechanisms of paperless to create custom views (such as 'Recently added', 'Tagged TODO') and they will appear on the dashboard instead of this message.</p>
 | 
			
		||||
    <p i18n>Paperless offers some more features that try to make your life easier:</p>
 | 
			
		||||
    <ul>
 | 
			
		||||
      <li i18n>Once you've got a couple documents in paperless and added metadata to them, paperless can assign that metadata to new documents automatically.</li>
 | 
			
		||||
      <li i18n>You can configure paperless to read your mails and add documents from attached files.</li>
 | 
			
		||||
    </ul>
 | 
			
		||||
    <p i18n>Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general.</p>
 | 
			
		||||
  </ng-container>
 | 
			
		||||
 | 
			
		||||
</app-widget-frame>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { WelcomeWidgetComponent } from './welcome-widget.component';
 | 
			
		||||
 | 
			
		||||
describe('WelcomeWidgetComponent', () => {
 | 
			
		||||
  let component: WelcomeWidgetComponent;
 | 
			
		||||
  let fixture: ComponentFixture<WelcomeWidgetComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ WelcomeWidgetComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(WelcomeWidgetComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,15 @@
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-welcome-widget',
 | 
			
		||||
  templateUrl: './welcome-widget.component.html',
 | 
			
		||||
  styleUrls: ['./welcome-widget.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class WelcomeWidgetComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor() { }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<div class="card mb-3 shadow">
 | 
			
		||||
<div class="card mb-3 shadow-sm">
 | 
			
		||||
  <div class="card-header">
 | 
			
		||||
    <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
      <h5 class="card-title mb-0">{{title}}</h5>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,18 @@
 | 
			
		||||
<app-page-header [(title)]="title">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger mr-2" (click)="delete()">
 | 
			
		||||
    <div class="input-group input-group-sm mr-5 d-none d-md-flex" *ngIf="getContentType() == 'application/pdf' && !useNativePdfViewer">
 | 
			
		||||
      <div class="input-group-prepend">
 | 
			
		||||
        <div class="input-group-text" i18n>Page</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
 | 
			
		||||
      <div class="input-group-append">
 | 
			
		||||
        <div class="input-group-text" i18n>of {{previewNumPages}}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger mr-2 ml-auto" (click)="delete()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#trash" />
 | 
			
		||||
        </svg>
 | 
			
		||||
        <span class="d-none d-lg-inline"> Delete</span>
 | 
			
		||||
        </svg> <span class="d-none d-lg-inline" i18n>Delete</span>
 | 
			
		||||
    </button>
 | 
			
		||||
 | 
			
		||||
    <div class="btn-group mr-2">
 | 
			
		||||
@@ -11,65 +20,131 @@
 | 
			
		||||
        <a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
 | 
			
		||||
            <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
                <use xlink:href="assets/bootstrap-icons.svg#download" />
 | 
			
		||||
            </svg>
 | 
			
		||||
            <span class="d-none d-lg-inline"> Download</span>
 | 
			
		||||
            </svg> <span class="d-none d-lg-inline" i18n>Download</span>
 | 
			
		||||
        </a>
 | 
			
		||||
    
 | 
			
		||||
        <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.paperless__has_archive_version">
 | 
			
		||||
          <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
 | 
			
		||||
          <div class="dropdown-menu" ngbDropdownMenu>
 | 
			
		||||
            <a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
        <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
 | 
			
		||||
            <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
 | 
			
		||||
            <div class="dropdown-menu shadow" ngbDropdownMenu>
 | 
			
		||||
                <a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="moreLike()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#three-dots" />
 | 
			
		||||
        </svg> <span class="d-none d-lg-inline" i18n>More like this</span>
 | 
			
		||||
    </button>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary" (click)="close()">
 | 
			
		||||
        <svg class="buttonicon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#x" />
 | 
			
		||||
        </svg>
 | 
			
		||||
        <span class="d-none d-lg-inline"> Close</span>
 | 
			
		||||
        </svg> <span class="d-none d-lg-inline" i18n>Close</span>
 | 
			
		||||
    </button>
 | 
			
		||||
</app-page-header>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<div class="row">
 | 
			
		||||
    <div class="col-xl">
 | 
			
		||||
    <div class="col mb-4">
 | 
			
		||||
 | 
			
		||||
        <form [formGroup]='documentForm' (ngSubmit)="save()">
 | 
			
		||||
 | 
			
		||||
            <app-input-text title="Title" formControlName="title"></app-input-text>
 | 
			
		||||
            <ul ngbNav #nav="ngbNav" class="nav-tabs">
 | 
			
		||||
                <li [ngbNavItem]="1">
 | 
			
		||||
                    <a ngbNavLink i18n>Details</a>
 | 
			
		||||
                    <ng-template ngbNavContent>
 | 
			
		||||
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
                <label for="archive_serial_number">Archive Serial Number</label>
 | 
			
		||||
                <input type="number" class="form-control" id="archive_serial_number"
 | 
			
		||||
                    formControlName='archive_serial_number'>
 | 
			
		||||
            </div>
 | 
			
		||||
                        <app-input-text #inputTitle i18n-title title="Title" formControlName="title" [error]="error?.title"></app-input-text>
 | 
			
		||||
                        <app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number>
 | 
			
		||||
                        <app-input-date i18n-title title="Date created" formControlName="created" [error]="error?.created"></app-input-date>
 | 
			
		||||
                        <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true"
 | 
			
		||||
                            (createNew)="createCorrespondent()" [suggestions]="suggestions?.correspondents"></app-input-select>
 | 
			
		||||
                        <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true"
 | 
			
		||||
                            (createNew)="createDocumentType()" [suggestions]="suggestions?.document_types"></app-input-select>
 | 
			
		||||
                        <app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags>
 | 
			
		||||
 | 
			
		||||
            <app-input-date-time title="Date created" titleTime="Time created" formControlName="created"></app-input-date-time>
 | 
			
		||||
                    </ng-template>
 | 
			
		||||
                </li>
 | 
			
		||||
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
                <label for="content">Content</label>
 | 
			
		||||
                <textarea class="form-control" id="content" rows="5" formControlName='content'></textarea>
 | 
			
		||||
            </div>
 | 
			
		||||
                <li [ngbNavItem]="2">
 | 
			
		||||
                    <a ngbNavLink i18n>Content</a>
 | 
			
		||||
                    <ng-template ngbNavContent>
 | 
			
		||||
                        <div class="form-group">
 | 
			
		||||
                            <textarea class="form-control" id="content" rows="20" formControlName='content'></textarea>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </ng-template>
 | 
			
		||||
                </li>
 | 
			
		||||
 | 
			
		||||
            <app-input-select [items]="correspondents" title="Correspondent" formControlName="correspondent_id" allowNull="true" (createNew)="createCorrespondent()"></app-input-select>
 | 
			
		||||
                <li [ngbNavItem]="3">
 | 
			
		||||
                    <a ngbNavLink i18n>Metadata</a>
 | 
			
		||||
                    <ng-template ngbNavContent>
 | 
			
		||||
 | 
			
		||||
            <app-input-select [items]="documentTypes" title="Document type" formControlName="document_type_id" allowNull="true" (createNew)="createDocumentType()"></app-input-select>
 | 
			
		||||
                        <table class="table table-borderless">
 | 
			
		||||
                            <tbody>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Date modified</td>
 | 
			
		||||
                                    <td>{{document.modified | customDate}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Date added</td>
 | 
			
		||||
                                    <td>{{document.added | customDate}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Media filename</td>
 | 
			
		||||
                                    <td>{{metadata?.media_filename}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Original MD5 checksum</td>
 | 
			
		||||
                                    <td>{{metadata?.original_checksum}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Original file size</td>
 | 
			
		||||
                                    <td>{{metadata?.original_size | fileSize}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td i18n>Original mime type</td>
 | 
			
		||||
                                    <td>{{metadata?.original_mime_type}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr *ngIf="metadata?.has_archive_version">
 | 
			
		||||
                                    <td i18n>Archive MD5 checksum</td>
 | 
			
		||||
                                    <td>{{metadata?.archive_checksum}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                                <tr *ngIf="metadata?.has_archive_version">
 | 
			
		||||
                                    <td i18n>Archive file size</td>
 | 
			
		||||
                                    <td>{{metadata?.archive_size | fileSize}}</td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                            </tbody>
 | 
			
		||||
                        </table>
 | 
			
		||||
 | 
			
		||||
            <app-input-tags formControlName="tags_id" title="Tags"></app-input-tags>
 | 
			
		||||
                        <app-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata?.length > 0"></app-metadata-collapse>
 | 
			
		||||
                        <app-metadata-collapse i18n-title title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata?.length > 0"></app-metadata-collapse>
 | 
			
		||||
 | 
			
		||||
            <button type="button" class="btn btn-outline-secondary" (click)="discard()">Discard</button> 
 | 
			
		||||
            <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()">Save & edit next</button> 
 | 
			
		||||
            <button type="submit" class="btn btn-primary">Save</button> 
 | 
			
		||||
                    </ng-template>
 | 
			
		||||
                </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
 | 
			
		||||
            <div [ngbNavOutlet]="nav" class="mt-2"></div>
 | 
			
		||||
 | 
			
		||||
            <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive">Discard</button> 
 | 
			
		||||
            <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive">Save & next</button> 
 | 
			
		||||
            <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> 
 | 
			
		||||
        </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="col-xl">
 | 
			
		||||
        <object [data]="previewUrl | safe" type="application/pdf" width="100%" height="100%">
 | 
			
		||||
            <p>Your browser does not support PDFs.
 | 
			
		||||
                <a href="previewUrl">Download the PDF</a>.</p>
 | 
			
		||||
        </object>
 | 
			
		||||
    <div class="col-md-6 col-xl-8 mb-3">
 | 
			
		||||
        <ng-container *ngIf="getContentType() == 'application/pdf'">
 | 
			
		||||
            <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
 | 
			
		||||
                <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer>
 | 
			
		||||
            </div>
 | 
			
		||||
            <ng-template #nativePdfViewer>
 | 
			
		||||
                <object [data]="previewUrl | safe" type="application/pdf" class="preview-sticky" width="100%"></object>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
        <ng-container *ngIf="getContentType() == 'text/plain'">
 | 
			
		||||
            <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky" width="100%"></object>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
.preview-sticky {
 | 
			
		||||
  height: calc(100vh - 160px);
 | 
			
		||||
  top: 70px;
 | 
			
		||||
  position: sticky;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pdf-viewer-container {
 | 
			
		||||
  background-color: gray;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { Component, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
import { FormControl, FormGroup } from '@angular/forms';
 | 
			
		||||
import { ActivatedRoute, Router } from '@angular/router';
 | 
			
		||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
@@ -6,14 +6,20 @@ import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata';
 | 
			
		||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
 | 
			
		||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe';
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
 | 
			
		||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
 | 
			
		||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
 | 
			
		||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
import { DeleteDialogComponent } from '../common/delete-dialog/delete-dialog.component';
 | 
			
		||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component';
 | 
			
		||||
import { CorrespondentEditDialogComponent } from '../manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component';
 | 
			
		||||
import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component';
 | 
			
		||||
import { PDFDocumentProxy } from 'ng2-pdf-viewer';
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
import { TextComponent } from '../common/input/text/text.component';
 | 
			
		||||
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
 | 
			
		||||
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-document-detail',
 | 
			
		||||
@@ -22,9 +28,21 @@ import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/do
 | 
			
		||||
})
 | 
			
		||||
export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  @ViewChild("inputTitle")
 | 
			
		||||
  titleInput: TextComponent
 | 
			
		||||
 | 
			
		||||
  expandOriginalMetadata = false
 | 
			
		||||
  expandArchivedMetadata = false
 | 
			
		||||
 | 
			
		||||
  error: any
 | 
			
		||||
 | 
			
		||||
  networkActive = false
 | 
			
		||||
 | 
			
		||||
  documentId: number
 | 
			
		||||
  document: PaperlessDocument
 | 
			
		||||
  metadata: PaperlessDocumentMetadata
 | 
			
		||||
  suggestions: PaperlessDocumentSuggestions
 | 
			
		||||
 | 
			
		||||
  title: string
 | 
			
		||||
  previewUrl: string
 | 
			
		||||
  downloadUrl: string
 | 
			
		||||
@@ -37,21 +55,35 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
    title: new FormControl(''),
 | 
			
		||||
    content: new FormControl(''),
 | 
			
		||||
    created: new FormControl(),
 | 
			
		||||
    correspondent_id: new FormControl(),
 | 
			
		||||
    document_type_id: new FormControl(),
 | 
			
		||||
    correspondent: new FormControl(),
 | 
			
		||||
    document_type: new FormControl(),
 | 
			
		||||
    archive_serial_number: new FormControl(),
 | 
			
		||||
    tags_id: new FormControl([])
 | 
			
		||||
    tags: new FormControl([])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  previewCurrentPage: number = 1
 | 
			
		||||
  previewNumPages: number = 1
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private documentsService: DocumentService, 
 | 
			
		||||
    private documentsService: DocumentService,
 | 
			
		||||
    private route: ActivatedRoute,
 | 
			
		||||
    private correspondentService: CorrespondentService,
 | 
			
		||||
    private documentTypeService: DocumentTypeService,
 | 
			
		||||
    private router: Router,
 | 
			
		||||
    private modalService: NgbModal,
 | 
			
		||||
    private openDocumentService: OpenDocumentsService,
 | 
			
		||||
    private documentListViewService: DocumentListViewService) { }
 | 
			
		||||
    private documentListViewService: DocumentListViewService,
 | 
			
		||||
    private documentTitlePipe: DocumentTitlePipe,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private settings: SettingsService) { }
 | 
			
		||||
 | 
			
		||||
  get useNativePdfViewer(): boolean {
 | 
			
		||||
    return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getContentType() {
 | 
			
		||||
    return this.metadata?.has_archive_version ? 'application/pdf' : this.metadata?.original_mime_type
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.documentForm.valueChanges.subscribe(wow => {
 | 
			
		||||
@@ -66,6 +98,7 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
      this.previewUrl = this.documentsService.getPreviewUrl(this.documentId)
 | 
			
		||||
      this.downloadUrl = this.documentsService.getDownloadUrl(this.documentId)
 | 
			
		||||
      this.downloadOriginalUrl = this.documentsService.getDownloadUrl(this.documentId, true)
 | 
			
		||||
      this.suggestions = null
 | 
			
		||||
      if (this.openDocumentService.getOpenDocument(this.documentId)) {
 | 
			
		||||
        this.updateComponent(this.openDocumentService.getOpenDocument(this.documentId))
 | 
			
		||||
      } else {
 | 
			
		||||
@@ -82,8 +115,15 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
    this.document = doc
 | 
			
		||||
    this.documentsService.getMetadata(doc.id).subscribe(result => {
 | 
			
		||||
      this.metadata = result
 | 
			
		||||
    }, error => {
 | 
			
		||||
      this.metadata = null
 | 
			
		||||
    })
 | 
			
		||||
    this.title = doc.title
 | 
			
		||||
    this.documentsService.getSuggestions(doc.id).subscribe(result => {
 | 
			
		||||
      this.suggestions = result
 | 
			
		||||
    }, error => {
 | 
			
		||||
      this.suggestions = null
 | 
			
		||||
    })
 | 
			
		||||
    this.title = this.documentTitlePipe.transform(doc.title)
 | 
			
		||||
    this.documentForm.patchValue(doc)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -93,7 +133,7 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
    modal.componentInstance.success.subscribe(newDocumentType => {
 | 
			
		||||
      this.documentTypeService.listAll().subscribe(documentTypes => {
 | 
			
		||||
        this.documentTypes = documentTypes.results
 | 
			
		||||
        this.documentForm.get('document_type_id').setValue(newDocumentType.id)
 | 
			
		||||
        this.documentForm.get('document_type').setValue(newDocumentType.id)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
@@ -104,7 +144,7 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
    modal.componentInstance.success.subscribe(newCorrespondent => {
 | 
			
		||||
      this.correspondentService.listAll().subscribe(correspondents => {
 | 
			
		||||
        this.correspondents = correspondents.results
 | 
			
		||||
        this.documentForm.get('correspondent_id').setValue(newCorrespondent.id)
 | 
			
		||||
        this.documentForm.get('correspondent').setValue(newCorrespondent.id)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
@@ -117,46 +157,77 @@ export class DocumentDetailComponent implements OnInit {
 | 
			
		||||
    }, error => {this.router.navigate(['404'])})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  save() {    
 | 
			
		||||
  save() {
 | 
			
		||||
    this.networkActive = true
 | 
			
		||||
    this.documentsService.update(this.document).subscribe(result => {
 | 
			
		||||
      this.close()
 | 
			
		||||
      this.networkActive = false
 | 
			
		||||
      this.error = null
 | 
			
		||||
    }, error => {
 | 
			
		||||
      this.networkActive = false
 | 
			
		||||
      this.error = error.error
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  saveEditNext() {
 | 
			
		||||
    this.networkActive = true
 | 
			
		||||
    this.documentsService.update(this.document).subscribe(result => {
 | 
			
		||||
      this.error = null
 | 
			
		||||
      this.documentListViewService.getNext(this.document.id).subscribe(nextDocId => {
 | 
			
		||||
        this.networkActive = false
 | 
			
		||||
        if (nextDocId) {
 | 
			
		||||
          this.openDocumentService.closeDocument(this.document)
 | 
			
		||||
          this.router.navigate(['documents', nextDocId])
 | 
			
		||||
          this.titleInput.focus()
 | 
			
		||||
        }
 | 
			
		||||
      }, error => {
 | 
			
		||||
        this.networkActive = false
 | 
			
		||||
      })
 | 
			
		||||
    }, error => {
 | 
			
		||||
      this.networkActive = false
 | 
			
		||||
      this.error = error.error
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  close() {
 | 
			
		||||
    this.openDocumentService.closeDocument(this.document)
 | 
			
		||||
    if (this.documentListViewService.savedViewId) {
 | 
			
		||||
      this.router.navigate(['view', this.documentListViewService.savedViewId])
 | 
			
		||||
    if (this.documentListViewService.activeSavedViewId) {
 | 
			
		||||
      this.router.navigate(['view', this.documentListViewService.activeSavedViewId])
 | 
			
		||||
    } else {
 | 
			
		||||
      this.router.navigate(['documents'])
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  delete() {
 | 
			
		||||
    let modal = this.modalService.open(DeleteDialogComponent, {backdrop: 'static'})
 | 
			
		||||
    modal.componentInstance.message = `Do you really want to delete document '${this.document.title}'?`
 | 
			
		||||
    modal.componentInstance.message2 = `The files for this document will be deleted permanently. This operation cannot be undone.`
 | 
			
		||||
    modal.componentInstance.deleteClicked.subscribe(() => {
 | 
			
		||||
    let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
 | 
			
		||||
    modal.componentInstance.title = $localize`Confirm delete`
 | 
			
		||||
    modal.componentInstance.messageBold = $localize`Do you really want to delete document "${this.document.title}"?`
 | 
			
		||||
    modal.componentInstance.message = $localize`The files for this document will be deleted permanently. This operation cannot be undone.`
 | 
			
		||||
    modal.componentInstance.btnClass = "btn-danger"
 | 
			
		||||
    modal.componentInstance.btnCaption = $localize`Delete document`
 | 
			
		||||
    modal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
      modal.componentInstance.buttonsEnabled = false
 | 
			
		||||
      this.documentsService.delete(this.document).subscribe(() => {
 | 
			
		||||
        modal.close()  
 | 
			
		||||
        modal.close()
 | 
			
		||||
        this.close()
 | 
			
		||||
      }, error => {
 | 
			
		||||
        this.toastService.showError($localize`Error deleting document: ${JSON.stringify(error)}`)
 | 
			
		||||
        modal.componentInstance.buttonsEnabled = true
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  moreLike() {
 | 
			
		||||
    this.router.navigate(["search"], {queryParams: {more_like:this.document.id}})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  hasNext() {
 | 
			
		||||
    return this.documentListViewService.hasNext(this.documentId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pdfPreviewLoaded(pdf: PDFDocumentProxy) {
 | 
			
		||||
    this.previewNumPages = pdf.numPages
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
<h6>
 | 
			
		||||
  <button type="button" class="btn btn-outline-secondary btn-sm mr-2"
 | 
			
		||||
      (click)="expand = !expand">
 | 
			
		||||
      <svg class="buttonicon" fill="currentColor" *ngIf="!expand">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#caret-down" />
 | 
			
		||||
      </svg>
 | 
			
		||||
      <svg class="buttonicon" fill="currentColor" *ngIf="expand">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#caret-up" />
 | 
			
		||||
      </svg>
 | 
			
		||||
  </button>
 | 
			
		||||
  {{title}}
 | 
			
		||||
</h6>
 | 
			
		||||
 | 
			
		||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="!expand">
 | 
			
		||||
  <table class="table table-borderless">
 | 
			
		||||
      <tbody>
 | 
			
		||||
          <tr *ngFor="let m of metadata">
 | 
			
		||||
              <td>{{m.prefix}}:{{m.key}}</td>
 | 
			
		||||
              <td class="metadata-column">{{m.value}}</td>
 | 
			
		||||
          </tr>
 | 
			
		||||
      </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
.metadata-column {
 | 
			
		||||
  overflow-wrap: anywhere;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { MetadataCollapseComponent } from './metadata-collapse.component';
 | 
			
		||||
 | 
			
		||||
describe('MetadataCollapseComponent', () => {
 | 
			
		||||
  let component: MetadataCollapseComponent;
 | 
			
		||||
  let fixture: ComponentFixture<MetadataCollapseComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ MetadataCollapseComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(MetadataCollapseComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-metadata-collapse',
 | 
			
		||||
  templateUrl: './metadata-collapse.component.html',
 | 
			
		||||
  styleUrls: ['./metadata-collapse.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class MetadataCollapseComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor() { }
 | 
			
		||||
 | 
			
		||||
  expand = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  metadata
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Metadata`
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,79 @@
 | 
			
		||||
<div class="row">
 | 
			
		||||
  <div class="col-auto mb-2 mb-xl-0" role="group" aria-label="Select">
 | 
			
		||||
    <button class="btn btn-sm btn-outline-danger" (click)="list.selectNone()">
 | 
			
		||||
      <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#slash-circle" />
 | 
			
		||||
      </svg> <ng-container i18n>Cancel</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto mb-2 mb-xl-0 ml-auto ml-md-0" role="group" aria-label="Select">
 | 
			
		||||
    <label class="mr-2 mb-0" i18n>Select:</label>
 | 
			
		||||
    <div class="btn-group">
 | 
			
		||||
      <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
 | 
			
		||||
        <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#file-earmark-check" />
 | 
			
		||||
        </svg> <ng-container i18n>Page</ng-container>
 | 
			
		||||
      </button>
 | 
			
		||||
      <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
 | 
			
		||||
        <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#check-all" />
 | 
			
		||||
        </svg> <ng-container i18n>All</ng-container>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="w-100 d-xl-none"></div>
 | 
			
		||||
  <div class="col-auto mb-2 mb-xl-0">
 | 
			
		||||
    <div class="d-flex">
 | 
			
		||||
      <label class="ml-auto mt-1 mb-0 mr-2" i18n>Edit:</label>
 | 
			
		||||
      <app-filterable-dropdown class="mr-2 mr-md-3" title="Tags" icon="tag-fill" i18n-title
 | 
			
		||||
        filterPlaceholder="Filter tags" i18n-filterPlaceholder
 | 
			
		||||
        [items]="tags"
 | 
			
		||||
        [editing]="true"
 | 
			
		||||
        [multiple]="true"
 | 
			
		||||
        [applyOnClose]="applyOnClose"
 | 
			
		||||
        (open)="openTagsDropdown()"
 | 
			
		||||
        [(selectionModel)]="tagSelectionModel"
 | 
			
		||||
        (apply)="setTags($event)">
 | 
			
		||||
      </app-filterable-dropdown>
 | 
			
		||||
      <app-filterable-dropdown class="mr-2 mr-md-3" title="Correspondent" icon="person-fill" i18n-title
 | 
			
		||||
        filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
 | 
			
		||||
        [items]="correspondents"
 | 
			
		||||
        [editing]="true"
 | 
			
		||||
        [applyOnClose]="applyOnClose"
 | 
			
		||||
        (open)="openCorrespondentDropdown()"
 | 
			
		||||
        [(selectionModel)]="correspondentSelectionModel"
 | 
			
		||||
        (apply)="setCorrespondents($event)">
 | 
			
		||||
      </app-filterable-dropdown>
 | 
			
		||||
      <app-filterable-dropdown class="mr-2 mr-md-3" title="Document type" icon="file-earmark-fill" i18n-title
 | 
			
		||||
        filterPlaceholder="Filter document types" i18n-filterPlaceholder
 | 
			
		||||
        [items]="documentTypes"
 | 
			
		||||
        [editing]="true"
 | 
			
		||||
        [applyOnClose]="applyOnClose"
 | 
			
		||||
        (open)="openDocumentTypeDropdown()"
 | 
			
		||||
        [(selectionModel)]="documentTypeSelectionModel"
 | 
			
		||||
        (apply)="setDocumentTypes($event)">
 | 
			
		||||
      </app-filterable-dropdown>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto ml-auto mb-2 mb-xl-0 d-flex">
 | 
			
		||||
    <div class="btn-group btn-group-sm mr-2">
 | 
			
		||||
      <button type="button" class="btn btn-outline-primary btn-sm" (click)="downloadSelected()">
 | 
			
		||||
        <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
          <use xlink:href="assets/bootstrap-icons.svg#download" />
 | 
			
		||||
        </svg> <ng-container i18n>Download</ng-container>
 | 
			
		||||
      </button>
 | 
			
		||||
      <div class="btn-group" ngbDropdown role="group" aria-label="Button group with nested dropdown">
 | 
			
		||||
        <button class="btn btn-outline-primary btn-sm dropdown-toggle-split" ngbDropdownToggle></button>
 | 
			
		||||
        <div class="dropdown-menu shadow" ngbDropdownMenu>
 | 
			
		||||
          <button ngbDropdownItem i18n (click)="downloadSelected('originals')">Download originals</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()">
 | 
			
		||||
      <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#trash" />
 | 
			
		||||
      </svg> <ng-container i18n>Delete</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
 | 
			
		||||
 | 
			
		||||
import { BulkEditorComponent } from './bulk-editor.component';
 | 
			
		||||
 | 
			
		||||
describe('BulkEditorComponent', () => {
 | 
			
		||||
  let component: BulkEditorComponent;
 | 
			
		||||
  let fixture: ComponentFixture<BulkEditorComponent>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [ BulkEditorComponent ]
 | 
			
		||||
    })
 | 
			
		||||
    .compileComponents();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    fixture = TestBed.createComponent(BulkEditorComponent);
 | 
			
		||||
    component = fixture.componentInstance;
 | 
			
		||||
    fixture.detectChanges();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should create', () => {
 | 
			
		||||
    expect(component).toBeTruthy();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,217 @@
 | 
			
		||||
import { Component } from '@angular/core';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
 | 
			
		||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
 | 
			
		||||
import { TagService } from 'src/app/services/rest/tag.service';
 | 
			
		||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
 | 
			
		||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
 | 
			
		||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { DocumentService, SelectionDataItem } from 'src/app/services/rest/document.service';
 | 
			
		||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
 | 
			
		||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component';
 | 
			
		||||
import { ChangedItems, FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
 | 
			
		||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
 | 
			
		||||
import { MatchingModel } from 'src/app/data/matching-model';
 | 
			
		||||
import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service';
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
import { saveAs } from 'file-saver';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-bulk-editor',
 | 
			
		||||
  templateUrl: './bulk-editor.component.html',
 | 
			
		||||
  styleUrls: ['./bulk-editor.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class BulkEditorComponent {
 | 
			
		||||
 | 
			
		||||
  tags: PaperlessTag[]
 | 
			
		||||
  correspondents: PaperlessCorrespondent[]
 | 
			
		||||
  documentTypes: PaperlessDocumentType[]
 | 
			
		||||
 | 
			
		||||
  tagSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  correspondentSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  documentTypeSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private documentTypeService: DocumentTypeService,
 | 
			
		||||
    private tagService: TagService,
 | 
			
		||||
    private correspondentService: CorrespondentService,
 | 
			
		||||
    public list: DocumentListViewService,
 | 
			
		||||
    private documentService: DocumentService,
 | 
			
		||||
    private modalService: NgbModal,
 | 
			
		||||
    private openDocumentService: OpenDocumentsService,
 | 
			
		||||
    private settings: SettingsService,
 | 
			
		||||
    private toastService: ToastService
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  applyOnClose: boolean = this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE)
 | 
			
		||||
  showConfirmationDialogs: boolean = this.settings.get(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS)
 | 
			
		||||
 | 
			
		||||
  ngOnInit() {
 | 
			
		||||
    this.tagService.listAll().subscribe(result => this.tags = result.results)
 | 
			
		||||
    this.correspondentService.listAll().subscribe(result => this.correspondents = result.results)
 | 
			
		||||
    this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private executeBulkOperation(modal, method: string, args) {
 | 
			
		||||
    if (modal) {
 | 
			
		||||
      modal.componentInstance.buttonsEnabled = false
 | 
			
		||||
    }
 | 
			
		||||
    this.documentService.bulkEdit(Array.from(this.list.selected), method, args).subscribe(
 | 
			
		||||
      response => {
 | 
			
		||||
        this.list.reload()
 | 
			
		||||
        this.list.reduceSelectionToFilter()
 | 
			
		||||
        this.list.selected.forEach(id => {
 | 
			
		||||
          this.openDocumentService.refreshDocument(id)
 | 
			
		||||
        })
 | 
			
		||||
        if (modal) {
 | 
			
		||||
          modal.close()
 | 
			
		||||
        }
 | 
			
		||||
      }, error => {
 | 
			
		||||
        if (modal) {
 | 
			
		||||
          modal.componentInstance.buttonsEnabled = true
 | 
			
		||||
        }
 | 
			
		||||
        this.toastService.showError($localize`Error executing bulk operation: ${JSON.stringify(error.error)}`)
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private applySelectionData(items: SelectionDataItem[], selectionModel: FilterableDropdownSelectionModel) {
 | 
			
		||||
    let selectionData = new Map<number, ToggleableItemState>()
 | 
			
		||||
    items.forEach(i => {
 | 
			
		||||
      if (i.document_count == this.list.selected.size) {
 | 
			
		||||
        selectionData.set(i.id, ToggleableItemState.Selected)
 | 
			
		||||
      } else if (i.document_count > 0) {
 | 
			
		||||
        selectionData.set(i.id, ToggleableItemState.PartiallySelected)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    selectionModel.init(selectionData)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  openTagsDropdown() {
 | 
			
		||||
    this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => {
 | 
			
		||||
      this.applySelectionData(s.selected_tags, this.tagSelectionModel)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  openDocumentTypeDropdown() {
 | 
			
		||||
    this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => {
 | 
			
		||||
      this.applySelectionData(s.selected_document_types, this.documentTypeSelectionModel)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  openCorrespondentDropdown() {
 | 
			
		||||
    this.documentService.getSelectionData(Array.from(this.list.selected)).subscribe(s => {
 | 
			
		||||
      this.applySelectionData(s.selected_correspondents, this.correspondentSelectionModel)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _localizeList(items: MatchingModel[]) {
 | 
			
		||||
    if (items.length == 0) {
 | 
			
		||||
      return ""
 | 
			
		||||
    } else if (items.length == 1) {
 | 
			
		||||
      return $localize`"${items[0].name}"`
 | 
			
		||||
    } else if (items.length == 2) {
 | 
			
		||||
      return $localize`:This is for messages like 'modify "tag1" and "tag2"':"${items[0].name}" and "${items[1].name}"`
 | 
			
		||||
    } else {
 | 
			
		||||
      let list = items.slice(0, items.length - 1).map(i => $localize`"${i.name}"`).join($localize`:this is used to separate enumerations and should probably be a comma and a whitespace in most languages:, `)
 | 
			
		||||
      return $localize`:this is for messages like 'modify "tag1", "tag2" and "tag3"':${list} and "${items[items.length - 1].name}"`
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setTags(changedTags: ChangedItems) {
 | 
			
		||||
    if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length == 0) return
 | 
			
		||||
 | 
			
		||||
    if (this.showConfirmationDialogs) {
 | 
			
		||||
      let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
 | 
			
		||||
      modal.componentInstance.title = $localize`Confirm tags assignment`
 | 
			
		||||
      if (changedTags.itemsToAdd.length == 1 && changedTags.itemsToRemove.length == 0) {
 | 
			
		||||
        let tag = changedTags.itemsToAdd[0]
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (changedTags.itemsToAdd.length > 1 && changedTags.itemsToRemove.length == 0) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(changedTags.itemsToAdd)} to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length == 1) {
 | 
			
		||||
        let tag = changedTags.itemsToRemove[0]
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (changedTags.itemsToAdd.length == 0 && changedTags.itemsToRemove.length > 1) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(changedTags.itemsToRemove)} from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(changedTags.itemsToAdd)} and remove the tags ${this._localizeList(changedTags.itemsToRemove)} on ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      modal.componentInstance.btnClass = "btn-warning"
 | 
			
		||||
      modal.componentInstance.btnCaption = $localize`Confirm`
 | 
			
		||||
      modal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
        this.executeBulkOperation(modal, 'modify_tags', {"add_tags": changedTags.itemsToAdd.map(t => t.id), "remove_tags": changedTags.itemsToRemove.map(t => t.id)})
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      this.executeBulkOperation(null, 'modify_tags', {"add_tags": changedTags.itemsToAdd.map(t => t.id), "remove_tags": changedTags.itemsToRemove.map(t => t.id)})
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCorrespondents(changedCorrespondents: ChangedItems) {
 | 
			
		||||
    if (changedCorrespondents.itemsToAdd.length == 0 && changedCorrespondents.itemsToRemove.length == 0) return
 | 
			
		||||
 | 
			
		||||
    let correspondent = changedCorrespondents.itemsToAdd.length > 0 ? changedCorrespondents.itemsToAdd[0] : null
 | 
			
		||||
 | 
			
		||||
    if (this.showConfirmationDialogs) {
 | 
			
		||||
      let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
 | 
			
		||||
      modal.componentInstance.title = $localize`Confirm correspondent assignment`
 | 
			
		||||
      if (correspondent) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      }
 | 
			
		||||
      modal.componentInstance.btnClass = "btn-warning"
 | 
			
		||||
      modal.componentInstance.btnCaption = $localize`Confirm`
 | 
			
		||||
      modal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
        this.executeBulkOperation(modal, 'set_correspondent', {"correspondent": correspondent ? correspondent.id : null})
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      this.executeBulkOperation(null, 'set_correspondent', {"correspondent": correspondent ? correspondent.id : null})
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setDocumentTypes(changedDocumentTypes: ChangedItems) {
 | 
			
		||||
    if (changedDocumentTypes.itemsToAdd.length == 0 && changedDocumentTypes.itemsToRemove.length == 0) return
 | 
			
		||||
 | 
			
		||||
    let documentType = changedDocumentTypes.itemsToAdd.length > 0 ? changedDocumentTypes.itemsToAdd[0] : null
 | 
			
		||||
 | 
			
		||||
    if (this.showConfirmationDialogs) {
 | 
			
		||||
      let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
 | 
			
		||||
      modal.componentInstance.title = $localize`Confirm document type assignment`
 | 
			
		||||
      if (documentType) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      }
 | 
			
		||||
      modal.componentInstance.btnClass = "btn-warning"
 | 
			
		||||
      modal.componentInstance.btnCaption = $localize`Confirm`
 | 
			
		||||
      modal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
        this.executeBulkOperation(modal, 'set_document_type', {"document_type": documentType ? documentType.id : null})
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      this.executeBulkOperation(null, 'set_document_type', {"document_type": documentType ? documentType.id : null})
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  applyDelete() {
 | 
			
		||||
    let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
 | 
			
		||||
    modal.componentInstance.delayConfirm(5)
 | 
			
		||||
    modal.componentInstance.title = $localize`Delete confirm`
 | 
			
		||||
    modal.componentInstance.messageBold = $localize`This operation will permanently delete ${this.list.selected.size} selected document(s).`
 | 
			
		||||
    modal.componentInstance.message = $localize`This operation cannot be undone.`
 | 
			
		||||
    modal.componentInstance.btnClass = "btn-danger"
 | 
			
		||||
    modal.componentInstance.btnCaption = $localize`Delete document(s)`
 | 
			
		||||
    modal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
      modal.componentInstance.buttonsEnabled = false
 | 
			
		||||
      this.executeBulkOperation(modal, "delete", {})
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  downloadSelected(content = "archive") {
 | 
			
		||||
    this.documentService.bulkDownload(Array.from(this.list.selected), content).subscribe((result: any) => {
 | 
			
		||||
      saveAs(result, 'documents.zip');
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,19 +1,27 @@
 | 
			
		||||
<div class="card mb-3 bg-light shadow-sm">
 | 
			
		||||
<div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable">
 | 
			
		||||
  <div class="row no-gutters">
 | 
			
		||||
    <div class="col-md-2 d-none d-lg-block">
 | 
			
		||||
      <img [src]="getThumbUrl()" class="card-img doc-img border-right">
 | 
			
		||||
    <div class="col-md-2 d-none d-lg-block doc-img-background rounded-left" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)">
 | 
			
		||||
      <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left">
 | 
			
		||||
 | 
			
		||||
      <div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected">
 | 
			
		||||
        <div class="custom-control custom-checkbox">
 | 
			
		||||
          <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)">
 | 
			
		||||
          <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="col">
 | 
			
		||||
      <div class="card-body">
 | 
			
		||||
      <div class="card-body bg-light">
 | 
			
		||||
 | 
			
		||||
        <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
          <h5 class="card-title">    
 | 
			
		||||
          <h5 class="card-title">
 | 
			
		||||
            <ng-container *ngIf="document.correspondent">
 | 
			
		||||
              <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{document.correspondent.name}}</a>
 | 
			
		||||
              <ng-template #nolink>{{document.correspondent.name}}</ng-template>:
 | 
			
		||||
              <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>
 | 
			
		||||
              <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
 | 
			
		||||
            </ng-container>
 | 
			
		||||
            {{document.title}}
 | 
			
		||||
            <app-tag [tag]="t" linkTitle="Filter by tag" *ngFor="let t of document.tags" class="ml-1" (click)="clickTag.emit(t)" [clickable]="clickTag.observers.length"></app-tag>
 | 
			
		||||
            {{document.title | documentTitle}}
 | 
			
		||||
            <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></app-tag>
 | 
			
		||||
          </h5>
 | 
			
		||||
          <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -23,33 +31,43 @@
 | 
			
		||||
        </p>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
        <div class="d-flex flex-column flex-md-row align-items-md-center">
 | 
			
		||||
          <div class="btn-group">
 | 
			
		||||
            <a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis">
 | 
			
		||||
              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
 | 
			
		||||
                <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
 | 
			
		||||
              </svg> <span class="d-block d-md-inline" i18n>More like this</span>
 | 
			
		||||
            </a>
 | 
			
		||||
            <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary">
 | 
			
		||||
              <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
                <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Edit
 | 
			
		||||
              </svg> <span class="d-block d-md-inline" i18n>Edit</span>
 | 
			
		||||
            </a>
 | 
			
		||||
            <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()">
 | 
			
		||||
              <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
                <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
 | 
			
		||||
                <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              View
 | 
			
		||||
            <a class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()">
 | 
			
		||||
              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
 | 
			
		||||
                <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
 | 
			
		||||
                <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
 | 
			
		||||
              </svg> <span class="d-block d-md-inline" i18n>View</span>
 | 
			
		||||
            </a>
 | 
			
		||||
            <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
 | 
			
		||||
            <a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
 | 
			
		||||
              <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
                <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
 | 
			
		||||
                <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Download
 | 
			
		||||
              </svg> <span class="d-block d-md-inline" i18n>Download</span>
 | 
			
		||||
            </a>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
          <small class="text-muted">Created: {{document.created | date}}</small>
 | 
			
		||||
 | 
			
		||||
          <div *ngIf="searchScore" class="d-flex align-items-center ml-md-auto mt-2 mt-md-0">
 | 
			
		||||
            <small class="text-muted" i18n>Score:</small>
 | 
			
		||||
 | 
			
		||||
            <ngb-progressbar [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <small class="text-muted" [class.ml-auto]="!searchScore" i18n>Created: {{document.created | customDate}}</small>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,39 @@
 | 
			
		||||
@import "/src/theme";
 | 
			
		||||
 | 
			
		||||
.result-content {
 | 
			
		||||
  color: darkgray;
 | 
			
		||||
  overflow-wrap: anywhere;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.doc-img {
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
  object-position: top;
 | 
			
		||||
  object-position: top left;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  mix-blend-mode: multiply;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
.card-title {
 | 
			
		||||
  word-break: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-score-bar {
 | 
			
		||||
  width: 100px;
 | 
			
		||||
  height: 5px;
 | 
			
		||||
  margin-top: 1px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.document-card-check {
 | 
			
		||||
  display: none
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.document-card:hover .document-card-check {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-selected {
 | 
			
		||||
  border-color: $primary;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.doc-img-background-selected {
 | 
			
		||||
  background-color: $primaryFaded;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { DomSanitizer } from '@angular/platform-browser';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -13,6 +12,19 @@ export class DocumentCardLargeComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  selected = false
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  toggleSelected = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  get selectable() {
 | 
			
		||||
    return this.toggleSelected.observers.length > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  moreLikeThis: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  document: PaperlessDocument
 | 
			
		||||
 | 
			
		||||
@@ -20,10 +32,23 @@ export class DocumentCardLargeComponent implements OnInit {
 | 
			
		||||
  details: any
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  clickTag = new EventEmitter<PaperlessTag>()
 | 
			
		||||
  clickTag = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  clickCorrespondent = new EventEmitter<PaperlessDocument>()
 | 
			
		||||
  clickCorrespondent = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  searchScore: number
 | 
			
		||||
 | 
			
		||||
  get searchScoreClass() {
 | 
			
		||||
    if (this.searchScore > 0.7) {
 | 
			
		||||
      return "success"
 | 
			
		||||
    } else if (this.searchScore > 0.3) {
 | 
			
		||||
      return "warning"
 | 
			
		||||
    } else {
 | 
			
		||||
      return "danger"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,44 +1,58 @@
 | 
			
		||||
<div class="col p-2 h-100" style="width: 16rem;">
 | 
			
		||||
  <div class="card h-100 shadow-sm">
 | 
			
		||||
    <div class=" border-bottom doc-img pr-1" [ngStyle]="{'background-image': 'url(' + getThumbUrl() + ')'}">
 | 
			
		||||
      <div class="row" *ngFor="let t of document.tags">
 | 
			
		||||
        <app-tag style="font-size: large;" [tag]="t" class="col text-right" (click)="clickTag.emit(t)" [clickable]="true" linkTitle="Filter by tag"></app-tag>
 | 
			
		||||
<div class="col p-2 h-100">
 | 
			
		||||
  <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected">
 | 
			
		||||
    <div class="border-bottom doc-img-container" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)">
 | 
			
		||||
      <img class="card-img doc-img rounded-top" [src]="getThumbUrl()">
 | 
			
		||||
 | 
			
		||||
      <div class="border-right border-bottom bg-light p-1 rounded document-card-check">
 | 
			
		||||
        <div class="custom-control custom-checkbox">
 | 
			
		||||
          <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)">
 | 
			
		||||
          <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1">
 | 
			
		||||
        <div *ngFor="let t of getTagsLimited$() | async">
 | 
			
		||||
          <app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div *ngIf="moreTags">
 | 
			
		||||
          <span class="badge badge-secondary">+ {{moreTags}}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    <div class="card-body p-2">
 | 
			
		||||
      <p class="card-text">
 | 
			
		||||
        <ng-container *ngIf="document.correspondent">
 | 
			
		||||
          <a [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{document.correspondent.name}}</a>:
 | 
			
		||||
          <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>:
 | 
			
		||||
        </ng-container>
 | 
			
		||||
        {{document.title}}
 | 
			
		||||
        {{document.title | documentTitle}} <span *ngIf="document.archive_serial_number">(#{{document.archive_serial_number}})</span>
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="card-footer">
 | 
			
		||||
 | 
			
		||||
      <div class="d-flex justify-content-between align-items-center ml-n2">
 | 
			
		||||
      <div class="d-flex justify-content-between align-items-center mx-n2">
 | 
			
		||||
        <div class="btn-group">
 | 
			
		||||
          <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit">
 | 
			
		||||
          <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title>
 | 
			
		||||
            <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
              <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </a>
 | 
			
		||||
          <a [href]="getPreviewUrl()" class="btn btn-sm btn-outline-secondary" title="View in browser">
 | 
			
		||||
            <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
              <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
 | 
			
		||||
              <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
 | 
			
		||||
          <a [href]="getPreviewUrl()" class="btn btn-sm btn-outline-secondary" title="View in browser" i18n-title>
 | 
			
		||||
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
 | 
			
		||||
              <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
 | 
			
		||||
              <path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </a>
 | 
			
		||||
          <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download">
 | 
			
		||||
          <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" (click)="$event.stopPropagation()" i18n-title>
 | 
			
		||||
            <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
              <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
 | 
			
		||||
              <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
        <small class="text-muted">{{document.created | date}}</small>
 | 
			
		||||
        <small class="text-muted pl-1">{{document.created | customDate:'shortDate'}}</small>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>  
 | 
			
		||||
</div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,36 @@
 | 
			
		||||
@import "/src/theme";
 | 
			
		||||
 | 
			
		||||
.doc-img {
 | 
			
		||||
  background-size: cover;
 | 
			
		||||
  background-position: top;
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
  object-position: top left;
 | 
			
		||||
  height: 200px;
 | 
			
		||||
}
 | 
			
		||||
  mix-blend-mode: multiply;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.document-card-check {
 | 
			
		||||
  display: none;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
 | 
			
		||||
  .custom-control {
 | 
			
		||||
    margin-left: 4px;
 | 
			
		||||
    margin-right: -3px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.document-card:hover .document-card-check {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-selected {
 | 
			
		||||
  border-color: $primary;
 | 
			
		||||
 | 
			
		||||
  .document-card-check {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.doc-img-background-selected {
 | 
			
		||||
  background-color: $primaryFaded;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { map } from 'rxjs/operators';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -12,14 +12,22 @@ export class DocumentCardSmallComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(private documentService: DocumentService) { }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  selected = false
 | 
			
		||||
  
 | 
			
		||||
  @Output()
 | 
			
		||||
  toggleSelected = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  document: PaperlessDocument
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  clickTag = new EventEmitter<PaperlessTag>()
 | 
			
		||||
  clickTag = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  clickCorrespondent = new EventEmitter<PaperlessDocument>()
 | 
			
		||||
  clickCorrespondent = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  moreTags: number = null
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
  }
 | 
			
		||||
@@ -35,4 +43,18 @@ export class DocumentCardSmallComponent implements OnInit {
 | 
			
		||||
  getPreviewUrl() {
 | 
			
		||||
    return this.documentService.getPreviewUrl(this.document.id)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTagsLimited$() {
 | 
			
		||||
    return this.document.tags$.pipe(
 | 
			
		||||
      map(tags => {
 | 
			
		||||
        if (tags.length > 7) {
 | 
			
		||||
          this.moreTags = tags.length - 6
 | 
			
		||||
          return tags.slice(0, 6)
 | 
			
		||||
        } else {
 | 
			
		||||
          return tags
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,19 @@
 | 
			
		||||
<app-page-header [title]="getTitle()">
 | 
			
		||||
 | 
			
		||||
  <div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode"
 | 
			
		||||
  <div ngbDropdown class="mr-2 flex-fill d-flex">
 | 
			
		||||
    <button class="btn btn-sm btn-outline-primary flex-fill" id="dropdownSelect" ngbDropdownToggle>
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#text-indent-left" />
 | 
			
		||||
      </svg> <ng-container i18n>Select</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
 | 
			
		||||
      <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
 | 
			
		||||
      <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
 | 
			
		||||
      <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="btn-group btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="displayMode"
 | 
			
		||||
    (ngModelChange)="saveDisplayMode()">
 | 
			
		||||
    <label ngbButtonLabel class="btn-outline-primary btn-sm">
 | 
			
		||||
      <input ngbButton type="radio" class="btn btn-sm" value="details">
 | 
			
		||||
@@ -21,110 +34,141 @@
 | 
			
		||||
      </svg>
 | 
			
		||||
    </label>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortDirection">
 | 
			
		||||
    <div ngbDropdown class="btn-group">
 | 
			
		||||
      <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>Sort by</button>
 | 
			
		||||
      <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
			
		||||
 | 
			
		||||
  <div ngbDropdown class="btn-group ml-2 flex-fill">
 | 
			
		||||
    <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
 | 
			
		||||
    <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
 | 
			
		||||
      <div class="w-100 d-flex btn-group-toggle pb-2 mb-1 border-bottom" ngbRadioGroup [(ngModel)]="list.sortReverse">
 | 
			
		||||
        <label ngbButtonLabel class="btn-outline-primary btn-sm mx-2 flex-fill">
 | 
			
		||||
          <input ngbButton type="radio" class="btn btn-sm" [value]="false">
 | 
			
		||||
          <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" />
 | 
			
		||||
          </svg>
 | 
			
		||||
        </label>
 | 
			
		||||
        <label ngbButtonLabel class="btn-outline-primary btn-sm mr-2 flex-fill">
 | 
			
		||||
          <input ngbButton type="radio" class="btn btn-sm" [value]="true">
 | 
			
		||||
          <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
            <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" />
 | 
			
		||||
          </svg>
 | 
			
		||||
        </label>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div>
 | 
			
		||||
        <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field"
 | 
			
		||||
          [class.active]="list.sortField == f.field">{{f.name}}</button>
 | 
			
		||||
          [class.active]="list.sortField == f.field">{{f.name}}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <label ngbButtonLabel class="btn-outline-primary btn-sm">
 | 
			
		||||
      <input ngbButton type="radio" class="btn btn-sm" value="asc">
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" />
 | 
			
		||||
      </svg>
 | 
			
		||||
    </label>
 | 
			
		||||
    <label ngbButtonLabel class="btn-outline-primary btn-sm">
 | 
			
		||||
      <input ngbButton type="radio" class="btn btn-sm" value="des">
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" />
 | 
			
		||||
      </svg>
 | 
			
		||||
    </label>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="btn-group ml-2">
 | 
			
		||||
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary" (click)="showFilter=!showFilter">
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#funnel" />
 | 
			
		||||
      </svg>
 | 
			
		||||
      Filter
 | 
			
		||||
    </button>
 | 
			
		||||
  <div class="btn-group ml-2 flex-fill" ngbDropdown role="group">
 | 
			
		||||
    <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" ngbDropdownToggle i18n>Views</button>
 | 
			
		||||
    <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
 | 
			
		||||
      <ng-container *ngIf="!list.activeSavedViewId">
 | 
			
		||||
        <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button>
 | 
			
		||||
        <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
 | 
			
		||||
      </ng-container>
 | 
			
		||||
 | 
			
		||||
    <div class="btn-group" ngbDropdown role="group">
 | 
			
		||||
      <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
 | 
			
		||||
      <div class="dropdown-menu" ngbDropdownMenu>
 | 
			
		||||
        <ng-container *ngIf="!list.savedViewId" >
 | 
			
		||||
          <button ngbDropdownItem *ngFor="let config of savedViewConfigService.getConfigs()" (click)="loadViewConfig(config)">{{config.title}}</button>
 | 
			
		||||
          <div class="dropdown-divider" *ngIf="savedViewConfigService.getConfigs().length > 0"></div>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
        
 | 
			
		||||
        <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button>
 | 
			
		||||
        <button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" i18n>Save "{{list.activeSavedViewTitle}}"</button>
 | 
			
		||||
      <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</app-page-header>
 | 
			
		||||
 | 
			
		||||
<div class="card w-100 mb-3" [hidden]="!showFilter">
 | 
			
		||||
  <div class="card-body">
 | 
			
		||||
    <h5 class="card-title">Filter</h5>
 | 
			
		||||
    <app-filter-editor [(filterRules)]="filterRules" (apply)="applyFilterRules()"></app-filter-editor>
 | 
			
		||||
  </div>
 | 
			
		||||
<div class="w-100 mb-2 mb-sm-4">
 | 
			
		||||
  <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [rulesModified]="filterRulesModified" (filterRulesChange)="rulesChanged()" (reset)="resetFilters()" #filterEditor></app-filter-editor>
 | 
			
		||||
  <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="row m-0 justify-content-end">
 | 
			
		||||
<div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
  <p>
 | 
			
		||||
    <span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
 | 
			
		||||
    <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span>
 | 
			
		||||
  </p>
 | 
			
		||||
  <ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
 | 
			
		||||
  [rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination>
 | 
			
		||||
  [rotate]="true" aria-label="Default pagination"></ngb-pagination>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div *ngIf="displayMode == 'largeCards'">
 | 
			
		||||
  <app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)">
 | 
			
		||||
  <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)">
 | 
			
		||||
  </app-document-card-large>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<table class="table table-sm border shadow" *ngIf="displayMode == 'details'">
 | 
			
		||||
<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
 | 
			
		||||
  <thead>
 | 
			
		||||
    <th class="d-none d-lg-table-cell">ASN</th>
 | 
			
		||||
    <th class="d-none d-md-table-cell">Correspondent</th>
 | 
			
		||||
    <th>Title</th>
 | 
			
		||||
    <th class="d-none d-xl-table-cell">Document type</th>
 | 
			
		||||
    <th>Created</th>
 | 
			
		||||
    <th class="d-none d-xl-table-cell">Added</th>
 | 
			
		||||
    <th></th>
 | 
			
		||||
    <th class="d-none d-lg-table-cell"
 | 
			
		||||
      sortable="archive_serial_number"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>ASN</th>
 | 
			
		||||
    <th class="d-none d-md-table-cell"
 | 
			
		||||
      sortable="correspondent__name"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>Correspondent</th>
 | 
			
		||||
    <th
 | 
			
		||||
      sortable="title"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>Title</th>
 | 
			
		||||
    <th class="d-none d-xl-table-cell"
 | 
			
		||||
      sortable="document_type__name"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>Document type</th>
 | 
			
		||||
    <th
 | 
			
		||||
      sortable="created"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>Created</th>
 | 
			
		||||
    <th class="d-none d-xl-table-cell"
 | 
			
		||||
      sortable="added"
 | 
			
		||||
      [currentSortField]="list.sortField"
 | 
			
		||||
      [currentSortReverse]="list.sortReverse"
 | 
			
		||||
      (sort)="onSort($event)"
 | 
			
		||||
      i18n>Added</th>
 | 
			
		||||
  </thead>
 | 
			
		||||
  <tbody>
 | 
			
		||||
    <tr *ngFor="let d of list.documents">
 | 
			
		||||
    <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
 | 
			
		||||
      <td>
 | 
			
		||||
        <div class="custom-control custom-checkbox">
 | 
			
		||||
          <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)">
 | 
			
		||||
          <label class="custom-control-label" for="docCheck{{d.id}}"></label>
 | 
			
		||||
        </div>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="d-none d-lg-table-cell">
 | 
			
		||||
        {{d.archive_serial_number}}
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="d-none d-md-table-cell">
 | 
			
		||||
        <ng-container *ngIf="d.correspondent">
 | 
			
		||||
          <a [routerLink]="" (click)="filterByCorrespondent(d.correspondent)" title="Filter by correspondent">{{d.correspondent.name}}</a>
 | 
			
		||||
          <a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td>
 | 
			
		||||
        <a routerLink="/documents/{{d.id}}" title="Edit document">{{d.title}}</a>
 | 
			
		||||
        <app-tag [tag]="t" *ngFor="let t of d.tags" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="filterByTag(t)"></app-tag>
 | 
			
		||||
        <a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
 | 
			
		||||
        <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="d-none d-xl-table-cell">
 | 
			
		||||
        <ng-container *ngIf="d.document_type">
 | 
			
		||||
          <a [routerLink]="" (click)="filterByDocumentType(d.document_type)" title="Filter by document type">{{d.document_type.name}}</a>
 | 
			
		||||
          <a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
 | 
			
		||||
        </ng-container>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td>
 | 
			
		||||
        {{d.created | date}}
 | 
			
		||||
        {{d.created | customDate}}
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="d-none d-xl-table-cell">
 | 
			
		||||
        {{d.added | date}}
 | 
			
		||||
        {{d.added | customDate}}
 | 
			
		||||
      </td>
 | 
			
		||||
    </tr>
 | 
			
		||||
  </tbody>
 | 
			
		||||
</table>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<div class=" m-n2 row" *ngIf="displayMode == 'smallCards'">
 | 
			
		||||
  <app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="filterByTag($event)" (clickCorrespondent)="filterByCorrespondent($event)"></app-document-card-small>    
 | 
			
		||||
<div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'">
 | 
			
		||||
  <app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<p *ngIf="list.documents.length == 0" class="mx-auto">No results</p>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,36 @@
 | 
			
		||||
@import "/src/theme";
 | 
			
		||||
 | 
			
		||||
tr {
 | 
			
		||||
  user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.table-row-selected {
 | 
			
		||||
  background-color: $primaryFaded;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$paperless-card-breakpoints: (
 | 
			
		||||
  0: 2, // xs
 | 
			
		||||
  768px: 3, //md
 | 
			
		||||
  992px: 4, //lg
 | 
			
		||||
  1200px: 5, //xl
 | 
			
		||||
  1400px: 6, // xxl
 | 
			
		||||
  1600px: 7,
 | 
			
		||||
  1800px: 8,
 | 
			
		||||
  2000px: 9
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
.row-cols-paperless-cards {
 | 
			
		||||
  @each $width, $n_cols in $paperless-card-breakpoints {
 | 
			
		||||
    @media(min-width: $width) {
 | 
			
		||||
      > * {
 | 
			
		||||
        flex: 0 0 auto;
 | 
			
		||||
        width: 100% / $n_cols;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-menu-right {
 | 
			
		||||
  right: 0 !important;
 | 
			
		||||
  left: auto !important;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,16 @@
 | 
			
		||||
import { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { ActivatedRoute } from '@angular/router';
 | 
			
		||||
import { Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
 | 
			
		||||
import { ActivatedRoute, Router } from '@angular/router';
 | 
			
		||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { cloneFilterRules, FilterRule } from 'src/app/data/filter-rule';
 | 
			
		||||
import { FILTER_CORRESPONDENT, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
 | 
			
		||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
 | 
			
		||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { SavedViewConfig } from 'src/app/data/saved-view-config';
 | 
			
		||||
import { Subscription } from 'rxjs';
 | 
			
		||||
import { PaperlessDocument } from 'src/app/data/paperless-document';
 | 
			
		||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
 | 
			
		||||
import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive';
 | 
			
		||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service';
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
 | 
			
		||||
import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
 | 
			
		||||
import { SavedViewConfigService } from 'src/app/services/saved-view-config.service';
 | 
			
		||||
import { Toast, ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
import { FilterEditorComponent } from './filter-editor/filter-editor.component';
 | 
			
		||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -18,28 +18,49 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
 | 
			
		||||
  templateUrl: './document-list.component.html',
 | 
			
		||||
  styleUrls: ['./document-list.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class DocumentListComponent implements OnInit {
 | 
			
		||||
export class DocumentListComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    public list: DocumentListViewService,
 | 
			
		||||
    public savedViewConfigService: SavedViewConfigService,
 | 
			
		||||
    public savedViewService: SavedViewService,
 | 
			
		||||
    public route: ActivatedRoute,
 | 
			
		||||
    private router: Router,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    public modalService: NgbModal) { }
 | 
			
		||||
    private modalService: NgbModal,
 | 
			
		||||
    private consumerStatusService: ConsumerStatusService
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  @ViewChild("filterEditor")
 | 
			
		||||
  private filterEditor: FilterEditorComponent
 | 
			
		||||
 | 
			
		||||
  @ViewChildren(SortableDirective) headers: QueryList<SortableDirective>;
 | 
			
		||||
 | 
			
		||||
  displayMode = 'smallCards' // largeCards, smallCards, details
 | 
			
		||||
 | 
			
		||||
  filterRules: FilterRule[] = []
 | 
			
		||||
  showFilter = false
 | 
			
		||||
  filterRulesModified: boolean = false
 | 
			
		||||
 | 
			
		||||
  private consumptionFinishedSubscription: Subscription
 | 
			
		||||
 | 
			
		||||
  get isFiltered() {
 | 
			
		||||
    return this.list.filterRules?.length > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getTitle() {
 | 
			
		||||
    return this.list.savedViewTitle || "Documents"
 | 
			
		||||
    return this.list.activeSavedViewTitle || $localize`Documents`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSortFields() {
 | 
			
		||||
    return DOCUMENT_SORT_FIELDS
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onSort(event: SortEvent) {
 | 
			
		||||
    this.list.setSort(event.column, event.reverse)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get isBulkEditing(): boolean {
 | 
			
		||||
    return this.list.selected.size > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  saveDisplayMode() {
 | 
			
		||||
    localStorage.setItem('document-list:displayMode', this.displayMode)
 | 
			
		||||
  }
 | 
			
		||||
@@ -48,86 +69,145 @@ export class DocumentListComponent implements OnInit {
 | 
			
		||||
    if (localStorage.getItem('document-list:displayMode') != null) {
 | 
			
		||||
      this.displayMode = localStorage.getItem('document-list:displayMode')
 | 
			
		||||
    }
 | 
			
		||||
    this.consumptionFinishedSubscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(() => {
 | 
			
		||||
      this.list.reload()
 | 
			
		||||
    })
 | 
			
		||||
    this.route.paramMap.subscribe(params => {
 | 
			
		||||
      if (params.has('id')) {
 | 
			
		||||
        this.list.savedView = this.savedViewConfigService.getConfig(params.get('id'))
 | 
			
		||||
        this.savedViewService.getCached(+params.get('id')).subscribe(view => {
 | 
			
		||||
          if (!view) {
 | 
			
		||||
            this.router.navigate(["404"])
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
          this.list.activateSavedView(view)
 | 
			
		||||
          this.list.reload()
 | 
			
		||||
          this.rulesChanged()
 | 
			
		||||
        })
 | 
			
		||||
      } else {
 | 
			
		||||
        this.list.savedView = null
 | 
			
		||||
        this.list.activateSavedView(null)
 | 
			
		||||
        this.list.reload()
 | 
			
		||||
        this.rulesChanged()
 | 
			
		||||
      }
 | 
			
		||||
      this.filterRules = this.list.filterRules
 | 
			
		||||
      this.showFilter = this.filterRules.length > 0
 | 
			
		||||
      // prevents temporarily visible results from previous views
 | 
			
		||||
      this.list.documents = []
 | 
			
		||||
      this.list.reload()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  applyFilterRules() {
 | 
			
		||||
    this.list.filterRules = this.filterRules
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    if (this.consumptionFinishedSubscription) {
 | 
			
		||||
      this.consumptionFinishedSubscription.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  loadViewConfig(config: SavedViewConfig) {
 | 
			
		||||
    this.filterRules = cloneFilterRules(config.filterRules)
 | 
			
		||||
    this.list.load(config)
 | 
			
		||||
  loadViewConfig(view: PaperlessSavedView) {
 | 
			
		||||
    this.list.loadSavedView(view)
 | 
			
		||||
    this.list.reload()
 | 
			
		||||
    this.rulesChanged()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  saveViewConfig() {
 | 
			
		||||
    this.savedViewConfigService.updateConfig(this.list.savedView)
 | 
			
		||||
    this.toastService.showToast(Toast.make("Information", `View "${this.list.savedView.title}" saved successfully.`))
 | 
			
		||||
    if (this.list.activeSavedViewId != null) {
 | 
			
		||||
      let savedView: PaperlessSavedView = {
 | 
			
		||||
        id: this.list.activeSavedViewId,
 | 
			
		||||
        filter_rules: this.list.filterRules,
 | 
			
		||||
        sort_field: this.list.sortField,
 | 
			
		||||
        sort_reverse: this.list.sortReverse
 | 
			
		||||
      }
 | 
			
		||||
      this.savedViewService.patch(savedView).subscribe(result => {
 | 
			
		||||
        this.toastService.showInfo($localize`View "${this.list.activeSavedViewTitle}" saved successfully.`)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  saveViewConfigAs() {
 | 
			
		||||
    let modal = this.modalService.open(SaveViewConfigDialogComponent, {backdrop: 'static'})
 | 
			
		||||
    modal.componentInstance.defaultName = this.filterEditor.generateFilterName()
 | 
			
		||||
    modal.componentInstance.saveClicked.subscribe(formValue => {
 | 
			
		||||
      this.savedViewConfigService.newConfig({
 | 
			
		||||
        title: formValue.title,
 | 
			
		||||
        showInDashboard: formValue.showInDashboard,
 | 
			
		||||
        showInSideBar: formValue.showInSideBar,
 | 
			
		||||
        filterRules: this.list.filterRules,
 | 
			
		||||
        sortDirection: this.list.sortDirection,
 | 
			
		||||
        sortField: this.list.sortField
 | 
			
		||||
      modal.componentInstance.buttonsEnabled = false
 | 
			
		||||
      let savedView: PaperlessSavedView = {
 | 
			
		||||
        name: formValue.name,
 | 
			
		||||
        show_on_dashboard: formValue.showOnDashboard,
 | 
			
		||||
        show_in_sidebar: formValue.showInSideBar,
 | 
			
		||||
        filter_rules: this.list.filterRules,
 | 
			
		||||
        sort_reverse: this.list.sortReverse,
 | 
			
		||||
        sort_field: this.list.sortField
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.savedViewService.create(savedView).subscribe(() => {
 | 
			
		||||
        modal.close()
 | 
			
		||||
        this.toastService.showInfo($localize`View "${savedView.name}" created successfully.`)
 | 
			
		||||
      }, error => {
 | 
			
		||||
        modal.componentInstance.error = error.error
 | 
			
		||||
        modal.componentInstance.buttonsEnabled = true
 | 
			
		||||
      })
 | 
			
		||||
      modal.close()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  filterByTag(t: PaperlessTag) {
 | 
			
		||||
    let filterRules = this.list.filterRules
 | 
			
		||||
    if (filterRules.find(rule => rule.type.id == FILTER_HAS_TAG && rule.value == t.id)) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_HAS_TAG), value: t.id})
 | 
			
		||||
    this.filterRules = filterRules
 | 
			
		||||
    this.applyFilterRules()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  filterByCorrespondent(c: PaperlessCorrespondent) {
 | 
			
		||||
    let filterRules = this.list.filterRules
 | 
			
		||||
    let existing_rule = filterRules.find(rule => rule.type.id == FILTER_CORRESPONDENT)
 | 
			
		||||
    if (existing_rule && existing_rule.value == c.id) {
 | 
			
		||||
      return
 | 
			
		||||
    } else if (existing_rule) {
 | 
			
		||||
      existing_rule.value = c.id
 | 
			
		||||
  resetFilters(): void {
 | 
			
		||||
    this.filterRulesModified = false
 | 
			
		||||
    if (this.list.activeSavedViewId) {
 | 
			
		||||
      this.savedViewService.getCached(this.list.activeSavedViewId).subscribe(viewUntouched => {
 | 
			
		||||
        this.list.filterRules = viewUntouched.filter_rules
 | 
			
		||||
        this.list.reload()
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_CORRESPONDENT), value: c.id})
 | 
			
		||||
      this.list.filterRules = []
 | 
			
		||||
      this.list.reload()
 | 
			
		||||
    }
 | 
			
		||||
    this.filterRules = filterRules
 | 
			
		||||
    this.applyFilterRules()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  filterByDocumentType(dt: PaperlessDocumentType) {
 | 
			
		||||
    let filterRules = this.list.filterRules
 | 
			
		||||
    let existing_rule = filterRules.find(rule => rule.type.id == FILTER_DOCUMENT_TYPE)
 | 
			
		||||
    if (existing_rule && existing_rule.value == dt.id) {
 | 
			
		||||
      return
 | 
			
		||||
    } else if (existing_rule) {
 | 
			
		||||
      existing_rule.value = dt.id
 | 
			
		||||
  rulesChanged() {
 | 
			
		||||
    let modified = false
 | 
			
		||||
    if (this.list.activeSavedViewId == null) {
 | 
			
		||||
      modified = this.list.filterRules.length > 0 // documents list is modified if it has any filters
 | 
			
		||||
    } else {
 | 
			
		||||
      filterRules.push({type: FILTER_RULE_TYPES.find(t => t.id == FILTER_DOCUMENT_TYPE), value: dt.id})
 | 
			
		||||
      // compare savedView current filters vs original
 | 
			
		||||
      this.savedViewService.getCached(this.list.activeSavedViewId).subscribe(view => {
 | 
			
		||||
        let filterRulesInitial = view.filter_rules
 | 
			
		||||
 | 
			
		||||
        if (this.list.filterRules.length !== filterRulesInitial.length) modified = true
 | 
			
		||||
        else {
 | 
			
		||||
          modified = this.list.filterRules.some(rule => {
 | 
			
		||||
            return (filterRulesInitial.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined)
 | 
			
		||||
          })
 | 
			
		||||
 | 
			
		||||
          if (!modified) {
 | 
			
		||||
            // only check other direction if we havent already determined is modified
 | 
			
		||||
            modified = filterRulesInitial.some(rule => {
 | 
			
		||||
              this.list.filterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    this.filterRules = filterRules
 | 
			
		||||
    this.applyFilterRules()
 | 
			
		||||
    this.filterRulesModified = modified
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleSelected(document: PaperlessDocument, event: MouseEvent): void {
 | 
			
		||||
    if (!event.shiftKey) this.list.toggleSelected(document)
 | 
			
		||||
    else this.list.selectRangeTo(document)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clickTag(tagID: number) {
 | 
			
		||||
    this.list.selectNone()
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.filterEditor.toggleTag(tagID)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clickCorrespondent(correspondentID: number) {
 | 
			
		||||
    this.list.selectNone()
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.filterEditor.toggleCorrespondent(correspondentID)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clickDocumentType(documentTypeID: number) {
 | 
			
		||||
    this.list.selectNone()
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.filterEditor.toggleDocumentType(documentTypeID)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  trackByDocumentId(index, item: PaperlessDocument) {
 | 
			
		||||
    return item.id
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,54 @@
 | 
			
		||||
<div class="row">
 | 
			
		||||
   <div class="col mb-2 mb-xl-0">
 | 
			
		||||
     <div class="form-inline d-flex align-items-center">
 | 
			
		||||
         <label class="text-muted mr-2 mb-0" i18n>Filter by:</label>
 | 
			
		||||
         <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="titleFilter" placeholder="Title" i18n-placeholder>
 | 
			
		||||
     </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="w-100 d-xl-none"></div>
 | 
			
		||||
    <div class="col col-xl-auto mb-2 mb-xl-0">
 | 
			
		||||
      <div class="d-flex">
 | 
			
		||||
        <app-filterable-dropdown class="mr-2 flex-fill" title="Tags" icon="tag-fill" i18n-title
 | 
			
		||||
          filterPlaceholder="Filter tags" i18n-filterPlaceholder
 | 
			
		||||
          [items]="tags"
 | 
			
		||||
          [(selectionModel)]="tagSelectionModel"
 | 
			
		||||
          (selectionModelChange)="updateRules()"
 | 
			
		||||
          [multiple]="true"
 | 
			
		||||
          (open)="onTagsDropdownOpen()"
 | 
			
		||||
          [allowSelectNone]="true"></app-filterable-dropdown>
 | 
			
		||||
        <app-filterable-dropdown class="mr-2 flex-fill" title="Correspondent" icon="person-fill" i18n-title
 | 
			
		||||
          filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
 | 
			
		||||
          [items]="correspondents"
 | 
			
		||||
          [(selectionModel)]="correspondentSelectionModel"
 | 
			
		||||
          (selectionModelChange)="updateRules()"
 | 
			
		||||
          (open)="onCorrespondentDropdownOpen()"
 | 
			
		||||
          [allowSelectNone]="true"></app-filterable-dropdown>
 | 
			
		||||
        <app-filterable-dropdown class="mr-2 flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
 | 
			
		||||
          filterPlaceholder="Filter document types" i18n-filterPlaceholder
 | 
			
		||||
          [items]="documentTypes"
 | 
			
		||||
          [(selectionModel)]="documentTypeSelectionModel"
 | 
			
		||||
          (open)="onDocumentTypeDropdownOpen()"
 | 
			
		||||
          (selectionModelChange)="updateRules()"
 | 
			
		||||
          [allowSelectNone]="true"></app-filterable-dropdown>
 | 
			
		||||
        <app-date-dropdown class="mr-2"
 | 
			
		||||
          title="Created" i18n-title
 | 
			
		||||
          (datesSet)="updateRules()"
 | 
			
		||||
          [(dateBefore)]="dateCreatedBefore"
 | 
			
		||||
          [(dateAfter)]="dateCreatedAfter"></app-date-dropdown>
 | 
			
		||||
        <app-date-dropdown
 | 
			
		||||
          [(dateBefore)]="dateAddedBefore"
 | 
			
		||||
          [(dateAfter)]="dateAddedAfter"
 | 
			
		||||
          title="Added" i18n-title
 | 
			
		||||
          (datesSet)="updateRules()"></app-date-dropdown>
 | 
			
		||||
     </div>
 | 
			
		||||
   </div>
 | 
			
		||||
   <div class="w-100 d-xl-none"></div>
 | 
			
		||||
   <div class="col col-xl-auto mb-2 mb-xl-0">
 | 
			
		||||
     <button class="btn btn-link btn-sm px-0 mx-0 ml-xl-n4" [disabled]="!rulesModified" (click)="resetSelected()">
 | 
			
		||||
       <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
         <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
 | 
			
		||||
       </svg> <ng-container i18n>Reset filters</ng-container>
 | 
			
		||||
 | 
			
		||||
     </button>
 | 
			
		||||
   </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
.quick-filter {
 | 
			
		||||
  min-width: 250px;
 | 
			
		||||
  max-height: 400px;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
 | 
			
		||||
  .selected-icon {
 | 
			
		||||
    min-width: 1em;
 | 
			
		||||
    min-height: 1em;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,226 @@
 | 
			
		||||
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
 | 
			
		||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
 | 
			
		||||
import { Subject, Subscription } from 'rxjs';
 | 
			
		||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
 | 
			
		||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
 | 
			
		||||
import { TagService } from 'src/app/services/rest/tag.service';
 | 
			
		||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
 | 
			
		||||
import { FilterRule } from 'src/app/data/filter-rule';
 | 
			
		||||
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE } from 'src/app/data/filter-rule-type';
 | 
			
		||||
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
 | 
			
		||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-filter-editor',
 | 
			
		||||
  templateUrl: './filter-editor.component.html',
 | 
			
		||||
  styleUrls: ['./filter-editor.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class FilterEditorComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  generateFilterName() {
 | 
			
		||||
    if (this.filterRules.length == 1) {
 | 
			
		||||
      let rule = this.filterRules[0]
 | 
			
		||||
      switch(this.filterRules[0].rule_type) {
 | 
			
		||||
 | 
			
		||||
        case FILTER_CORRESPONDENT:
 | 
			
		||||
          if (rule.value) {
 | 
			
		||||
            return $localize`Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}`
 | 
			
		||||
          } else {
 | 
			
		||||
            return $localize`Without correspondent`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        case FILTER_DOCUMENT_TYPE:
 | 
			
		||||
          if (rule.value) {
 | 
			
		||||
            return $localize`Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}`
 | 
			
		||||
          } else {
 | 
			
		||||
            return $localize`Without document type`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        case FILTER_HAS_TAG:
 | 
			
		||||
          return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}`
 | 
			
		||||
 | 
			
		||||
        case FILTER_HAS_ANY_TAG:
 | 
			
		||||
          if (rule.value == "false") {
 | 
			
		||||
            return $localize`Without any tag`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        case FILTER_TITLE:
 | 
			
		||||
          return $localize`Title: ${rule.value}`
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return ""
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private documentTypeService: DocumentTypeService,
 | 
			
		||||
    private tagService: TagService,
 | 
			
		||||
    private correspondentService: CorrespondentService
 | 
			
		||||
  ) { }
 | 
			
		||||
 | 
			
		||||
  tags: PaperlessTag[] = []
 | 
			
		||||
  correspondents: PaperlessCorrespondent[] = []
 | 
			
		||||
  documentTypes: PaperlessDocumentType[] = []
 | 
			
		||||
 | 
			
		||||
  _titleFilter = ""
 | 
			
		||||
 | 
			
		||||
  tagSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  correspondentSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  documentTypeSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
 | 
			
		||||
  dateCreatedBefore: string
 | 
			
		||||
  dateCreatedAfter: string
 | 
			
		||||
  dateAddedBefore: string
 | 
			
		||||
  dateAddedAfter: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set filterRules (value: FilterRule[]) {
 | 
			
		||||
    this.documentTypeSelectionModel.clear(false)
 | 
			
		||||
    this.tagSelectionModel.clear(false)
 | 
			
		||||
    this.correspondentSelectionModel.clear(false)
 | 
			
		||||
    this._titleFilter = null
 | 
			
		||||
    this.dateAddedBefore = null
 | 
			
		||||
    this.dateAddedAfter = null
 | 
			
		||||
    this.dateCreatedBefore = null
 | 
			
		||||
    this.dateCreatedAfter = null
 | 
			
		||||
 | 
			
		||||
    value.forEach(rule => {
 | 
			
		||||
      switch (rule.rule_type) {
 | 
			
		||||
        case FILTER_TITLE:
 | 
			
		||||
          this._titleFilter = rule.value
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_CREATED_AFTER:
 | 
			
		||||
          this.dateCreatedAfter = rule.value
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_CREATED_BEFORE:
 | 
			
		||||
          this.dateCreatedBefore = rule.value
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_ADDED_AFTER:
 | 
			
		||||
          this.dateAddedAfter = rule.value
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_ADDED_BEFORE:
 | 
			
		||||
          this.dateAddedBefore = rule.value
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_HAS_TAG:
 | 
			
		||||
          this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false)
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_HAS_ANY_TAG:
 | 
			
		||||
          this.tagSelectionModel.set(null, ToggleableItemState.Selected, false)
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_CORRESPONDENT:
 | 
			
		||||
          this.correspondentSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false)
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_DOCUMENT_TYPE:
 | 
			
		||||
          this.documentTypeSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false)
 | 
			
		||||
          break
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get filterRules(): FilterRule[] {
 | 
			
		||||
    let filterRules: FilterRule[] = []
 | 
			
		||||
    if (this._titleFilter) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_TITLE, value: this._titleFilter})
 | 
			
		||||
    }
 | 
			
		||||
    if (this.tagSelectionModel.isNoneSelected()) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"})
 | 
			
		||||
    } else {
 | 
			
		||||
      this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => {
 | 
			
		||||
        filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id?.toString()})
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
    this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_CORRESPONDENT, value: correspondent.id?.toString()})
 | 
			
		||||
    })
 | 
			
		||||
    this.documentTypeSelectionModel.getSelectedItems().forEach(documentType => {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_DOCUMENT_TYPE, value: documentType.id?.toString()})
 | 
			
		||||
    })
 | 
			
		||||
    if (this.dateCreatedBefore) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_CREATED_BEFORE, value: this.dateCreatedBefore})
 | 
			
		||||
    }
 | 
			
		||||
    if (this.dateCreatedAfter) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_CREATED_AFTER, value: this.dateCreatedAfter})
 | 
			
		||||
    }
 | 
			
		||||
    if (this.dateAddedBefore) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_ADDED_BEFORE, value: this.dateAddedBefore})
 | 
			
		||||
    }
 | 
			
		||||
    if (this.dateAddedAfter) {
 | 
			
		||||
      filterRules.push({rule_type: FILTER_ADDED_AFTER, value: this.dateAddedAfter})
 | 
			
		||||
    }
 | 
			
		||||
    return filterRules
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  filterRulesChange = new EventEmitter<FilterRule[]>()
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  reset = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  rulesModified: boolean = false
 | 
			
		||||
 | 
			
		||||
  updateRules() {
 | 
			
		||||
    this.filterRulesChange.next(this.filterRules)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get titleFilter() {
 | 
			
		||||
    return this._titleFilter
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set titleFilter(value) {
 | 
			
		||||
    this.titleFilterDebounce.next(value)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  titleFilterDebounce: Subject<string>
 | 
			
		||||
  subscription: Subscription
 | 
			
		||||
 | 
			
		||||
  ngOnInit() {
 | 
			
		||||
    this.tagService.listAll().subscribe(result => this.tags = result.results)
 | 
			
		||||
    this.correspondentService.listAll().subscribe(result => this.correspondents = result.results)
 | 
			
		||||
    this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
 | 
			
		||||
 | 
			
		||||
    this.titleFilterDebounce = new Subject<string>()
 | 
			
		||||
 | 
			
		||||
    this.subscription = this.titleFilterDebounce.pipe(
 | 
			
		||||
      debounceTime(400),
 | 
			
		||||
      distinctUntilChanged()
 | 
			
		||||
    ).subscribe(title => {
 | 
			
		||||
      this._titleFilter = title
 | 
			
		||||
      this.updateRules()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    this.titleFilterDebounce.complete()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  resetSelected() {
 | 
			
		||||
    this.reset.next()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleTag(tagId: number) {
 | 
			
		||||
    this.tagSelectionModel.toggle(tagId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleCorrespondent(correspondentId: number) {
 | 
			
		||||
    this.correspondentSelectionModel.toggle(correspondentId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleDocumentType(documentTypeId: number) {
 | 
			
		||||
    this.documentTypeSelectionModel.toggle(documentTypeId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onTagsDropdownOpen() {
 | 
			
		||||
    this.tagSelectionModel.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onCorrespondentDropdownOpen() {
 | 
			
		||||
    this.correspondentSelectionModel.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onDocumentTypeDropdownOpen() {
 | 
			
		||||
    this.documentTypeSelectionModel.apply()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,17 +1,17 @@
 | 
			
		||||
<form [formGroup]="saveViewConfigForm" class="needs-validation" novalidate (ngSubmit)="save()">
 | 
			
		||||
<form [formGroup]="saveViewConfigForm" (ngSubmit)="save()">
 | 
			
		||||
  <div class="modal-header">
 | 
			
		||||
    <h4 class="modal-title" id="modal-basic-title">Save current view</h4>
 | 
			
		||||
    <button type="button" class="close" aria-label="Close" (click)="cancel()">
 | 
			
		||||
    <h4 class="modal-title" id="modal-basic-title" i18n>Save current view</h4>
 | 
			
		||||
    <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()">
 | 
			
		||||
      <span aria-hidden="true">×</span>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="modal-body">
 | 
			
		||||
    <app-input-text title="Title" formControlName="title"></app-input-text>
 | 
			
		||||
    <app-input-check title="Show in side bar" formControlName="showInSideBar"></app-input-check>
 | 
			
		||||
    <app-input-check title="Show in dashboard" formControlName="showInDashboard"></app-input-check>
 | 
			
		||||
    <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
 | 
			
		||||
    <app-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></app-input-check>
 | 
			
		||||
    <app-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></app-input-check>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="modal-footer">
 | 
			
		||||
    <button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
 | 
			
		||||
    <button type="submit" class="btn btn-primary">Save</button>
 | 
			
		||||
    <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="!buttonsEnabled">Cancel</button>
 | 
			
		||||
    <button type="submit" class="btn btn-primary" i18n [disabled]="!buttonsEnabled">Save</button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { FormControl, FormGroup } from '@angular/forms';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
 | 
			
		||||
@@ -14,13 +14,37 @@ export class SaveViewConfigDialogComponent implements OnInit {
 | 
			
		||||
  @Output()
 | 
			
		||||
  public saveClicked = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  error
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  buttonsEnabled = true
 | 
			
		||||
 | 
			
		||||
  closeEnabled = false
 | 
			
		||||
 | 
			
		||||
  _defaultName = ""
 | 
			
		||||
 | 
			
		||||
  get defaultName() {
 | 
			
		||||
    return this._defaultName
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set defaultName(value: string) {
 | 
			
		||||
    this._defaultName = value
 | 
			
		||||
    this.saveViewConfigForm.patchValue({name: value})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  saveViewConfigForm = new FormGroup({
 | 
			
		||||
    title: new FormControl(''),
 | 
			
		||||
    name: new FormControl(''),
 | 
			
		||||
    showInSideBar: new FormControl(false),
 | 
			
		||||
    showInDashboard: new FormControl(false),
 | 
			
		||||
    showOnDashboard: new FormControl(false),
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    // wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.closeEnabled = true
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  save() {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,52 +0,0 @@
 | 
			
		||||
<div *ngFor="let rule of filterRules" class="form-row form-group">
 | 
			
		||||
  <div class="col-md-3 col-form-label">
 | 
			
		||||
    <span>{{rule.type.name}}</span>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col">
 | 
			
		||||
    <input *ngIf="rule.type.datatype == 'string'" type="text" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
    <input *ngIf="rule.type.datatype == 'number'" type="number" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
    <input *ngIf="rule.type.datatype == 'date'" type="date" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
 | 
			
		||||
    <select *ngIf="rule.type.datatype == 'tag'" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
      <option *ngFor="let t of tags" [ngValue]="t.id">{{t.name}}</option>
 | 
			
		||||
    </select>
 | 
			
		||||
 | 
			
		||||
    <select *ngIf="rule.type.datatype == 'document_type'" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
      <option *ngFor="let dt of documentTypes" [ngValue]="dt.id">{{dt.name}}</option>
 | 
			
		||||
    </select>
 | 
			
		||||
 | 
			
		||||
    <select *ngIf="rule.type.datatype == 'correspondent'" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
      <option *ngFor="let c of correspondents" [ngValue]="c.id">{{c.name}}</option>
 | 
			
		||||
    </select>
 | 
			
		||||
 | 
			
		||||
    <select *ngIf="rule.type.datatype == 'boolean'" class="form-control form-control-sm" [(ngModel)]="rule.value">
 | 
			
		||||
      <option [ngValue]="true">Yes</option>
 | 
			
		||||
      <option [ngValue]="false">No</option>
 | 
			
		||||
    </select>
 | 
			
		||||
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto">
 | 
			
		||||
    <button class="btn btn-sm btn-outline-secondary" (click)="removeRuleClicked(rule)">
 | 
			
		||||
      <svg class="toolbaricon" fill="currentColor">
 | 
			
		||||
        <use xlink:href="assets/bootstrap-icons.svg#x"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="form-row form-group">
 | 
			
		||||
  <div class="col">
 | 
			
		||||
    <select [(ngModel)]="selectedRuleType" class="form-control form-control-sm">
 | 
			
		||||
      <option *ngFor="let ruleType of getRuleTypes()" [ngValue]="ruleType">{{ruleType.name}}</option>
 | 
			
		||||
    </select>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto">
 | 
			
		||||
    <button (click)="newRuleClicked()" class="btn btn-sm btn-outline-secondary">Add</button>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto">
 | 
			
		||||
    <button (click)="clearClicked()" class="btn btn-sm btn-outline-secondary">Clear</button>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-auto">
 | 
			
		||||
    <button (click)="applyClicked()" class="btn btn-sm btn-outline-secondary">Apply</button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,64 +0,0 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { FilterRule } from 'src/app/data/filter-rule';
 | 
			
		||||
import { FilterRuleType, FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
 | 
			
		||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
 | 
			
		||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
 | 
			
		||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
 | 
			
		||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
 | 
			
		||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
 | 
			
		||||
import { TagService } from 'src/app/services/rest/tag.service';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-filter-editor',
 | 
			
		||||
  templateUrl: './filter-editor.component.html',
 | 
			
		||||
  styleUrls: ['./filter-editor.component.scss']
 | 
			
		||||
})
 | 
			
		||||
export class FilterEditorComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
  constructor(private documentTypeService: DocumentTypeService, private tagService: TagService, private correspondentService: CorrespondentService) { }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  filterRules: FilterRule[] = []
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  apply = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  selectedRuleType: FilterRuleType = FILTER_RULE_TYPES[0]
 | 
			
		||||
 | 
			
		||||
  correspondents: PaperlessCorrespondent[] = []
 | 
			
		||||
  tags: PaperlessTag[] = []
 | 
			
		||||
  documentTypes: PaperlessDocumentType[] = []
 | 
			
		||||
 | 
			
		||||
  newRuleClicked() {
 | 
			
		||||
    this.filterRules.push({type: this.selectedRuleType, value: null})
 | 
			
		||||
    this.selectedRuleType = this.getRuleTypes().length > 0 ? this.getRuleTypes()[0] : null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeRuleClicked(rule) {
 | 
			
		||||
    let index = this.filterRules.findIndex(r => r == rule)
 | 
			
		||||
    if (index > -1) {
 | 
			
		||||
      this.filterRules.splice(index, 1)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  applyClicked() {
 | 
			
		||||
    this.apply.next()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearClicked() {
 | 
			
		||||
    this.filterRules.splice(0,this.filterRules.length)
 | 
			
		||||
    this.apply.next()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.correspondentService.listAll().subscribe(result => {this.correspondents = result.results})
 | 
			
		||||
    this.tagService.listAll().subscribe(result => this.tags = result.results)
 | 
			
		||||
    this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getRuleTypes() {
 | 
			
		||||
    return FILTER_RULE_TYPES.filter(rt => rt.multi || !this.filterRules.find(r => r.type == rt))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,20 +1,18 @@
 | 
			
		||||
<form [formGroup]="objectForm" class="needs-validation" novalidate (ngSubmit)="save()">
 | 
			
		||||
<form [formGroup]="objectForm" (ngSubmit)="save()">
 | 
			
		||||
  <div class="modal-header">
 | 
			
		||||
    <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
 | 
			
		||||
    <button type="button" class="close" aria-label="Close" (click)="cancel()">
 | 
			
		||||
    <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()">
 | 
			
		||||
      <span aria-hidden="true">×</span>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="modal-body">
 | 
			
		||||
    
 | 
			
		||||
    <app-input-text title="Name" formControlName="name"></app-input-text>
 | 
			
		||||
    <app-input-text title="Match" formControlName="match"></app-input-text>
 | 
			
		||||
    <app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
 | 
			
		||||
    <app-input-check title="Case insensitive" formControlName="is_insensitive"></app-input-check>
 | 
			
		||||
 | 
			
		||||
    <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
 | 
			
		||||
    <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
 | 
			
		||||
    <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
 | 
			
		||||
    <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="modal-footer">
 | 
			
		||||
    <button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
 | 
			
		||||
    <button type="submit" class="btn btn-primary">Save</button>
 | 
			
		||||
    <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
 | 
			
		||||
    <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
</form>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 | 
			
		||||
import { Component } from '@angular/core';
 | 
			
		||||
import { FormControl, FormGroup } from '@angular/forms';
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 | 
			
		||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component';
 | 
			
		||||
@@ -14,7 +14,15 @@ import { ToastService } from 'src/app/services/toast.service';
 | 
			
		||||
export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> {
 | 
			
		||||
 | 
			
		||||
  constructor(service: CorrespondentService, activeModal: NgbActiveModal, toastService: ToastService) {
 | 
			
		||||
    super(service, activeModal, toastService, 'correspondent')
 | 
			
		||||
    super(service, activeModal, toastService)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getCreateTitle() {
 | 
			
		||||
    return $localize`Create new correspondent`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getEditTitle() {
 | 
			
		||||
    return $localize`Edit correspondent`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getForm(): FormGroup {
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user