Updated dashboard

This commit is contained in:
shamoon 2023-09-23 00:10:51 -07:00
parent 96176589ca
commit 787209d1e4
15 changed files with 177 additions and 41 deletions

View File

@ -1,7 +1,9 @@
<div class="row pt-3 pb-3 pb-md-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"> <div class="col-md text-truncate">
<p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p> <h3 class="text-truncate" style="line-height: 1.4">
<p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p> {{title}}
<span *ngIf="subTitle" class="h6 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
</h3>
</div> </div>
<div class="btn-toolbar col col-md-auto"> <div class="btn-toolbar col col-md-auto">
<ng-content></ng-content> <ng-content></ng-content>

View File

@ -25,7 +25,7 @@ describe('PageHeaderComponent', () => {
component.title = 'Foo' component.title = 'Foo'
component.subTitle = 'Bar' component.subTitle = 'Bar'
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('FooBar') expect(fixture.nativeElement.textContent).toContain('Foo Bar')
}) })
it('should set html title', () => { it('should set html title', () => {

View File

@ -1,29 +1,36 @@
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title> <pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
<pngx-logo extra_classes="d-none d-md-block"></pngx-logo> <pngx-logo extra_classes="d-none d-md-block mt-n2 p-1" height="4.5rem"></pngx-logo>
</pngx-page-header> </pngx-page-header>
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col col-md-9 mb-3">
<div tourAnchor="tour.dashboard"> <div class="row row-cols-1 g-4" tourAnchor="tour.dashboard">
<ng-container *ngIf="savedViewService.loading"> <div *ngIf="savedViewService.loading" class="col">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
</div>
<div *ngIf="settingsService.offerTour()" class="col">
<pngx-welcome-widget (dismiss)="completeTour()"></pngx-welcome-widget>
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
<div class="col">
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget>
</div>
</ng-container>
</ng-container> </ng-container>
</div>
<pngx-welcome-widget *ngIf="settingsService.offerTour()" (dismiss)="completeTour()"></pngx-welcome-widget> </div>
<div class="col">
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <div class="row row-cols-1 g-4">
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst"> <div class="col">
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget> <pngx-statistics-widget></pngx-statistics-widget>
</ng-container> </div>
<div class="col">
<pngx-upload-file-widget></pngx-upload-file-widget>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4">
<pngx-statistics-widget></pngx-statistics-widget>
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div> </div>

View File

@ -3,20 +3,28 @@
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a> <a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a>
<table content class="table table-sm table-hover table-borderless mb-0"> <table content class="table table-hover mb-0 align-middle">
<thead> <thead>
<tr> <tr>
<th scope="col" i18n>Created</th> <th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th> <th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
</tr> </tr>
</thead> </thead>
<tbody *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <tbody *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<tr *ngFor="let doc of documents" (mouseleave)="mouseLeaveCard()"> <tr *ngFor="let doc of documents" (mouseleave)="mouseLeaveCard()">
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td> <td class="py-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td class="position-relative"> <td class="py-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag></a> <a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none">{{doc.title | documentTitle}}</a>
</td>
<td class="py-3 d-none d-md-table-cell">
<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
</td>
<td class="position-relative py-3 d-none d-md-table-cell">
<a *ngIf="doc.correspondent !== null" class="btn-link" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
<div class="btn-group position-absolute top-50 end-0 translate-middle-y"> <div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle" <a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle" [ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover"> autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="buttonicon-xs" fill="currentColor"> <svg class="buttonicon-xs" fill="currentColor">
@ -26,7 +34,7 @@
<ng-template #previewContent> <ng-template #previewContent>
<object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object> <object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object>
</ng-template> </ng-template>
<a [href]="getDownloadUrl(doc)" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()"> <a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg class="buttonicon-xs" fill="currentColor"> <svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download"/> <use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg> </svg>

View File

@ -5,9 +5,12 @@ table {
th:first-child { th:first-child {
width: 25%; width: 25%;
@media (min-width: 768px) {
width: 15%;
}
} }
tbody app-tag { tbody pngx-tag {
cursor: pointer; cursor: pointer;
} }
@ -22,3 +25,8 @@ tr:hover .btn-group {
opacity: 1; opacity: 1;
pointer-events: all; pointer-events: all;
} }
td.py-3 {
padding-top: 0.75em !important;
padding-bottom: 0.75em !important;
}

View File

@ -52,6 +52,7 @@ const documentResults = [
{ {
id: 3, id: 3,
title: 'doc3', title: 'doc3',
correspondent: 0,
}, },
] ]

View File

@ -6,18 +6,22 @@ import {
QueryList, QueryList,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
import { Router } from '@angular/router' import { Params, Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { PaperlessDocument } from 'src/app/data/paperless-document' import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import {
FILTER_CORRESPONDENT,
FILTER_HAS_TAGS_ALL,
} from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
@Component({ @Component({
selector: 'pngx-saved-view-widget', selector: 'pngx-saved-view-widget',
@ -38,7 +42,8 @@ export class SavedViewWidgetComponent
private router: Router, private router: Router,
private list: DocumentListViewService, private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService
) { ) {
super() super()
} }
@ -141,4 +146,15 @@ export class SavedViewWidgetComponent
mouseLeaveCard() { mouseLeaveCard() {
this.popover?.close() this.popover?.close()
} }
getCorrespondentQueryParams(correspondentId: number): Params {
return correspondentId !== undefined
? queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: correspondentId.toString(),
},
])
: null
}
} }

View File

@ -42,5 +42,32 @@
</div> </div>
</div> </div>
</div> </div>
<div class="list-group border-light mt-3">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a *ngIf="statistics?.tag_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/tags/">
<ng-container i18n>Tags</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.tag_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a *ngIf="statistics?.correspondent_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/correspondents/">
<ng-container i18n>Correspondents</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.correspondent_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a *ngIf="statistics?.document_type_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Document Types</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.document_type_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a *ngIf="statistics?.storage_path_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Storage Paths</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.storage_path_count | number}}</span>
</a>
</ng-container>
</div>
</ng-container> </ng-container>
</pngx-widget-frame> </pngx-widget-frame>

View File

@ -11,6 +11,7 @@ import { environment } from 'src/environments/environment'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
describe('StatisticsWidgetComponent', () => { describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent let component: StatisticsWidgetComponent
@ -19,7 +20,11 @@ describe('StatisticsWidgetComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [StatisticsWidgetComponent, WidgetFrameComponent], declarations: [
StatisticsWidgetComponent,
WidgetFrameComponent,
IfPermissionsDirective,
],
providers: [PermissionsGuard], providers: [PermissionsGuard],
imports: [ imports: [
HttpClientTestingModule, HttpClientTestingModule,

View File

@ -6,6 +6,7 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import * as mimeTypeNames from 'mime-names' import * as mimeTypeNames from 'mime-names'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
export interface Statistics { export interface Statistics {
documents_total?: number documents_total?: number
@ -13,6 +14,10 @@ export interface Statistics {
inbox_tag?: number inbox_tag?: number
document_file_type_counts?: DocumentFileType[] document_file_type_counts?: DocumentFileType[]
character_count?: number character_count?: number
tag_count?: number
correspondent_count?: number
document_type_count?: number
storage_path_count?: number
} }
interface DocumentFileType { interface DocumentFileType {
@ -25,14 +30,19 @@ interface DocumentFileType {
templateUrl: './statistics-widget.component.html', templateUrl: './statistics-widget.component.html',
styleUrls: ['./statistics-widget.component.scss'], styleUrls: ['./statistics-widget.component.scss'],
}) })
export class StatisticsWidgetComponent implements OnInit, OnDestroy { export class StatisticsWidgetComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
loading: boolean = true loading: boolean = true
constructor( constructor(
private http: HttpClient, private http: HttpClient,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
private documentListViewService: DocumentListViewService private documentListViewService: DocumentListViewService
) {} ) {
super()
}
statistics: Statistics = {} statistics: Statistics = {}

View File

@ -8,12 +8,12 @@
</svg> </svg>
</a> </a>
</div> </div>
<div content tourAnchor="tour.upload-widget"> <div content tourAnchor="tour.upload-widget" class="h-100">
<form> <form class="h-100">
<ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)" <ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card" (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card h-100"
multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true multiple="true" contentClassName="justify-content-center d-flex flex-column text-muted align-items-center py-5 px-2 h-100" [showBrowseBtn]=true
browseBtnClassName="btn btn-sm btn-outline-primary ms-2" i18n-dropZoneLabel i18n-browseBtnLabel> browseBtnClassName="btn btn-sm btn-outline-primary mt-2" i18n-dropZoneLabel i18n-browseBtnLabel>
</ngx-file-drop> </ngx-file-drop>
</form> </form>
<p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p> <p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>

View File

@ -1,7 +1,7 @@
<div class="card mb-3 shadow-sm bg-light"> <div class="card h-100 shadow-sm bg-light">
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{title}}</h5> <h6 class="card-title mb-0">{{title}}</h6>
<ng-container *ngIf="loading"> <ng-container *ngIf="loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>

View File

@ -214,6 +214,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
} }
} }
.card .table {
border-color: var(--bs-gray-800);
}
.alert-secondary { .alert-secondary {
background-color: var(--bs-light); background-color: var(--bs-light);
border-color: var(--pngx-bg-darker); border-color: var(--pngx-bg-darker);

View File

@ -1571,6 +1571,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
) )
tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True) tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True)
Tag.objects.create(name="t2")
Tag.objects.create(name="t3")
Correspondent.objects.create(name="c1")
Correspondent.objects.create(name="c2")
DocumentType.objects.create(name="dt1")
StoragePath.objects.create(name="sp1")
StoragePath.objects.create(name="sp2")
doc1.tags.add(tag_inbox) doc1.tags.add(tag_inbox)
@ -1588,6 +1595,10 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
1, 1,
) )
self.assertEqual(response.data["character_count"], 11) self.assertEqual(response.data["character_count"], 11)
self.assertEqual(response.data["tag_count"], 3)
self.assertEqual(response.data["correspondent_count"], 2)
self.assertEqual(response.data["document_type_count"], 1)
self.assertEqual(response.data["storage_path_count"], 2)
def test_statistics_no_inbox_tag(self): def test_statistics_no_inbox_tag(self):
Document.objects.create(title="none1", checksum="A") Document.objects.create(title="none1", checksum="A")

View File

@ -907,6 +907,39 @@ class StatisticsView(APIView):
if user is None if user is None
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag) else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
) )
correspondent_count = (
Correspondent.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
),
)
)
document_type_count = (
DocumentType.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
),
)
)
storage_path_count = (
StoragePath.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
),
)
)
documents_total = documents.count() documents_total = documents.count()
@ -941,6 +974,10 @@ class StatisticsView(APIView):
"inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None, "inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None,
"document_file_type_counts": document_file_type_counts, "document_file_type_counts": document_file_type_counts,
"character_count": character_count, "character_count": character_count,
"tag_count": len(tags),
"correspondent_count": correspondent_count,
"document_type_count": document_type_count,
"storage_path_count": storage_path_count,
}, },
) )