warehouse frontend

This commit is contained in:
hungdztrau123 2024-05-23 16:03:38 +07:00
parent 9d4d0b39a6
commit fa7283cff1
50 changed files with 1286 additions and 37 deletions

View File

@ -9,6 +9,7 @@ import { DocumentTypeListComponent } from './components/manage/document-type-lis
import { LogsComponent } from './components/admin/logs/logs.component' import { LogsComponent } from './components/admin/logs/logs.component'
import { SettingsComponent } from './components/admin/settings/settings.component' import { SettingsComponent } from './components/admin/settings/settings.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component' import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { WarehouseListComponent } from './components/manage/warehouse-list/warehouse-list.component'
import { NotFoundComponent } from './components/not-found/not-found.component' import { NotFoundComponent } from './components/not-found/not-found.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DirtyFormGuard } from './guards/dirty-form.guard' import { DirtyFormGuard } from './guards/dirty-form.guard'
@ -103,6 +104,17 @@ export const routes: Routes = [
}, },
}, },
}, },
{
path: 'warehouses',
component: WarehouseListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Warehouse,
},
},
},
{ {
path: 'documenttypes', path: 'documenttypes',
component: DocumentTypeListComponent, component: DocumentTypeListComponent,

View File

@ -12,6 +12,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
import { DocumentDetailComponent } from './components/document-detail/document-detail.component' import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DashboardComponent } from './components/dashboard/dashboard.component' import { DashboardComponent } from './components/dashboard/dashboard.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component' import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { WarehouseListComponent } from './components/manage/warehouse-list/warehouse-list.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component' import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component' import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { LogsComponent } from './components/admin/logs/logs.component' import { LogsComponent } from './components/admin/logs/logs.component'
@ -22,6 +23,7 @@ import { NotFoundComponent } from './components/not-found/not-found.component'
import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component'
import { CorrespondentEditDialogComponent } from './components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from './components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { WarehouseEditDialogComponent } from './components/common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { TagComponent } from './components/common/tag/tag.component' import { TagComponent } from './components/common/tag/tag.component'
import { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component'
@ -388,6 +390,7 @@ function initializeApp(settings: SettingsService) {
DocumentTypeListComponent, DocumentTypeListComponent,
CorrespondentListComponent, CorrespondentListComponent,
StoragePathListComponent, StoragePathListComponent,
WarehouseListComponent,
LogsComponent, LogsComponent,
SettingsComponent, SettingsComponent,
NotFoundComponent, NotFoundComponent,
@ -396,6 +399,7 @@ function initializeApp(settings: SettingsService) {
TagEditDialogComponent, TagEditDialogComponent,
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
StoragePathEditDialogComponent, StoragePathEditDialogComponent,
WarehouseEditDialogComponent,
TagComponent, TagComponent,
ClearableBadgeComponent, ClearableBadgeComponent,
PageHeaderComponent, PageHeaderComponent,

View File

@ -203,6 +203,13 @@
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span> <i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Warehouse }">
<a class="nav-link" routerLink="warehouses" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Warehouses" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Warehouses</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"

View File

@ -0,0 +1,31 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
@if (object?.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
}
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
<pngx-input-text i18n-title title="Type" formControlName="type" [error]="error?.type" autocomplete="off"></pngx-input-text>
<pngx-input-text i18n-title title="Parent Warehouse" formControlName="parent_warehouse" [error]="error?.parent_warehouse" autocomplete="off"></pngx-input-text>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) {
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
}
@if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
}
<div *pngxIfOwner="object">
<pngx-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div>
</form>

View File

@ -0,0 +1,64 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SettingsService } from 'src/app/services/settings.service'
import { CheckComponent } from '../../input/check/check.component'
import { ColorComponent } from '../../input/color/color.component'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogMode } from '../edit-dialog.component'
import { WarehouseEditDialogComponent } from './warehouse-edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('WarehouseEditDialogComponent', () => {
let component: WarehouseEditDialogComponent
let settingsService: SettingsService
let fixture: ComponentFixture<WarehouseEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
WarehouseEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
CheckComponent,
],
providers: [NgbActiveModal, SettingsService],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(WarehouseEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,45 @@
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'
import { Warehouse } from 'src/app/data/warehouse'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@Component({
selector: 'pngx-warehouse-edit-dialog',
templateUrl: './warehouse-edit-dialog.component.html',
styleUrls: ['./warehouse-edit-dialog.component.scss'],
})
export class WarehouseEditDialogComponent extends EditDialogComponent<Warehouse> {
constructor(
service: WarehouseService,
activeModal: NgbActiveModal,
userService: UserService,
settingsService: SettingsService
) {
super(service, activeModal, userService, settingsService)
}
getCreateTitle() {
return $localize`Create new warehouse`
}
getEditTitle() {
return $localize`Edit warehouse`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(''),
type: new FormControl(''),
parent_warehouse: new FormControl(''),
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
match: new FormControl(''),
is_insensitive: new FormControl(true),
permissions_form: new FormControl(null),
})
}
}

View File

@ -0,0 +1,8 @@
@if (tag === undefined) {
@if (!clickable) {
<span class="badge private" i18n>Private</span>
}
@if (clickable) {
<a [title]="linkTitle" class="badge private" i18n>Private</a>
}
}

View File

@ -0,0 +1,13 @@
a {
cursor: pointer;
white-space: normal;
word-break: break-word;
text-align: end;
}
.private {
background-color: #000000;
color: #ffffff;
opacity: .5;
font-style: italic;
}

View File

@ -0,0 +1,45 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { WarehouseComponent } from './warehouse.component'
import { Warehouse } from 'src/app/data/warehouse'
import { By } from '@angular/platform-browser'
const warehouse: Warehouse = {
id: 1,
type: 'Warehouse',
name: 'Warehouse1',
parent_warehouse: null,
}
describe('WarehouseComponent', () => {
let component: WarehouseComponent
let fixture: ComponentFixture<WarehouseComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [WarehouseComponent],
providers: [],
imports: [],
}).compileComponents()
fixture = TestBed.createComponent(WarehouseComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should handle private warehouses', () => {
expect(
fixture.debugElement.query(By.css('span')).nativeElement.textContent
).toEqual('Private')
})
it('should support clickable option', () => {
component.warehouse = warehouse
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).toBeNull()
component.clickable = true
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).not.toBeNull()
})
})

View File

@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core'
import { Warehouse } from 'src/app/data/warehouse'
@Component({
selector: 'pngx-warehouse',
templateUrl: './warehouse.component.html',
styleUrls: ['./warehouse.component.scss'],
})
export class WarehouseComponent {
constructor() {}
@Input()
warehouse: Warehouse
@Input()
linkTitle: string = ''
@Input()
clickable: boolean = false
}

View File

@ -84,6 +84,14 @@
</a> </a>
} }
</ng-container> </ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Warehouse }">
@if (statistics?.warehouse_count > 0) {
<a class="list-group-item d-flex justify-content-between align-items-center" routerLink="/warehouses/">
<ng-container i18n>Warehouses</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.warehouse_count | number}}</span>
</a>
}
</ng-container>
</div> </div>
</ng-container> </ng-container>
</pngx-widget-frame> </pngx-widget-frame>

View File

@ -18,6 +18,7 @@ export interface Statistics {
correspondent_count?: number correspondent_count?: number
document_type_count?: number document_type_count?: number
storage_path_count?: number storage_path_count?: number
warehouse_count?: number
} }
interface DocumentFileType { interface DocumentFileType {

View File

@ -111,6 +111,8 @@
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select> (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select> (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-select [items]="warehouses" i18n-title title="Warehouse" formControlName="warehouses" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createWarehouse($event)" [suggestions]="suggestions?.warehouses" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Warehouse }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags> <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
@for (fieldInstance of document?.custom_fields; track fieldInstance; let i = $index) { @for (fieldInstance of document?.custom_fields; track fieldInstance; let i = $index) {
<div [formGroup]="customFieldFormFields.controls[i]"> <div [formGroup]="customFieldFormFields.controls[i]">

View File

@ -29,6 +29,7 @@ import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE, FILTER_DOCUMENT_TYPE,
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_WAREHOUSE,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_CREATED_AFTER, FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE, FILTER_CREATED_BEFORE,
@ -37,6 +38,7 @@ import { Correspondent } from 'src/app/data/correspondent'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Warehouse } from 'src/app/data/warehouse'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@ -52,6 +54,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@ -59,6 +62,7 @@ import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { WarehouseEditDialogComponent } from '../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { DateComponent } from '../common/input/date/date.component' import { DateComponent } from '../common/input/date/date.component'
import { NumberComponent } from '../common/input/number/number.component' import { NumberComponent } from '../common/input/number/number.component'
import { PermissionsFormComponent } from '../common/input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../common/input/permissions/permissions-form/permissions-form.component'
@ -165,6 +169,7 @@ describe('DocumentDetailComponent', () => {
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
CorrespondentEditDialogComponent, CorrespondentEditDialogComponent,
StoragePathEditDialogComponent, StoragePathEditDialogComponent,
WarehouseEditDialogComponent,
IfOwnerDirective, IfOwnerDirective,
PermissionsFormComponent, PermissionsFormComponent,
SafeHtmlPipe, SafeHtmlPipe,
@ -220,6 +225,20 @@ describe('DocumentDetailComponent', () => {
}), }),
}, },
}, },
{
provide: WarehouseService,
useValue: {
listAll: () =>
of({
results: [
{
id: 41,
name: 'Warehouse41',
},
],
}),
},
},
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
@ -366,6 +385,7 @@ describe('DocumentDetailComponent', () => {
expect(component.correspondents).toBeUndefined() expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined() expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined() expect(component.storagePaths).toBeUndefined()
expect(component.warehouses).toBeUndefined()
expect(component.users).toBeUndefined() expect(component.users).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`) httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone( httpTestingController.expectNone(
@ -377,6 +397,9 @@ describe('DocumentDetailComponent', () => {
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/` `${environment.apiBaseUrl}documents/storage_paths/`
) )
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/warehouses/`
)
currentUserCan = true currentUserCan = true
}) })
@ -419,6 +442,20 @@ describe('DocumentDetailComponent', () => {
expect(component.documentForm.get('storage_path').value).toEqual(12) expect(component.documentForm.get('storage_path').value).toEqual(12)
}) })
it('should support creating warehouse', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
component.createWarehouse('NewWarehouse12')
expect(modalSpy).toHaveBeenCalled()
openModal.componentInstance.succeeded.next({
id: 12,
name: 'NewWarehouse12',
})
expect(component.documentForm.get('warehouse').value).toEqual(12)
})
it('should allow dischard changes', () => { it('should allow dischard changes', () => {
initNormally() initNormally()
component.title = 'Foo Bar' component.title = 'Foo Bar'
@ -819,6 +856,24 @@ describe('DocumentDetailComponent', () => {
]) ])
}) })
it('should support quick filtering by warehouse', () => {
initNormally()
const object = {
id: 22,
name: 'Warehouse22',
type: 'Warehouse',
parent_warehouse: 22,
} as Warehouse
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_WAREHOUSE,
value: object.id.toString(),
},
])
})
it('should support quick filtering by all tags', () => { it('should support quick filtering by all tags', () => {
initNormally() initNormally()
const object1 = { const object1 = {

View File

@ -44,10 +44,14 @@ import {
FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_WAREHOUSE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { Warehouse } from 'src/app/data/warehouse'
import { WarehouseEditDialogComponent } from '../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { import {
PermissionAction, PermissionAction,
@ -134,6 +138,8 @@ export class DocumentDetailComponent
correspondents: Correspondent[] correspondents: Correspondent[]
documentTypes: DocumentType[] documentTypes: DocumentType[]
storagePaths: StoragePath[] storagePaths: StoragePath[]
warehouses: Warehouse[]
documentForm: FormGroup = new FormGroup({ documentForm: FormGroup = new FormGroup({
title: new FormControl(''), title: new FormControl(''),
@ -142,6 +148,7 @@ export class DocumentDetailComponent
correspondent: new FormControl(), correspondent: new FormControl(),
document_type: new FormControl(), document_type: new FormControl(),
storage_path: new FormControl(), storage_path: new FormControl(),
warehouses: new FormControl(),
archive_serial_number: new FormControl(), archive_serial_number: new FormControl(),
tags: new FormControl([]), tags: new FormControl([]),
permissions_form: new FormControl(null), permissions_form: new FormControl(null),
@ -197,6 +204,7 @@ export class DocumentDetailComponent
private toastService: ToastService, private toastService: ToastService,
private settings: SettingsService, private settings: SettingsService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
private warehouseService: WarehouseService,
private permissionsService: PermissionsService, private permissionsService: PermissionsService,
private userService: UserService, private userService: UserService,
private customFieldsService: CustomFieldsService, private customFieldsService: CustomFieldsService,
@ -285,6 +293,17 @@ export class DocumentDetailComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
} }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Warehouse
)
) {
this.warehouseService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.warehouses = result.results))
}
if ( if (
this.permissionsService.currentUserCan( this.permissionsService.currentUserCan(
PermissionAction.View, PermissionAction.View,
@ -408,6 +427,7 @@ export class DocumentDetailComponent
correspondent: doc.correspondent, correspondent: doc.correspondent,
document_type: doc.document_type, document_type: doc.document_type,
storage_path: doc.storage_path, storage_path: doc.storage_path,
warehouses: doc.warehouses,
archive_serial_number: doc.archive_serial_number, archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags], tags: [...doc.tags],
permissions_form: { permissions_form: {
@ -602,6 +622,27 @@ export class DocumentDetailComponent
}) })
} }
createWarehouse(newName: string) {
var modal = this.modalService.open(WarehouseEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.succeeded
.pipe(
switchMap((newWarehouse) => {
return this.warehouseService
.listAll()
.pipe(map((warehouses) => ({ newWarehouse, warehouses })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newWarehouse, warehouses }) => {
this.warehouses = warehouses.results
this.documentForm.get('warehouses').setValue(newWarehouse.id)
})
}
discard() { discard() {
this.documentsService this.documentsService
.get(this.documentId) .get(this.documentId)
@ -968,6 +1009,12 @@ export class DocumentDetailComponent
rule_type: FILTER_STORAGE_PATH, rule_type: FILTER_STORAGE_PATH,
value: (i as StoragePath).id.toString(), value: (i as StoragePath).id.toString(),
} }
} else if (i.hasOwnProperty('type')) {
// Warehouse
return {
rule_type: FILTER_WAREHOUSE,
value: (i as Warehouse).id.toString(),
}
} else if (i.hasOwnProperty('is_inbox_tag')) { } else if (i.hasOwnProperty('is_inbox_tag')) {
// Tag // Tag
return { return {

View File

@ -74,6 +74,22 @@
(apply)="setStoragePaths($event)"> (apply)="setStoragePaths($event)">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
<pngx-filterable-dropdown title="Warehouses" icon="warehouse-fill" i18n-title
filterPlaceholder="Filter warehouses" i18n-filterPlaceholder
[items]="warehouses"
[disabled]="!userCanEditAll"
[editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose"
[createRef]="createWarehouse.bind(this)"
(opened)="openWarehousesDropdown()"
[(selectionModel)]="warehouseSelectionModel"
[documentCounts]="warehouseDocumentCounts"
(apply)="setWarehouses($event)">
</pngx-filterable-dropdown>
}
</div> </div>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar"> <div class="btn-toolbar">

View File

@ -24,6 +24,7 @@ import {
DocumentService, DocumentService,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@ -49,9 +50,11 @@ import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Warehouse } from 'src/app/data/warehouse'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
@ -82,6 +85,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService let storagePathService: StoragePathService
let warehouseService: WarehouseService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
beforeEach(async () => { beforeEach(async () => {
@ -148,6 +152,18 @@ describe('BulkEditorComponent', () => {
}), }),
}, },
}, },
{
provide: WarehouseService,
useValue: {
listAll: () =>
of({
results: [
{ id: 88, name: 'warehouse88' },
{ id: 77, name: 'warehouse77' },
],
}),
},
},
FilterPipe, FilterPipe,
SettingsService, SettingsService,
{ {
@ -189,6 +205,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService) correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService) documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService) storagePathService = TestBed.inject(StoragePathService)
warehouseService = TestBed.inject(WarehouseService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent) fixture = TestBed.createComponent(BulkEditorComponent)
@ -262,6 +279,22 @@ describe('BulkEditorComponent', () => {
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1) expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
}) })
it('should apply selection data to warehouse menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.warehousesSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openWarehouseDropdown()
expect(component.warehousesSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => { it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@ -679,6 +712,105 @@ describe('BulkEditorComponent', () => {
) )
}) })
it('should execute modify warehouse bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setWarehouses({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_warehouse',
parameters: { warehouse: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify warehouse bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setWarehouses({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should set modal dialog text accordingly for warehouse edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setWarehouses({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'Warehouse 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the warehouse from 2 selected document(s).'
)
modal.close()
component.setWarehouses({
itemsToAdd: [{ id: 101, name: 'Warehouse 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the storage path "Warehouse 101" to 2 selected document(s).'
)
})
it('should only execute bulk operations when changes are detected', () => { it('should only execute bulk operations when changes are detected', () => {
component.setTags({ component.setTags({
itemsToAdd: [], itemsToAdd: [],
@ -696,6 +828,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [], itemsToAdd: [],
itemsToRemove: [], itemsToRemove: [],
}) })
component.setWarehouses({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
@ -988,6 +1124,7 @@ describe('BulkEditorComponent', () => {
expect(component.correspondents).toBeUndefined() expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined() expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined() expect(component.storagePaths).toBeUndefined()
expect(component.warehouses).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`) httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/` `${environment.apiBaseUrl}documents/correspondents/`
@ -998,6 +1135,9 @@ describe('BulkEditorComponent', () => {
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/` `${environment.apiBaseUrl}documents/storage_paths/`
) )
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/warehouses/`
)
}) })
it('should support create new tag', () => { it('should support create new tag', () => {
@ -1175,4 +1315,48 @@ describe('BulkEditorComponent', () => {
) )
expect(component.storagePaths).toEqual(storagePaths.results) expect(component.storagePaths).toEqual(storagePaths.results)
}) })
it('should support create new warehouse', () => {
const name = 'New Warehouse'
const newWarehouse = { id: 101, name: 'New Warehouse' }
const warehouses: Results<Warehouse> = {
results: [
{ id: 1, name: 'Warehouse 1' },
{ id: 2, name: 'Warehouse 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newWarehouse),
},
}
const warehousesListAllSpy = jest.spyOn(warehouseService, 'listAll')
warehousesListAllSpy.mockReturnValue(of(warehouses))
const warehousesSelectionModelToggleSpy = jest.spyOn(
component.warehousesSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createWarehouse(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
WarehouseEditDialogComponent,
{ backdrop: 'static' }
)
expect(warehousesListAllSpy).toHaveBeenCalled()
expect(warehousesSelectionModelToggleSpy).toHaveBeenCalledWith(
newWarehouse.id
)
expect(component.warehouses).toEqual(warehouses.results)
})
}) })

View File

@ -24,6 +24,8 @@ import { ToastService } from 'src/app/services/toast.service'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { Warehouse } from 'src/app/data/warehouse'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
@ -39,6 +41,7 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
@ -55,15 +58,19 @@ export class BulkEditorComponent
correspondents: Correspondent[] correspondents: Correspondent[]
documentTypes: DocumentType[] documentTypes: DocumentType[]
storagePaths: StoragePath[] storagePaths: StoragePath[]
warehouses: Warehouse[]
tagSelectionModel = new FilterableDropdownSelectionModel() tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
warehousesSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[] storagePathDocumentCounts: SelectionDataItem[]
warehouseDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@ -85,6 +92,7 @@ export class BulkEditorComponent
private settings: SettingsService, private settings: SettingsService,
private toastService: ToastService, private toastService: ToastService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
private warehouseService: WarehouseService,
private permissionService: PermissionsService private permissionService: PermissionsService
) { ) {
super() super()
@ -166,6 +174,17 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
} }
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.Warehouse
)
) {
this.warehouseService
.listAll()
.pipe(first())
.subscribe((result) => (this.warehouses = result.results))
}
this.downloadForm this.downloadForm
.get('downloadFileTypeArchive') .get('downloadFileTypeArchive')
@ -297,6 +316,19 @@ export class BulkEditorComponent
}) })
} }
openWarehouseDropdown() {
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.warehouseDocumentCounts = s.selected_warehouses
this.applySelectionData(
s.selected_warehouses,
this.warehousesSelectionModel
)
})
}
private _localizeList(items: MatchingModel[]) { private _localizeList(items: MatchingModel[]) {
if (items.length == 0) { if (items.length == 0) {
return '' return ''
@ -495,6 +527,44 @@ export class BulkEditorComponent
} }
} }
setWarehouses(changedDocumentPaths: ChangedItems) {
if (
changedDocumentPaths.itemsToAdd.length == 0 &&
changedDocumentPaths.itemsToRemove.length == 0
)
return
let warehouse =
changedDocumentPaths.itemsToAdd.length > 0
? changedDocumentPaths.itemsToAdd[0]
: null
if (this.showConfirmationDialogs) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm warehouse assignment`
if (warehouse) {
modal.componentInstance.message = $localize`This operation will assign the warehouse "${warehouse.name}" to ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the warehouse from ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeBulkOperation(modal, 'set_warehouse', {
warehouse: warehouse ? warehouse.id : null,
})
})
} else {
this.executeBulkOperation(null, 'set_warehouse', {
warehouse: warehouse ? warehouse.id : null,
})
}
}
createTag(name: string) { createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, { let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static', backdrop: 'static',
@ -581,6 +651,27 @@ export class BulkEditorComponent
}) })
} }
createWarehouse(name: string) {
let modal = this.modalService.open(WarehouseEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newWarehouse) => {
return this.warehouseService
.listAll()
.pipe(map((warehouses) => ({ newWarehouse, warehouses })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newWarehouse, warehouses }) => {
this.warehouses = warehouses.results
this.warehousesSelectionModel.toggle(newWarehouse.id)
})
}
applyDelete() { applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',

View File

@ -83,6 +83,12 @@
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
</button> </button>
} }
@if (document.warehouses) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by warehouse" i18n-title
(click)="clickWarehouse.emit(document.warehouses);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.warehouses$ | async)?.name}}</small>
</button>
}
@if (document.archive_serial_number | isNumber) { @if (document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>

View File

@ -53,6 +53,9 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
@Output() @Output()
clickStoragePath = new EventEmitter<number>() clickStoragePath = new EventEmitter<number>()
@Output()
clickWarehouse = new EventEmitter<number>()
@Output() @Output()
clickMoreLike = new EventEmitter() clickMoreLike = new EventEmitter()

View File

@ -54,6 +54,13 @@
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small> <small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
</button> </button>
} }
@if (document.warehouses) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle warehouse filter" i18n-title
(click)="clickWarehouse.emit(document.warehouses);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{(document.warehouses$ | async)?.name ?? privateName}}</small>
</button>
}
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column text-light"> <div class="d-flex flex-column text-light">

View File

@ -50,6 +50,9 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
@Output() @Output()
clickStoragePath = new EventEmitter<number>() clickStoragePath = new EventEmitter<number>()
@Output()
clickWarehouse = new EventEmitter<number>()
moreTags: number = null moreTags: number = null
@ViewChild('popover') popover: NgbPopover @ViewChild('popover') popover: NgbPopover

View File

@ -192,6 +192,15 @@
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Storage path</th> i18n>Storage path</th>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
<th class="d-none d-xl-table-cell"
pngxSortable="warehouse__name"
title="Sort by warehouse" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Warehouse</th>
}
<th <th
pngxSortable="created" pngxSortable="created"
title="Sort by created date" i18n-title title="Sort by created date" i18n-title
@ -260,6 +269,13 @@
} }
</td> </td>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
<td class="d-none d-xl-table-cell">
@if (d.warehouses) {
<a (click)="clickWarehouse(d.warehouses);$event.stopPropagation()" title="Filter by warehouse" i18n-title>{{(d.warehouses$ | async)?.name}}</a>
}
</td>
}
<td> <td>
{{d.created_date | customDate}} {{d.created_date | customDate}}
</td> </td>

View File

@ -588,6 +588,7 @@ describe('DocumentListComponent', () => {
component.clickCorrespondent(2) component.clickCorrespondent(2)
component.clickDocumentType(3) component.clickDocumentType(3)
component.clickStoragePath(4) component.clickStoragePath(4)
component.clickWarehouse(5)
}) })
it('should support quick filter on document more like', () => { it('should support quick filter on document more like', () => {

View File

@ -291,6 +291,11 @@ export class DocumentListComponent
this.filterEditor.toggleStoragePath(storagePathID) this.filterEditor.toggleStoragePath(storagePathID)
} }
clickWarehouse(warehouseID: number) {
this.list.selectNone()
this.filterEditor.toggleWarehouse(warehouseID)
}
clickMoreLike(documentID: number) { clickMoreLike(documentID: number) {
this.list.quickFilter([ this.list.quickFilter([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },

View File

@ -70,6 +70,16 @@
[documentCounts]="storagePathDocumentCounts" [documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown> [allowSelectNone]="true"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
<pngx-filterable-dropdown class="flex-fill" title="Warehouse" icon="folder-fill" i18n-title
filterPlaceholder="Filter warehouses" i18n-filterPlaceholder
[items]="warehouses"
[(selectionModel)]="warehouseSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onWarehouseDropdownOpen()"
[documentCounts]="warehouseDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<pngx-date-dropdown <pngx-date-dropdown

View File

@ -45,6 +45,9 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_DOES_NOT_HAVE_STORAGE_PATH, FILTER_DOES_NOT_HAVE_STORAGE_PATH,
FILTER_WAREHOUSE,
FILTER_HAS_WAREHOUSE_ANY,
FILTER_DOES_NOT_HAVE_WAREHOUSE,
FILTER_OWNER, FILTER_OWNER,
FILTER_OWNER_ANY, FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE, FILTER_OWNER_DOES_NOT_INCLUDE,
@ -56,6 +59,7 @@ import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { Warehouse } from 'src/app/data/warehouse'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@ -65,6 +69,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
@ -131,6 +136,17 @@ const storage_paths: StoragePath[] = [
}, },
] ]
const warehouses: Warehouse[] = [
{
id: 42,
name: 'Warehouse32',
},
{
id: 43,
name: 'Warehouse33',
},
]
const users: User[] = [ const users: User[] = [
{ {
id: 1, id: 1,
@ -187,6 +203,12 @@ describe('FilterEditorComponent', () => {
listAll: () => of({ results: storage_paths }), listAll: () => of({ results: storage_paths }),
}, },
}, },
{
provide: WarehouseService,
useValue: {
listAll: () => of({ results: warehouses }),
},
},
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
@ -806,6 +828,89 @@ describe('FilterEditorComponent', () => {
] ]
})) }))
it('should ingest filter rules for has warehouse', fakeAsync(() => {
expect(component.warehouseSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_WAREHOUSE,
value: '42',
},
]
expect(component.warehouseSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.warehouseSelectionModel.intersection).toEqual(
Intersection.Include
)
expect(component.warehouseSelectionModel.getSelectedItems()).toEqual([
warehouses[0],
])
component.toggleWarehouse(42) // coverage
}))
it('should ingest filter rules for has any of warehouse', fakeAsync(() => {
expect(component.warehouseSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: '42',
},
{
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: '43',
},
]
expect(component.warehouseSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.warehouseSelectionModel.intersection).toEqual(
Intersection.Include
)
expect(component.warehouseSelectionModel.getSelectedItems()).toEqual(
warehouses
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: null,
},
]
}))
it('should ingest filter rules for does not have any of warehouses', fakeAsync(() => {
expect(component.warehouseSelectionModel.getExcludedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: '42',
},
{
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: '43',
},
]
expect(component.warehouseSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.warehouseSelectionModel.getExcludedItems()).toEqual(
warehouses
)
// coverage
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: null,
},
]
}))
it('should ingest filter rules for owner', fakeAsync(() => { it('should ingest filter rules for owner', fakeAsync(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
@ -1317,6 +1422,63 @@ describe('FilterEditorComponent', () => {
]) ])
})) }))
it('should convert user input to correct filter rules on warehouse selections', fakeAsync(() => {
const warehouseFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4] // Warehouse dropdown
warehouseFilterableDropdown.triggerEventHandler('opened')
const warehouseButtons = warehouseFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)
warehouseButtons[1].triggerEventHandler('toggle')
warehouseButtons[2].triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: warehouses[0].id.toString(),
},
{
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: warehouses[1].id.toString(),
},
])
const toggleIntersectionButtons = warehouseFilterableDropdown.queryAll(
By.css('input[type=radio]')
)
toggleIntersectionButtons[1].nativeElement.checked = true
toggleIntersectionButtons[1].triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: warehouses[0].id.toString(),
},
{
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: warehouses[1].id.toString(),
},
])
}))
it('should convert user input to correct filter rules on warehouse select not assigned', fakeAsync(() => {
const warehousesFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4]
warehousesFilterableDropdown.triggerEventHandler('opened')
const notAssignedButton = warehousesFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)[0]
notAssignedButton.triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_WAREHOUSE,
value: null,
},
])
}))
it('should convert user input to correct filter rules on date created after', fakeAsync(() => { it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent) By.directive(DateDropdownComponent)

View File

@ -11,10 +11,12 @@ import {
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { Warehouse } from 'src/app/data/warehouse'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators' import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { filterRulesDiffer } from 'src/app/utils/filter-rules' import { filterRulesDiffer } from 'src/app/utils/filter-rules'
@ -35,15 +37,18 @@ import {
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_WAREHOUSE_ANY,
FILTER_ASN_ISNULL, FILTER_ASN_ISNULL,
FILTER_ASN_GT, FILTER_ASN_GT,
FILTER_ASN_LT, FILTER_ASN_LT,
FILTER_DOES_NOT_HAVE_CORRESPONDENT, FILTER_DOES_NOT_HAVE_CORRESPONDENT,
FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE, FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
FILTER_DOES_NOT_HAVE_STORAGE_PATH, FILTER_DOES_NOT_HAVE_STORAGE_PATH,
FILTER_DOES_NOT_HAVE_WAREHOUSE,
FILTER_DOCUMENT_TYPE, FILTER_DOCUMENT_TYPE,
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_WAREHOUSE,
FILTER_OWNER, FILTER_OWNER,
FILTER_OWNER_DOES_NOT_INCLUDE, FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL, FILTER_OWNER_ISNULL,
@ -189,6 +194,16 @@ export class FilterEditorComponent
return $localize`Without document type` return $localize`Without document type`
} }
case FILTER_WAREHOUSE:
case FILTER_HAS_WAREHOUSE_ANY:
if (rule.value) {
return $localize`Warehouse: ${this.warehouses.find(
(w) => w.id == +rule.value
)?.name}`
} else {
return $localize`Without warehouse`
}
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
if (rule.value) { if (rule.value) {
@ -231,6 +246,7 @@ export class FilterEditorComponent
constructor( constructor(
private documentTypeService: DocumentTypeService, private documentTypeService: DocumentTypeService,
private tagService: TagService, private tagService: TagService,
private warehouseService: WarehouseService,
private correspondentService: CorrespondentService, private correspondentService: CorrespondentService,
private documentService: DocumentService, private documentService: DocumentService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
@ -246,11 +262,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = [] correspondents: Correspondent[] = []
documentTypes: DocumentType[] = [] documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = [] storagePaths: StoragePath[] = []
warehouses: Warehouse[] = []
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[] storagePathDocumentCounts: SelectionDataItem[]
warehouseDocumentCounts: SelectionDataItem[]
_textFilter = '' _textFilter = ''
_moreLikeId: number _moreLikeId: number
@ -288,6 +306,8 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
warehouseSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string dateCreatedBefore: string
dateCreatedAfter: string dateCreatedAfter: string
@ -320,6 +340,7 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.clear(false) this.documentTypeSelectionModel.clear(false)
this.storagePathSelectionModel.clear(false) this.storagePathSelectionModel.clear(false)
this.warehouseSelectionModel.clear(false)
this.tagSelectionModel.clear(false) this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false) this.correspondentSelectionModel.clear(false)
this._textFilter = null this._textFilter = null
@ -470,6 +491,24 @@ export class FilterEditorComponent
false false
) )
break break
case FILTER_WAREHOUSE:
case FILTER_HAS_WAREHOUSE_ANY:
this.warehouseSelectionModel.logicalOperator = LogicalOperator.Or
this.warehouseSelectionModel.intersection = Intersection.Include
this.warehouseSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_WAREHOUSE:
this.warehouseSelectionModel.intersection = Intersection.Exclude
this.warehouseSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
@ -683,6 +722,26 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.warehouseSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_WAREHOUSE, value: null })
} else {
this.warehouseSelectionModel
.getSelectedItems()
.forEach((warehouse) => {
filterRules.push({
rule_type: FILTER_HAS_WAREHOUSE_ANY,
value: warehouse.id?.toString(),
})
})
this.warehouseSelectionModel
.getExcludedItems()
.forEach((warehouse) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
value: warehouse.id?.toString(),
})
})
}
if (this.storagePathSelectionModel.isNoneSelected()) { if (this.storagePathSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null }) filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else { } else {
@ -845,6 +904,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts = this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null selectionData?.selected_storage_paths ?? null
this.warehouseDocumentCounts =
selectionData?.selected_warehouses ?? null
} }
rulesModified: boolean = false rulesModified: boolean = false
@ -895,6 +956,16 @@ export class FilterEditorComponent
.listAll() .listAll()
.subscribe((result) => (this.documentTypes = result.results)) .subscribe((result) => (this.documentTypes = result.results))
} }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Warehouse
)
) {
this.warehouseService
.listAll()
.subscribe((result) => (this.warehouses = result.results))
}
if ( if (
this.permissionsService.currentUserCan( this.permissionsService.currentUserCan(
PermissionAction.View, PermissionAction.View,
@ -941,6 +1012,10 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.toggle(documentTypeId) this.documentTypeSelectionModel.toggle(documentTypeId)
} }
toggleWarehouse(warehouseId: number) {
this.warehouseSelectionModel.toggle(warehouseId)
}
toggleStoragePath(storagePathID: number) { toggleStoragePath(storagePathID: number) {
this.storagePathSelectionModel.toggle(storagePathID) this.storagePathSelectionModel.toggle(storagePathID)
} }
@ -957,6 +1032,10 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.apply() this.documentTypeSelectionModel.apply()
} }
onWarehouseDropdownOpen() {
this.warehouseSelectionModel.apply()
}
onStoragePathDropdownOpen() { onStoragePathDropdownOpen() {
this.storagePathSelectionModel.apply() this.storagePathSelectionModel.apply()
} }

View File

@ -0,0 +1,72 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { WarehouseListComponent } from './warehouse-list.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('WarehouseListComponent', () => {
let component: WarehouseListComponent
let fixture: ComponentFixture<WarehouseListComponent>
let warehouseService: WarehouseService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
WarehouseListComponent,
SortableDirective,
PageHeaderComponent,
IfPermissionsDirective,
SafeHtmlPipe,
],
providers: [DatePipe],
imports: [
HttpClientTestingModule,
NgbPaginationModule,
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
warehouseService = TestBed.inject(WarehouseService)
jest.spyOn(warehouseService, 'listFiltered').mockReturnValue(
of({
count: 3,
all: [1, 2, 3],
results: [
{
id: 1,
name: 'Warehouse1',
},
{
id: 2,
name: 'Warehouse2',
},
{
id: 3,
name: 'Warehouse3',
},
],
})
)
fixture = TestBed.createComponent(WarehouseListComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
// Tests are included in management-list.component.spec.ts
it('should use correct delete message', () => {
expect(component.getDeleteMessage({ id: 1, name: 'Warehouse1' })).toEqual(
'Do you really want to delete the warehouse "Warehouse1"?'
)
})
})

View File

@ -0,0 +1,55 @@
import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Warehouse } from 'src/app/data/warehouse'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { ToastService } from 'src/app/services/toast.service'
import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({
selector: 'pngx-warehouse-list',
templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'],
})
export class WarehouseListComponent extends ManagementListComponent<Warehouse> {
constructor(
warehouseService: WarehouseService,
modalService: NgbModal,
toastService: ToastService,
documentListViewService: DocumentListViewService,
permissionsService: PermissionsService
) {
super(
warehouseService,
modalService,
WarehouseEditDialogComponent,
toastService,
documentListViewService,
permissionsService,
FILTER_HAS_TAGS_ALL,
$localize`warehouse`,
$localize`warehouses`,
PermissionType.Warehouse,
[
{
key: 'type',
name: $localize`Type`,
rendersHtml: true,
valueFn: (w: Warehouse) => {
return w.type
},
},
]
)
}
getDeleteMessage(object: Warehouse) {
return $localize`Do you really want to delete the warehouse "${object.name}"?`
}
}

View File

@ -7,5 +7,7 @@ export interface DocumentSuggestions {
storage_paths?: number[] storage_paths?: number[]
warehouses?: number[]
dates?: string[] // ISO-formatted date string e.g. 2022-11-03 dates?: string[] // ISO-formatted date string e.g. 2022-11-03
} }

View File

@ -3,6 +3,7 @@ import { Tag } from './tag'
import { DocumentType } from './document-type' import { DocumentType } from './document-type'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { StoragePath } from './storage-path' import { StoragePath } from './storage-path'
import { Warehouse } from './warehouse'
import { ObjectWithPermissions } from './object-with-permissions' import { ObjectWithPermissions } from './object-with-permissions'
import { DocumentNote } from './document-note' import { DocumentNote } from './document-note'
import { CustomFieldInstance } from './custom-field-instance' import { CustomFieldInstance } from './custom-field-instance'
@ -28,6 +29,10 @@ export interface Document extends ObjectWithPermissions {
storage_path?: number storage_path?: number
warehouses$?: Observable<Warehouse>
warehouses?: number
title?: string title?: string
content?: string content?: string

View File

@ -49,6 +49,10 @@ export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36 export const FILTER_CUSTOM_FIELDS = 36
export const FILTER_WAREHOUSE = 50
export const FILTER_HAS_WAREHOUSE_ANY = 51
export const FILTER_DOES_NOT_HAVE_WAREHOUSE = 52
export const FILTER_RULE_TYPES: FilterRuleType[] = [ export const FILTER_RULE_TYPES: FilterRuleType[] = [
{ {
id: FILTER_TITLE, id: FILTER_TITLE,
@ -108,6 +112,25 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'storage_path', datatype: 'storage_path',
multi: true, multi: true,
}, },
{
id: FILTER_WAREHOUSE,
filtervar: 'warehouses__id',
isnull_filtervar: 'warehouses__isnull',
datatype: 'warehouse',
multi: false,
},
{
id: FILTER_HAS_WAREHOUSE_ANY,
filtervar: 'warehouses__id__in',
datatype: 'warehouse',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_WAREHOUSE,
filtervar: 'warehouses__id__none',
datatype: 'warehouse',
multi: true,
},
{ {
id: FILTER_DOCUMENT_TYPE, id: FILTER_DOCUMENT_TYPE,
filtervar: 'document_type__id', filtervar: 'document_type__id',

View File

@ -0,0 +1,8 @@
import { MatchingModel } from './matching-model'
export interface Warehouse extends MatchingModel {
type?: string
parent_warehouse?: number
}

View File

@ -17,6 +17,8 @@ export interface WorkflowAction extends ObjectWithId {
assign_storage_path?: number // StoragePath.id assign_storage_path?: number // StoragePath.id
assign_warehouses?: number // Warehouse.id
assign_owner?: number // User.id assign_owner?: number // User.id
assign_view_users?: number[] // [User.id] assign_view_users?: number[] // [User.id]
@ -45,6 +47,10 @@ export interface WorkflowAction extends ObjectWithId {
remove_all_storage_paths?: boolean remove_all_storage_paths?: boolean
remove_warehouses?: number[] // [Warehouse.id]
remove_all_warehouses?: boolean
remove_owners?: number[] // [User.id] remove_owners?: number[] // [User.id]
remove_all_owners?: boolean remove_all_owners?: boolean

View File

@ -264,6 +264,10 @@ describe('PermissionsService', () => {
'change_applicationconfiguration', 'change_applicationconfiguration',
'delete_applicationconfiguration', 'delete_applicationconfiguration',
'view_applicationconfiguration', 'view_applicationconfiguration',
'change_warehouse',
'view_warehouse',
'add_warehouse',
'delete_warehouse',
], ],
{ {
username: 'testuser', username: 'testuser',

View File

@ -12,6 +12,7 @@ export enum PermissionAction {
export enum PermissionType { export enum PermissionType {
Document = '%s_document', Document = '%s_document',
Tag = '%s_tag', Tag = '%s_tag',
Warehouse = '%s_warehouse',
Correspondent = '%s_correspondent', Correspondent = '%s_correspondent',
DocumentType = '%s_documenttype', DocumentType = '%s_documenttype',
StoragePath = '%s_storagepath', StoragePath = '%s_storagepath',

View File

@ -13,6 +13,8 @@ import { TagService } from './tag.service'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { StoragePathService } from './storage-path.service' import { StoragePathService } from './storage-path.service'
import { WarehouseService } from './warehouse.service'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@ -26,6 +28,7 @@ export const DOCUMENT_SORT_FIELDS = [
{ field: 'correspondent__name', name: $localize`Correspondent` }, { field: 'correspondent__name', name: $localize`Correspondent` },
{ field: 'title', name: $localize`Title` }, { field: 'title', name: $localize`Title` },
{ field: 'document_type__name', name: $localize`Document type` }, { field: 'document_type__name', name: $localize`Document type` },
{ field: 'warehouses__name', name: $localize`Warehouse` },
{ field: 'created', name: $localize`Created` }, { field: 'created', name: $localize`Created` },
{ field: 'added', name: $localize`Added` }, { field: 'added', name: $localize`Added` },
{ field: 'modified', name: $localize`Modified` }, { field: 'modified', name: $localize`Modified` },
@ -51,6 +54,8 @@ export interface SelectionData {
selected_correspondents: SelectionDataItem[] selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[] selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[] selected_document_types: SelectionDataItem[]
selected_warehouses: SelectionDataItem[]
} }
@Injectable({ @Injectable({
@ -65,6 +70,7 @@ export class DocumentService extends AbstractPaperlessService<Document> {
private documentTypeService: DocumentTypeService, private documentTypeService: DocumentTypeService,
private tagService: TagService, private tagService: TagService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
private warehouseService: WarehouseService,
private permissionsService: PermissionsService, private permissionsService: PermissionsService,
private settingsService: SettingsService private settingsService: SettingsService
) { ) {
@ -116,6 +122,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
) { ) {
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path) doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
} }
if (
doc.warehouses &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Warehouse
)
) {
doc.warehouses$ = this.warehouseService.getCached(doc.warehouses)
}
return doc return doc
} }

View File

@ -0,0 +1,4 @@
import { WarehouseService } from './warehouse.service'
import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
commonAbstractNameFilterPaperlessServiceTests('warehouses', WarehouseService)

View File

@ -0,0 +1,13 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Warehouse } from 'src/app/data/warehouse'
import { AbstractNameFilterService } from './abstract-name-filter-service'
@Injectable({
providedIn: 'root',
})
export class WarehouseService extends AbstractNameFilterService<Warehouse> {
constructor(http: HttpClient) {
super(http, 'warehouses')
}
}

View File

@ -192,6 +192,8 @@ class DocumentFilterSet(FilterSet):
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True) storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
warehouses__id__none = ObjectFilter(field_name="warehouses", exclude=True)
is_in_inbox = InboxFilter() is_in_inbox = InboxFilter()
title_content = TitleContentFilter() title_content = TitleContentFilter()
@ -225,6 +227,9 @@ class DocumentFilterSet(FilterSet):
"storage_path": ["isnull"], "storage_path": ["isnull"],
"storage_path__id": ID_KWARGS, "storage_path__id": ID_KWARGS,
"storage_path__name": CHAR_KWARGS, "storage_path__name": CHAR_KWARGS,
"warehouses": ["isnull"],
"warehouses__id": ID_KWARGS,
"warehouses__name": CHAR_KWARGS,
"owner": ["isnull"], "owner": ["isnull"],
"owner__id": ID_KWARGS, "owner__id": ID_KWARGS,
"custom_fields": ["icontains"], "custom_fields": ["icontains"],

View File

@ -1,30 +0,0 @@
# Generated by Django 4.2.11 on 2024-05-15 04:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('documents', '1046_workflowaction_remove_all_correspondents_and_more'),
]
operations = [
migrations.CreateModel(
name='Warehouse',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256, unique=True, verbose_name='name')),
('type', models.CharField(blank=True, choices=[(1, 'Warehouse'), (2, 'Shelf'), (3, 'Boxcase')], default=1, max_length=20, null=True)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='owner')),
('parent_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_warehouses', to='documents.warehouse')),
],
options={
'verbose_name': 'warehouse',
'verbose_name_plural': 'warehouses',
},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.11 on 2024-05-20 09:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1047_warehouse_document_warehouses'),
]
operations = [
migrations.AlterField(
model_name='warehouse',
name='parent_warehouse',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subwarehouses', to='documents.warehouse'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.11 on 2024-05-20 09:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1048_alter_warehouse_parent_warehouse'),
]
operations = [
migrations.AlterField(
model_name='warehouse',
name='parent_warehouse',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subwarehouse', to='documents.warehouse'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.11 on 2024-05-20 10:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1049_alter_warehouse_parent_warehouse'),
]
operations = [
migrations.AlterField(
model_name='warehouse',
name='parent_warehouse',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='documents.warehouse'),
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 4.2.11 on 2024-05-21 07:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('documents', '1050_alter_warehouse_parent_warehouse'),
]
operations = [
migrations.AlterModelOptions(
name='warehouse',
options={'ordering': ('name',), 'verbose_name': 'warehouse', 'verbose_name_plural': 'warehouses'},
),
migrations.AddField(
model_name='warehouse',
name='is_insensitive',
field=models.BooleanField(default=True, verbose_name='is insensitive'),
),
migrations.AddField(
model_name='warehouse',
name='match',
field=models.CharField(blank=True, max_length=256, verbose_name='match'),
),
migrations.AddField(
model_name='warehouse',
name='matching_algorithm',
field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any word'), (2, 'All words'), (3, 'Exact match'), (4, 'Regular expression'), (5, 'Fuzzy word'), (6, 'Automatic')], default=1, verbose_name='matching algorithm'),
),
migrations.AlterField(
model_name='warehouse',
name='name',
field=models.CharField(max_length=128, verbose_name='name'),
),
migrations.AddConstraint(
model_name='warehouse',
constraint=models.UniqueConstraint(fields=('name', 'owner'), name='documents_warehouse_unique_name_owner'),
),
migrations.AddConstraint(
model_name='warehouse',
constraint=models.UniqueConstraint(condition=models.Q(('owner__isnull', True)), fields=('name',), name='documents_warehouse_name_uniq'),
),
]

View File

@ -129,7 +129,7 @@ class StoragePath(MatchingModel):
verbose_name = _("storage path") verbose_name = _("storage path")
verbose_name_plural = _("storage paths") verbose_name_plural = _("storage paths")
class Warehouse(ModelWithOwner): class Warehouse(MatchingModel):
WAREHOUSE = "Warehouse" WAREHOUSE = "Warehouse"
SHELF = "Shelf" SHELF = "Shelf"
@ -140,13 +140,12 @@ class Warehouse(ModelWithOwner):
(BOXCASE, _("Boxcase")), (BOXCASE, _("Boxcase")),
) )
name = models.CharField(_("name"), max_length=256, unique=True)
type = models.CharField(max_length=20, null=True, blank=True, type = models.CharField(max_length=20, null=True, blank=True,
choices=TYPE_WAREHOUSE, choices=TYPE_WAREHOUSE,
default=WAREHOUSE,) default=WAREHOUSE,)
parent_warehouse = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name="parent_warehouses" ) parent_warehouse = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True )
class Meta: class Meta(MatchingModel.Meta):
verbose_name = _("warehouse") verbose_name = _("warehouse")
verbose_name_plural = _("warehouses") verbose_name_plural = _("warehouses")

View File

@ -1756,6 +1756,10 @@ class WorkflowSerializer(serializers.ModelSerializer):
class WarehouseSerializer(MatchingModelSerializer, OwnedObjectSerializer): class WarehouseSerializer(MatchingModelSerializer, OwnedObjectSerializer):
document_count = serializers.SerializerMethodField()
def get_document_count(self,obj):
document = Document.objects.filter(warehouses=obj).count()
return document
class Meta: class Meta:
model = Warehouse model = Warehouse
@ -1763,10 +1767,17 @@ class WarehouseSerializer(MatchingModelSerializer, OwnedObjectSerializer):
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
document_count = self.get_document_count(instance)
data['document_count'] = document_count
if instance.parent_warehouse: if instance.parent_warehouse:
data['parent_warehouse'] = WarehouseSerializer(instance.parent_warehouse).data parent_serializer = self.__class__(instance.parent_warehouse)
data['parent_warehouse'] = parent_serializer.data
data['parent_warehouse']['document_count'] = document_count
else: else:
data['parent_warehouse'] = None data['parent_warehouse'] = None
return data return data

View File

@ -336,7 +336,7 @@ class DocumentViewSet(
ObjectOwnedOrGrantedPermissionsFilter, ObjectOwnedOrGrantedPermissionsFilter,
) )
filterset_class = DocumentFilterSet filterset_class = DocumentFilterSet
search_fields = ("title", "correspondent__name", "content") search_fields = ("title", "correspondent__name", "content", "warehouses")
ordering_fields = ( ordering_fields = (
"id", "id",
"title", "title",
@ -1519,9 +1519,18 @@ class BulkEditObjectsView(PassUserMixin):
"Error performing bulk permissions edit, check logs for more detail.", "Error performing bulk permissions edit, check logs for more detail.",
) )
elif operation == "delete": elif operation == "delete" and object_type == "warehouses":
documents = Document.objects.filter(warehouses__in=object_ids)
documents.delete()
objs.delete() objs.delete()
elif operation == "delete":
objs.delete()
return Response({"result": "OK"}) return Response({"result": "OK"})