revert document types
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS documents_document_type_idx;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
DROP COLUMN IF EXISTS document_type_id;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS document_types;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE TABLE document_types (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
CONSTRAINT document_types_unique_name UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ADD COLUMN document_type_id UUID REFERENCES document_types(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX documents_document_type_idx ON documents(document_type_id);
|
|
||||||
@@ -248,23 +248,6 @@ pub struct Document {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub current_version_id: Uuid,
|
pub current_version_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub document_type_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
|
||||||
#[diesel(table_name = document_types)]
|
|
||||||
pub struct DocumentType {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
|
||||||
#[diesel(table_name = document_types)]
|
|
||||||
pub struct NewDocumentType {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub tenant_id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -280,7 +263,6 @@ pub struct NewDocument {
|
|||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub document_type_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ impl OpenApi for ApiDoc {
|
|||||||
doc.merge(crate::routes::documents::DocumentsApiDoc::openapi());
|
doc.merge(crate::routes::documents::DocumentsApiDoc::openapi());
|
||||||
doc.merge(crate::routes::folders::FoldersApiDoc::openapi());
|
doc.merge(crate::routes::folders::FoldersApiDoc::openapi());
|
||||||
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
|
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
|
||||||
doc.merge(crate::routes::document_types::DocumentTypesApiDoc::openapi());
|
|
||||||
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
|
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
|
||||||
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
|
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
|
||||||
|
|
||||||
@@ -44,10 +43,6 @@ impl OpenApi for ApiDoc {
|
|||||||
.name("Tags")
|
.name("Tags")
|
||||||
.description(Some("Tag catalog"))
|
.description(Some("Tag catalog"))
|
||||||
.build(),
|
.build(),
|
||||||
TagBuilder::new()
|
|
||||||
.name("DocumentTypes")
|
|
||||||
.description(Some("Document type catalog"))
|
|
||||||
.build(),
|
|
||||||
TagBuilder::new()
|
TagBuilder::new()
|
||||||
.name("Correspondents")
|
.name("Correspondents")
|
||||||
.description(Some("Correspondent catalog"))
|
.description(Some("Correspondent catalog"))
|
||||||
@@ -82,16 +77,14 @@ pub mod schemas {
|
|||||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||||
UpdateCorrespondentRequest,
|
UpdateCorrespondentRequest,
|
||||||
};
|
};
|
||||||
pub use crate::routes::document_types::{CreateDocumentTypeRequest, UpdateDocumentTypeRequest};
|
|
||||||
pub use crate::routes::documents::{
|
pub use crate::routes::documents::{
|
||||||
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest,
|
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest,
|
||||||
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest,
|
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest,
|
||||||
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
|
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
|
||||||
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput,
|
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput,
|
||||||
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery,
|
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery,
|
||||||
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, DocumentTypeResponse,
|
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest,
|
||||||
MoveDocumentRequest, RestoreDocumentRequest, TagResponse, UpdateDocumentRequest,
|
RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm,
|
||||||
UploadDocumentForm,
|
|
||||||
};
|
};
|
||||||
pub use crate::routes::folders::{
|
pub use crate::routes::folders::{
|
||||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse,
|
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse,
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Json, Path},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use utoipa::ToSchema;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::TenantScopedConn,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
models::{DocumentType, NewDocumentType},
|
|
||||||
schema::document_types,
|
|
||||||
utils::named_entity::normalize_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::documents::DocumentTypeResponse;
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct CreateDocumentTypeRequest {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
pub struct UpdateDocumentTypeRequest {
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/document-types",
|
|
||||||
responses((status = 200, description = "Document types", body = [DocumentTypeResponse])),
|
|
||||||
tag = "DocumentTypes"
|
|
||||||
)]
|
|
||||||
pub async fn list_document_types(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<Json<Vec<DocumentTypeResponse>>> {
|
|
||||||
let types: Vec<DocumentType> = document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.order(document_types::name.asc())
|
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
Ok(Json(
|
|
||||||
types.into_iter().map(DocumentTypeResponse::from).collect(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/document-types",
|
|
||||||
request_body = CreateDocumentTypeRequest,
|
|
||||||
responses((status = 201, description = "Document type created", body = DocumentTypeResponse)),
|
|
||||||
tag = "DocumentTypes"
|
|
||||||
)]
|
|
||||||
pub async fn create_document_type(
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<CreateDocumentTypeRequest>,
|
|
||||||
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
|
|
||||||
let name = normalize_name(&payload.name, || {
|
|
||||||
AppError::bad_request("name must not be empty")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let new_type = NewDocumentType {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tenant_id,
|
|
||||||
name: name.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match diesel::insert_into(document_types::table)
|
|
||||||
.values(&new_type)
|
|
||||||
.get_result::<DocumentType>(&mut conn)
|
|
||||||
{
|
|
||||||
Ok(created) => Ok((
|
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(DocumentTypeResponse::from(created)),
|
|
||||||
)),
|
|
||||||
Err(diesel::result::Error::DatabaseError(
|
|
||||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
|
||||||
_,
|
|
||||||
)) => Err(
|
|
||||||
AppError::conflict("a document type with that name already exists")
|
|
||||||
.with_code("duplicate_document_type"),
|
|
||||||
),
|
|
||||||
Err(err) => Err(AppError::from(err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch,
|
|
||||||
path = "/api/document-types/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Document type ID")),
|
|
||||||
request_body = UpdateDocumentTypeRequest,
|
|
||||||
responses((status = 200, description = "Document type updated", body = DocumentTypeResponse)),
|
|
||||||
tag = "DocumentTypes"
|
|
||||||
)]
|
|
||||||
pub async fn update_document_type(
|
|
||||||
Path(document_type_id): Path<Uuid>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
Json(payload): Json<UpdateDocumentTypeRequest>,
|
|
||||||
) -> AppResult<Json<DocumentTypeResponse>> {
|
|
||||||
let name_raw = payload
|
|
||||||
.name
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::bad_request("name must be provided and not empty"))?;
|
|
||||||
let name = normalize_name(name_raw, || {
|
|
||||||
AppError::bad_request("name must be provided and not empty")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let target = document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.find(document_type_id);
|
|
||||||
|
|
||||||
let update_result = diesel::update(target.clone())
|
|
||||||
.set(document_types::name.eq(&name))
|
|
||||||
.get_result::<DocumentType>(&mut conn);
|
|
||||||
|
|
||||||
match update_result {
|
|
||||||
Ok(updated) => Ok(Json(DocumentTypeResponse::from(updated))),
|
|
||||||
Err(diesel::result::Error::NotFound) => Err(AppError::not_found()),
|
|
||||||
Err(diesel::result::Error::DatabaseError(
|
|
||||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
|
||||||
_,
|
|
||||||
)) => Err(
|
|
||||||
AppError::conflict("a document type with that name already exists")
|
|
||||||
.with_code("duplicate_document_type"),
|
|
||||||
),
|
|
||||||
Err(err) => Err(AppError::from(err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/document-types/{id}",
|
|
||||||
params(("id" = Uuid, Path, description = "Document type ID")),
|
|
||||||
responses((status = 204, description = "Document type deleted")),
|
|
||||||
tag = "DocumentTypes"
|
|
||||||
)]
|
|
||||||
pub async fn delete_document_type(
|
|
||||||
Path(document_type_id): Path<Uuid>,
|
|
||||||
TenantScopedConn {
|
|
||||||
mut conn,
|
|
||||||
tenant_id,
|
|
||||||
..
|
|
||||||
}: TenantScopedConn,
|
|
||||||
) -> AppResult<StatusCode> {
|
|
||||||
let affected = diesel::delete(
|
|
||||||
document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.find(document_type_id),
|
|
||||||
)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
if affected == 0 {
|
|
||||||
return Err(AppError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
|
||||||
#[openapi(
|
|
||||||
paths(
|
|
||||||
crate::routes::document_types::list_document_types,
|
|
||||||
crate::routes::document_types::create_document_type,
|
|
||||||
crate::routes::document_types::update_document_type,
|
|
||||||
crate::routes::document_types::delete_document_type
|
|
||||||
),
|
|
||||||
components(schemas(
|
|
||||||
crate::routes::document_types::CreateDocumentTypeRequest,
|
|
||||||
crate::routes::document_types::UpdateDocumentTypeRequest,
|
|
||||||
crate::routes::documents::DocumentTypeResponse
|
|
||||||
))
|
|
||||||
)]
|
|
||||||
pub struct DocumentTypesApiDoc;
|
|
||||||
@@ -37,13 +37,12 @@ use crate::documents::{
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT};
|
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentType, DocumentVersion, NewDocument,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
|
||||||
NewDocumentTag, NewDocumentVersion, Tag,
|
NewDocumentVersion, Tag,
|
||||||
};
|
};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_asset_objects, document_assets, document_correspondents, document_tags,
|
document_asset_objects, document_assets, document_correspondents, document_tags,
|
||||||
document_types, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl,
|
document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
||||||
tags,
|
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
@@ -68,7 +67,6 @@ pub struct DocumentListQuery {
|
|||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub tags: Option<String>,
|
pub tags: Option<String>,
|
||||||
pub correspondents: Option<String>,
|
pub correspondents: Option<String>,
|
||||||
pub document_types: Option<String>,
|
|
||||||
#[serde(default = "default_document_status_filter")]
|
#[serde(default = "default_document_status_filter")]
|
||||||
#[schema(default = "active")]
|
#[schema(default = "active")]
|
||||||
pub status: DocumentStatusFilter,
|
pub status: DocumentStatusFilter,
|
||||||
@@ -141,21 +139,6 @@ impl From<Tag> for TagResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct DocumentTypeResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DocumentType> for DocumentTypeResponse {
|
|
||||||
fn from(value: DocumentType) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id,
|
|
||||||
name: value.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct DocumentResponse {
|
pub struct DocumentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -174,12 +157,6 @@ pub struct DocumentResponse {
|
|||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = Object)]
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub document_type_id: Option<Uuid>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub document_type: Option<DocumentTypeResponse>,
|
|
||||||
pub tags: Vec<TagResponse>,
|
pub tags: Vec<TagResponse>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
@@ -223,9 +200,6 @@ pub struct UpdateDocumentRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schema(nullable, value_type = Object)]
|
#[schema(nullable, value_type = Object)]
|
||||||
pub metadata: Option<DocumentMetadataUpdate>,
|
pub metadata: Option<DocumentMetadataUpdate>,
|
||||||
#[serde(default)]
|
|
||||||
#[schema(nullable, value_type = Option<Uuid>)]
|
|
||||||
pub document_type_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -280,7 +254,6 @@ struct DocumentUpdateChangeset {
|
|||||||
issued_at: Option<Option<NaiveDateTime>>,
|
issued_at: Option<Option<NaiveDateTime>>,
|
||||||
metadata: Option<Value>,
|
metadata: Option<Value>,
|
||||||
updated_at: Option<NaiveDateTime>,
|
updated_at: Option<NaiveDateTime>,
|
||||||
document_type_id: Option<Option<Uuid>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||||
@@ -325,7 +298,6 @@ struct UploadRequest {
|
|||||||
correspondents: Vec<CorrespondentAssignmentInput>,
|
correspondents: Vec<CorrespondentAssignmentInput>,
|
||||||
issued_at_override: Option<NaiveDateTime>,
|
issued_at_override: Option<NaiveDateTime>,
|
||||||
skip_if_existing: bool,
|
skip_if_existing: bool,
|
||||||
document_type_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UploadOutcome {
|
enum UploadOutcome {
|
||||||
@@ -402,7 +374,6 @@ pub async fn list_documents(
|
|||||||
query,
|
query,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
document_types,
|
|
||||||
status,
|
status,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
@@ -435,12 +406,6 @@ pub async fn list_documents(
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_owned());
|
.map(|s| s.to_owned());
|
||||||
let document_types_param = document_types
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_owned());
|
|
||||||
|
|
||||||
let include_descendants = include_descendants.unwrap_or(true);
|
let include_descendants = include_descendants.unwrap_or(true);
|
||||||
|
|
||||||
match (folder_id, include_descendants) {
|
match (folder_id, include_descendants) {
|
||||||
@@ -579,20 +544,6 @@ pub async fn list_documents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(document_types_param) = document_types_param.as_ref() {
|
|
||||||
let type_ids: Result<Vec<Uuid>, _> = document_types_param
|
|
||||||
.split(',')
|
|
||||||
.map(|s| Uuid::parse_str(s.trim()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if let Ok(ids) = type_ids {
|
|
||||||
if !ids.is_empty() {
|
|
||||||
let filter_values: Vec<Option<Uuid>> = ids.into_iter().map(Some).collect();
|
|
||||||
docs_query = docs_query.filter(documents::document_type_id.eq_any(filter_values));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref set) = filter_ids {
|
if let Some(ref set) = filter_ids {
|
||||||
if set.is_empty() {
|
if set.is_empty() {
|
||||||
return Ok(Json(vec![]));
|
return Ok(Json(vec![]));
|
||||||
@@ -735,8 +686,6 @@ pub async fn get_document(
|
|||||||
.find(doc.current_version_id)
|
.find(doc.current_version_id)
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let doc_type = load_document_type(&mut conn, tenant_id, doc.document_type_id)?;
|
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
|
||||||
let version_id = current_version.id;
|
let version_id = current_version.id;
|
||||||
@@ -753,7 +702,6 @@ pub async fn get_document(
|
|||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
correspondents_map.remove(&document_id).unwrap_or_default(),
|
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
doc_type,
|
|
||||||
)?,
|
)?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -786,7 +734,6 @@ pub async fn upload_document(
|
|||||||
let mut issued_at_override: Option<NaiveDateTime> = None;
|
let mut issued_at_override: Option<NaiveDateTime> = None;
|
||||||
let mut skip_if_existing = false;
|
let mut skip_if_existing = false;
|
||||||
let mut title_override: Option<String> = None;
|
let mut title_override: Option<String> = None;
|
||||||
let mut document_type_id: Option<Uuid> = None;
|
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
||||||
let msg = format!("invalid multipart data: {err}");
|
let msg = format!("invalid multipart data: {err}");
|
||||||
@@ -905,20 +852,6 @@ pub async fn upload_document(
|
|||||||
"1" | "true" | "yes"
|
"1" | "true" | "yes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some("document_type_id") => {
|
|
||||||
let value = field.text().await.map_err(|err| {
|
|
||||||
let msg = format!("invalid document_type_id: {err}");
|
|
||||||
error!(error = %err, "invalid document_type payload");
|
|
||||||
AppError::bad_request(msg)
|
|
||||||
})?;
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
let parsed = Uuid::parse_str(trimmed).map_err(|_| {
|
|
||||||
AppError::bad_request("document_type_id must be a valid UUID")
|
|
||||||
})?;
|
|
||||||
document_type_id = Some(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -949,7 +882,6 @@ pub async fn upload_document(
|
|||||||
correspondents,
|
correspondents,
|
||||||
issued_at_override,
|
issued_at_override,
|
||||||
skip_if_existing,
|
skip_if_existing,
|
||||||
document_type_id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
|
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
|
||||||
@@ -1482,46 +1414,6 @@ pub async fn update_document(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc_type_update =
|
|
||||||
classify_nullable(payload_obj.get("document_type_id")).map_err(AppError::bad_request)?;
|
|
||||||
|
|
||||||
match doc_type_update {
|
|
||||||
NullableValue::Omitted => {}
|
|
||||||
NullableValue::Null => {
|
|
||||||
if document.document_type_id.is_some() {
|
|
||||||
changes.document_type_id = Some(None);
|
|
||||||
has_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NullableValue::String(raw) => {
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"document_type_id must not be empty when provided",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let parsed = Uuid::parse_str(trimmed)
|
|
||||||
.map_err(|_| AppError::bad_request("document_type_id must be a valid UUID"))?;
|
|
||||||
|
|
||||||
if document.document_type_id != Some(parsed) {
|
|
||||||
let exists = document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.find(parsed)
|
|
||||||
.first::<DocumentType>(&mut conn)
|
|
||||||
.optional()?;
|
|
||||||
|
|
||||||
if exists.is_none() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"document_type_id does not exist for this tenant",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
changes.document_type_id = Some(Some(parsed));
|
|
||||||
has_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !has_changes {
|
if !has_changes {
|
||||||
return Err(AppError::bad_request("no changes provided"));
|
return Err(AppError::bad_request("no changes provided"));
|
||||||
}
|
}
|
||||||
@@ -1555,8 +1447,6 @@ pub async fn update_document(
|
|||||||
.find(document.current_version_id)
|
.find(document.current_version_id)
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
|
|
||||||
|
|
||||||
if title_changed {
|
if title_changed {
|
||||||
if let Err(err) = enqueue_job(
|
if let Err(err) = enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
@@ -1593,7 +1483,6 @@ pub async fn update_document(
|
|||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
correspondents_map.remove(&document_id).unwrap_or_default(),
|
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
doc_type,
|
|
||||||
)?,
|
)?,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -2195,23 +2084,8 @@ async fn process_upload(
|
|||||||
correspondents,
|
correspondents,
|
||||||
issued_at_override,
|
issued_at_override,
|
||||||
skip_if_existing,
|
skip_if_existing,
|
||||||
document_type_id,
|
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
if let Some(type_id) = document_type_id {
|
|
||||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
|
||||||
let exists = document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.find(type_id)
|
|
||||||
.first::<DocumentType>(&mut conn)
|
|
||||||
.optional()?;
|
|
||||||
if exists.is_none() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"document_type_id does not exist for this tenant",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(folder) = folder_id {
|
if let Some(folder) = folder_id {
|
||||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?;
|
ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?;
|
||||||
@@ -2292,24 +2166,6 @@ async fn process_upload(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(type_id) = document_type_id {
|
|
||||||
if document.document_type_id != Some(type_id) {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
diesel::update(
|
|
||||||
documents::table
|
|
||||||
.find(document.id)
|
|
||||||
.filter(documents::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.set((
|
|
||||||
documents::document_type_id.eq(Some(type_id)),
|
|
||||||
documents::updated_at.eq(now),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
document.document_type_id = Some(type_id);
|
|
||||||
document.updated_at = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if document.deleted_at.is_some() {
|
if document.deleted_at.is_some() {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
diesel::update(documents::table.find(document.id))
|
diesel::update(documents::table.find(document.id))
|
||||||
@@ -2322,7 +2178,6 @@ async fn process_upload(
|
|||||||
document.updated_at = now;
|
document.updated_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
|
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
|
||||||
let mut correspondents_map =
|
let mut correspondents_map =
|
||||||
load_correspondents_for_documents(&mut conn, &[document.id])?;
|
load_correspondents_for_documents(&mut conn, &[document.id])?;
|
||||||
@@ -2346,7 +2201,6 @@ async fn process_upload(
|
|||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
doc_type,
|
|
||||||
)?,
|
)?,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -2386,7 +2240,6 @@ async fn process_upload(
|
|||||||
title: derived_title.clone(),
|
title: derived_title.clone(),
|
||||||
metadata: metadata_value.clone(),
|
metadata: metadata_value.clone(),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
document_type_id,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(documents::table)
|
diesel::insert_into(documents::table)
|
||||||
.values(&new_document)
|
.values(&new_document)
|
||||||
@@ -2445,7 +2298,6 @@ async fn process_upload(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[doc_id])?;
|
let tags_map = load_tags_for_documents(&mut conn, &[doc_id])?;
|
||||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?;
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?;
|
||||||
let tags = tags_map.get(&doc_id).cloned();
|
let tags = tags_map.get(&doc_id).cloned();
|
||||||
@@ -2460,7 +2312,6 @@ async fn process_upload(
|
|||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
Some((to_version_response(version.clone()), Vec::new())),
|
Some((to_version_response(version.clone()), Vec::new())),
|
||||||
doc_type,
|
|
||||||
)?,
|
)?,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2493,7 +2344,6 @@ pub(crate) fn to_document_response(
|
|||||||
tags: Option<Vec<Tag>>,
|
tags: Option<Vec<Tag>>,
|
||||||
correspondents: Vec<DocumentCorrespondentResponse>,
|
correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
||||||
doc_type: Option<DocumentType>,
|
|
||||||
) -> AppResult<DocumentResponse> {
|
) -> AppResult<DocumentResponse> {
|
||||||
let current_version = if let Some((version, assets)) = current_version {
|
let current_version = if let Some((version, assets)) = current_version {
|
||||||
let download_path = build_download_path(state, &doc, user_id)?;
|
let download_path = build_download_path(state, &doc, user_id)?;
|
||||||
@@ -2518,11 +2368,6 @@ pub(crate) fn to_document_response(
|
|||||||
deleted_at: doc.deleted_at.map(to_iso),
|
deleted_at: doc.deleted_at.map(to_iso),
|
||||||
issued_at: doc.issued_at.map(to_iso),
|
issued_at: doc.issued_at.map(to_iso),
|
||||||
metadata: doc.metadata,
|
metadata: doc.metadata,
|
||||||
document_type_id: doc.document_type_id,
|
|
||||||
document_type: doc_type.map(|typ| DocumentTypeResponse {
|
|
||||||
id: typ.id,
|
|
||||||
name: typ.name,
|
|
||||||
}),
|
|
||||||
tags: tags
|
tags: tags
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2533,22 +2378,6 @@ pub(crate) fn to_document_response(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_document_type(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
type_id: Option<Uuid>,
|
|
||||||
) -> diesel::QueryResult<Option<DocumentType>> {
|
|
||||||
if let Some(id) = type_id {
|
|
||||||
document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.find(id)
|
|
||||||
.first::<DocumentType>(conn)
|
|
||||||
.optional()
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn hydrate_documents(
|
pub(crate) fn hydrate_documents(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -2560,20 +2389,6 @@ pub(crate) fn hydrate_documents(
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
|
|
||||||
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
|
|
||||||
HashMap::new()
|
|
||||||
} else {
|
|
||||||
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
|
|
||||||
document_types::table
|
|
||||||
.filter(document_types::tenant_id.eq(tenant_id))
|
|
||||||
.filter(document_types::id.eq_any(&ids))
|
|
||||||
.load::<DocumentType>(conn)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|typ| (typ.id, typ))
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||||
let tags_map = load_tags_for_documents(conn, &doc_ids)?;
|
let tags_map = load_tags_for_documents(conn, &doc_ids)?;
|
||||||
let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?;
|
let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?;
|
||||||
@@ -2584,9 +2399,6 @@ pub(crate) fn hydrate_documents(
|
|||||||
let tags = tags_map.get(&doc.id).cloned();
|
let tags = tags_map.get(&doc.id).cloned();
|
||||||
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
||||||
let current_version = primary_versions.get(&doc.id).cloned();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
let doc_type = doc
|
|
||||||
.document_type_id
|
|
||||||
.and_then(|id| doc_type_map.get(&id).cloned());
|
|
||||||
responses.push(to_document_response(
|
responses.push(to_document_response(
|
||||||
state,
|
state,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -2594,7 +2406,6 @@ pub(crate) fn hydrate_documents(
|
|||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
current_version,
|
current_version,
|
||||||
doc_type,
|
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2636,7 +2447,6 @@ pub(crate) fn hydrate_documents(
|
|||||||
crate::routes::documents::DocumentResponse,
|
crate::routes::documents::DocumentResponse,
|
||||||
crate::routes::documents::DocumentDetailResponse,
|
crate::routes::documents::DocumentDetailResponse,
|
||||||
crate::routes::documents::DocumentMetadataUpdate,
|
crate::routes::documents::DocumentMetadataUpdate,
|
||||||
crate::routes::documents::DocumentTypeResponse,
|
|
||||||
crate::routes::documents::TagResponse,
|
crate::routes::documents::TagResponse,
|
||||||
crate::routes::documents::CorrespondentAssignmentInput,
|
crate::routes::documents::CorrespondentAssignmentInput,
|
||||||
crate::routes::documents::AssignCorrespondentsRequest,
|
crate::routes::documents::AssignCorrespondentsRequest,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod correspondents;
|
pub mod correspondents;
|
||||||
pub mod document_types;
|
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
@@ -133,17 +132,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/", get(tags::list_tags).post(tags::create_tag))
|
.route("/", get(tags::list_tags).post(tags::create_tag))
|
||||||
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
|
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
|
||||||
|
|
||||||
let document_types_routes = Router::new()
|
|
||||||
.route(
|
|
||||||
"/",
|
|
||||||
get(document_types::list_document_types).post(document_types::create_document_type),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/:id",
|
|
||||||
patch(document_types::update_document_type)
|
|
||||||
.delete(document_types::delete_document_type),
|
|
||||||
);
|
|
||||||
|
|
||||||
let correspondents_routes = Router::new()
|
let correspondents_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
@@ -171,7 +159,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/document-types", document_types_routes)
|
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
.nest("/api/profile", profile_routes)
|
.nest("/api/profile", profile_routes)
|
||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
|
|||||||
@@ -62,15 +62,6 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
document_types (id) {
|
|
||||||
id -> Uuid,
|
|
||||||
tenant_id -> Uuid,
|
|
||||||
#[max_length = 100]
|
|
||||||
name -> Varchar,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
document_versions (id) {
|
document_versions (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -106,7 +97,6 @@ diesel::table! {
|
|||||||
title -> Varchar,
|
title -> Varchar,
|
||||||
current_version_id -> Uuid,
|
current_version_id -> Uuid,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
document_type_id -> Nullable<Uuid>,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +250,7 @@ diesel::joinable!(document_tags -> documents (document_id));
|
|||||||
diesel::joinable!(document_tags -> tags (tag_id));
|
diesel::joinable!(document_tags -> tags (tag_id));
|
||||||
diesel::joinable!(document_tags -> tenants (tenant_id));
|
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_tags -> users (assigned_by));
|
diesel::joinable!(document_tags -> users (assigned_by));
|
||||||
diesel::joinable!(document_types -> tenants (tenant_id));
|
|
||||||
diesel::joinable!(document_versions -> tenants (tenant_id));
|
diesel::joinable!(document_versions -> tenants (tenant_id));
|
||||||
diesel::joinable!(documents -> document_types (document_type_id));
|
|
||||||
diesel::joinable!(documents -> folders (folder_id));
|
diesel::joinable!(documents -> folders (folder_id));
|
||||||
diesel::joinable!(documents -> tenants (tenant_id));
|
diesel::joinable!(documents -> tenants (tenant_id));
|
||||||
diesel::joinable!(folders -> tenants (tenant_id));
|
diesel::joinable!(folders -> tenants (tenant_id));
|
||||||
@@ -283,7 +271,6 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
document_assets,
|
document_assets,
|
||||||
document_correspondents,
|
document_correspondents,
|
||||||
document_tags,
|
document_tags,
|
||||||
document_types,
|
|
||||||
document_versions,
|
document_versions,
|
||||||
documents,
|
documents,
|
||||||
folders,
|
folders,
|
||||||
|
|||||||
@@ -538,7 +538,6 @@ impl TestApp {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: false,
|
||||||
document_type_id: None,
|
|
||||||
};
|
};
|
||||||
self.upload_document_with_extras(
|
self.upload_document_with_extras(
|
||||||
path,
|
path,
|
||||||
@@ -618,13 +617,6 @@ impl TestApp {
|
|||||||
body.extend(b"\r\n");
|
body.extend(b"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(document_type_id) = extras.document_type_id {
|
|
||||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
|
||||||
body.extend(b"Content-Disposition: form-data; name=\"document_type_id\"\r\n\r\n");
|
|
||||||
body.extend(document_type_id.to_string().as_bytes());
|
|
||||||
body.extend(b"\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
if extras.skip_existing {
|
if extras.skip_existing {
|
||||||
body.extend(format!("--{boundary}\r\n").as_bytes());
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
|
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
|
||||||
@@ -674,7 +666,6 @@ pub struct UploadExtras<'a> {
|
|||||||
pub correspondents_json: Option<&'a str>,
|
pub correspondents_json: Option<&'a str>,
|
||||||
pub issued_at: Option<&'a str>,
|
pub issued_at: Option<&'a str>,
|
||||||
pub skip_existing: bool,
|
pub skip_existing: bool,
|
||||||
pub document_type_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> UploadExtras<'a> {
|
impl<'a> UploadExtras<'a> {
|
||||||
@@ -686,7 +677,6 @@ impl<'a> UploadExtras<'a> {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: false,
|
||||||
document_type_id: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct DocumentTypeInfo {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct ApiErrorResponse {
|
|
||||||
error: String,
|
|
||||||
#[serde(default)]
|
|
||||||
code: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn document_type_crud_flow() -> Result<()> {
|
|
||||||
let _lock = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let password = "doctypecrud";
|
|
||||||
let username = "doctype_admin";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let create_invoices = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Invoices"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(create_invoices.status(), StatusCode::CREATED);
|
|
||||||
let invoices_body = body_to_vec(create_invoices.into_body()).await?;
|
|
||||||
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
|
|
||||||
let invoices_id = invoices.id;
|
|
||||||
|
|
||||||
let create_receipts = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Receipts"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(create_receipts.status(), StatusCode::CREATED);
|
|
||||||
let receipts_body = body_to_vec(create_receipts.into_body()).await?;
|
|
||||||
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
|
|
||||||
|
|
||||||
let list_resp = app.get("/api/document-types", Some(&token)).await?;
|
|
||||||
assert!(list_resp.status().is_success());
|
|
||||||
let list_body = body_to_vec(list_resp.into_body()).await?;
|
|
||||||
let mut all_types: Vec<DocumentTypeInfo> = serde_json::from_slice(&list_body)?;
|
|
||||||
all_types.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
assert_eq!(all_types.len(), 2);
|
|
||||||
assert_eq!(all_types[0].name, "Invoices");
|
|
||||||
assert_eq!(all_types[1].name, "Receipts");
|
|
||||||
|
|
||||||
let update_resp = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/document-types/{}", invoices_id),
|
|
||||||
&json!({"name": "Bills"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(update_resp.status(), StatusCode::OK);
|
|
||||||
let update_body = body_to_vec(update_resp.into_body()).await?;
|
|
||||||
let updated: DocumentTypeInfo = serde_json::from_slice(&update_body)?;
|
|
||||||
assert_eq!(updated.id, invoices_id);
|
|
||||||
assert_eq!(updated.name, "Bills");
|
|
||||||
|
|
||||||
let conflict_resp = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/document-types/{}", receipts.id),
|
|
||||||
&json!({"name": "Bills"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(conflict_resp.status(), StatusCode::CONFLICT);
|
|
||||||
let conflict_body = body_to_vec(conflict_resp.into_body()).await?;
|
|
||||||
let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?;
|
|
||||||
assert_eq!(
|
|
||||||
conflict_json.error,
|
|
||||||
"a document type with that name already exists"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
conflict_json.code.as_deref(),
|
|
||||||
Some("duplicate_document_type")
|
|
||||||
);
|
|
||||||
|
|
||||||
let delete_receipts = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/document-types/{}", receipts.id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_receipts.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let list_after_resp = app.get("/api/document-types", Some(&token)).await?;
|
|
||||||
assert!(list_after_resp.status().is_success());
|
|
||||||
let list_after_body = body_to_vec(list_after_resp.into_body()).await?;
|
|
||||||
let list_after: Vec<DocumentTypeInfo> = serde_json::from_slice(&list_after_body)?;
|
|
||||||
assert_eq!(list_after.len(), 1);
|
|
||||||
assert_eq!(list_after[0].id, invoices_id);
|
|
||||||
assert_eq!(list_after[0].name, "Bills");
|
|
||||||
|
|
||||||
let delete_bills = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/document-types/{}", invoices_id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_bills.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let final_list_resp = app.get("/api/document-types", Some(&token)).await?;
|
|
||||||
let final_list_body = body_to_vec(final_list_resp.into_body()).await?;
|
|
||||||
let final_list: Vec<DocumentTypeInfo> = serde_json::from_slice(&final_list_body)?;
|
|
||||||
assert!(final_list.is_empty());
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -30,21 +30,11 @@ struct DocumentInfo {
|
|||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
metadata: Value,
|
metadata: Value,
|
||||||
#[serde(default)]
|
|
||||||
document_type_id: Option<Uuid>,
|
|
||||||
#[serde(default)]
|
|
||||||
document_type: Option<DocumentTypeInfo>,
|
|
||||||
tags: Vec<TagSummary>,
|
tags: Vec<TagSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
current_version: Option<DocumentVersionPayload>,
|
current_version: Option<DocumentVersionPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct DocumentTypeInfo {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentVersionPayload {
|
struct DocumentVersionPayload {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -72,8 +62,6 @@ struct DocumentAssetInfo {
|
|||||||
struct DocumentListItem {
|
struct DocumentListItem {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
document_type_id: Option<Uuid>,
|
|
||||||
#[serde(default)]
|
|
||||||
current_version: Option<DocumentVersionPayload>,
|
current_version: Option<DocumentVersionPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +174,6 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
assert_eq!(detail.document.title, "doc");
|
assert_eq!(detail.document.title, "doc");
|
||||||
assert_eq!(detail.document.deleted_at, None);
|
assert_eq!(detail.document.deleted_at, None);
|
||||||
assert!(detail.document.issued_at.is_none());
|
assert!(detail.document.issued_at.is_none());
|
||||||
assert!(detail.document.document_type_id.is_none());
|
|
||||||
assert!(detail.document.tags.is_empty());
|
assert!(detail.document.tags.is_empty());
|
||||||
let current_version = detail
|
let current_version = detail
|
||||||
.document
|
.document
|
||||||
@@ -420,7 +407,6 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: false,
|
||||||
document_type_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let first_upload = app
|
let first_upload = app
|
||||||
@@ -471,7 +457,6 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
|
|||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: true,
|
skip_existing: true,
|
||||||
document_type_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let skip_resp = app
|
let skip_resp = app
|
||||||
@@ -1453,298 +1438,6 @@ async fn list_documents_by_status_filter() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn document_upload_and_patch_document_type() -> Result<()> {
|
|
||||||
let _lock = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let password = "doctypeupload";
|
|
||||||
let username = "doctype_user";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let invoices_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Invoices"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(invoices_resp.status(), StatusCode::CREATED);
|
|
||||||
let invoices_body = body_to_vec(invoices_resp.into_body()).await?;
|
|
||||||
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
|
|
||||||
|
|
||||||
let receipts_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Receipts"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
|
|
||||||
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
|
|
||||||
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
|
|
||||||
|
|
||||||
let upload_extras = UploadExtras {
|
|
||||||
title: Some("Invoice #1"),
|
|
||||||
metadata_json: None,
|
|
||||||
tag_ids_json: None,
|
|
||||||
correspondents_json: None,
|
|
||||||
issued_at: None,
|
|
||||||
skip_existing: false,
|
|
||||||
document_type_id: Some(invoices.id),
|
|
||||||
};
|
|
||||||
|
|
||||||
let upload_resp = app
|
|
||||||
.upload_document_with_extras(
|
|
||||||
"/api/documents",
|
|
||||||
"invoice.pdf",
|
|
||||||
"application/pdf",
|
|
||||||
b"invoice-bytes",
|
|
||||||
None,
|
|
||||||
upload_extras,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(upload_resp.status().is_success());
|
|
||||||
let upload_body = body_to_vec(upload_resp.into_body()).await?;
|
|
||||||
let mut detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
|
||||||
assert_eq!(detail.document.document_type_id, Some(invoices.id));
|
|
||||||
let doc_type_info = detail
|
|
||||||
.document
|
|
||||||
.document_type
|
|
||||||
.as_ref()
|
|
||||||
.expect("document type present");
|
|
||||||
assert_eq!(doc_type_info.name, "Invoices");
|
|
||||||
|
|
||||||
let document_id = detail.document.id;
|
|
||||||
|
|
||||||
let update_resp = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/documents/{}", document_id),
|
|
||||||
&json!({"document_type_id": receipts.id}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(update_resp.status().is_success());
|
|
||||||
let update_body = body_to_vec(update_resp.into_body()).await?;
|
|
||||||
detail = serde_json::from_slice(&update_body)?;
|
|
||||||
assert_eq!(detail.document.document_type_id, Some(receipts.id));
|
|
||||||
assert_eq!(
|
|
||||||
detail
|
|
||||||
.document
|
|
||||||
.document_type
|
|
||||||
.as_ref()
|
|
||||||
.map(|info| info.name.as_str()),
|
|
||||||
Some("Receipts")
|
|
||||||
);
|
|
||||||
|
|
||||||
let clear_resp = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/documents/{}", document_id),
|
|
||||||
&json!({"document_type_id": null}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(clear_resp.status().is_success());
|
|
||||||
let clear_body = body_to_vec(clear_resp.into_body()).await?;
|
|
||||||
detail = serde_json::from_slice(&clear_body)?;
|
|
||||||
assert!(detail.document.document_type_id.is_none());
|
|
||||||
assert!(detail.document.document_type.is_none());
|
|
||||||
|
|
||||||
let invalid_id = Uuid::new_v4();
|
|
||||||
let invalid_resp = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/documents/{}", document_id),
|
|
||||||
&json!({"document_type_id": invalid_id}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(invalid_resp.status(), StatusCode::BAD_REQUEST);
|
|
||||||
let invalid_body = body_to_vec(invalid_resp.into_body()).await?;
|
|
||||||
let invalid_error: ApiErrorResponse = serde_json::from_slice(&invalid_body)?;
|
|
||||||
assert_eq!(
|
|
||||||
invalid_error.error,
|
|
||||||
"document_type_id does not exist for this tenant"
|
|
||||||
);
|
|
||||||
|
|
||||||
let final_detail_resp = app
|
|
||||||
.get(&format!("/api/documents/{}", document_id), Some(&token))
|
|
||||||
.await?;
|
|
||||||
let final_detail_body = body_to_vec(final_detail_resp.into_body()).await?;
|
|
||||||
let final_detail: DocumentDetail = serde_json::from_slice(&final_detail_body)?;
|
|
||||||
assert!(final_detail.document.document_type_id.is_none());
|
|
||||||
assert!(final_detail.document.document_type.is_none());
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_documents_filtered_by_document_type() -> Result<()> {
|
|
||||||
let _lock = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let password = "doctypefilter";
|
|
||||||
app.insert_user("doctypeuser", password, "admin").await?;
|
|
||||||
let token = app.login_token("doctypeuser", password).await?;
|
|
||||||
|
|
||||||
let invoices_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Invoices"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(invoices_resp.status(), StatusCode::CREATED);
|
|
||||||
let invoices_body = body_to_vec(invoices_resp.into_body()).await?;
|
|
||||||
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
|
|
||||||
|
|
||||||
let receipts_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/document-types",
|
|
||||||
&json!({"name": "Receipts"}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
|
|
||||||
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
|
|
||||||
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
|
|
||||||
assert_eq!(invoices.name, "Invoices");
|
|
||||||
assert_eq!(receipts.name, "Receipts");
|
|
||||||
|
|
||||||
let doc_a = app
|
|
||||||
.upload_document(
|
|
||||||
"/api/documents",
|
|
||||||
"invoice-a.pdf",
|
|
||||||
"application/pdf",
|
|
||||||
b"invoice-a",
|
|
||||||
None,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let doc_a_body = body_to_vec(doc_a.into_body()).await?;
|
|
||||||
let detail_a: DocumentDetail = serde_json::from_slice(&doc_a_body)?;
|
|
||||||
|
|
||||||
let doc_b = app
|
|
||||||
.upload_document(
|
|
||||||
"/api/documents",
|
|
||||||
"receipt-b.pdf",
|
|
||||||
"application/pdf",
|
|
||||||
b"receipt-b",
|
|
||||||
None,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let doc_b_body = body_to_vec(doc_b.into_body()).await?;
|
|
||||||
let detail_b: DocumentDetail = serde_json::from_slice(&doc_b_body)?;
|
|
||||||
|
|
||||||
let doc_c = app
|
|
||||||
.upload_document(
|
|
||||||
"/api/documents",
|
|
||||||
"notes.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"notes",
|
|
||||||
None,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let doc_c_body = body_to_vec(doc_c.into_body()).await?;
|
|
||||||
let detail_c: DocumentDetail = serde_json::from_slice(&doc_c_body)?;
|
|
||||||
|
|
||||||
let assign_a = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/documents/{}", detail_a.document.id),
|
|
||||||
&json!({"document_type_id": invoices.id}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(assign_a.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let assign_b = app
|
|
||||||
.patch_json(
|
|
||||||
&format!("/api/documents/{}", detail_b.document.id),
|
|
||||||
&json!({"document_type_id": receipts.id}),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(assign_b.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let invoices_resp = app
|
|
||||||
.get(
|
|
||||||
&format!("/api/documents?document_types={}", invoices.id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(invoices_resp.status().is_success());
|
|
||||||
let invoices_list_body = body_to_vec(invoices_resp.into_body()).await?;
|
|
||||||
let invoices_docs: Vec<DocumentListItem> = serde_json::from_slice(&invoices_list_body)?;
|
|
||||||
assert_eq!(invoices_docs.len(), 1);
|
|
||||||
assert_eq!(invoices_docs[0].id, detail_a.document.id);
|
|
||||||
assert_eq!(invoices_docs[0].document_type_id, Some(invoices.id));
|
|
||||||
|
|
||||||
let receipts_resp = app
|
|
||||||
.get(
|
|
||||||
&format!("/api/documents?document_types={}", receipts.id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(receipts_resp.status().is_success());
|
|
||||||
let receipts_list_body = body_to_vec(receipts_resp.into_body()).await?;
|
|
||||||
let receipts_docs: Vec<DocumentListItem> = serde_json::from_slice(&receipts_list_body)?;
|
|
||||||
assert_eq!(receipts_docs.len(), 1);
|
|
||||||
assert_eq!(receipts_docs[0].id, detail_b.document.id);
|
|
||||||
assert_eq!(receipts_docs[0].document_type_id, Some(receipts.id));
|
|
||||||
|
|
||||||
let combined_resp = app
|
|
||||||
.get(
|
|
||||||
&format!(
|
|
||||||
"/api/documents?document_types={},{}",
|
|
||||||
invoices.id, receipts.id
|
|
||||||
),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(combined_resp.status().is_success());
|
|
||||||
let combined_body = body_to_vec(combined_resp.into_body()).await?;
|
|
||||||
let combined_docs: Vec<DocumentListItem> = serde_json::from_slice(&combined_body)?;
|
|
||||||
let combined_ids: Vec<Uuid> = combined_docs.iter().map(|doc| doc.id).collect();
|
|
||||||
assert!(combined_ids.contains(&detail_a.document.id));
|
|
||||||
assert!(combined_ids.contains(&detail_b.document.id));
|
|
||||||
assert!(!combined_ids.contains(&detail_c.document.id));
|
|
||||||
|
|
||||||
let refreshed_a = app
|
|
||||||
.get(
|
|
||||||
&format!("/api/documents/{}", detail_a.document.id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(refreshed_a.status().is_success());
|
|
||||||
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
|
|
||||||
let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?;
|
|
||||||
assert_eq!(
|
|
||||||
refreshed_a_detail.document.document_type_id,
|
|
||||||
Some(invoices.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
let refreshed_b = app
|
|
||||||
.get(
|
|
||||||
&format!("/api/documents/{}", detail_b.document.id),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(refreshed_b.status().is_success());
|
|
||||||
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
|
|
||||||
let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?;
|
|
||||||
assert_eq!(
|
|
||||||
refreshed_b_detail.document.document_type_id,
|
|
||||||
Some(receipts.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
|
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ const AppLayout = () => {
|
|||||||
const previewInflightRef = useRef(new Map());
|
const previewInflightRef = useRef(new Map());
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [correspondents, setCorrespondents] = useState([]);
|
const [correspondents, setCorrespondents] = useState([]);
|
||||||
const [documentTypes, setDocumentTypes] = useState([]);
|
|
||||||
const [webdavTokens, setWebdavTokens] = useState([]);
|
const [webdavTokens, setWebdavTokens] = useState([]);
|
||||||
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
||||||
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
||||||
@@ -276,7 +275,6 @@ const AppLayout = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||||
const [activeDocumentTypeFilters, setActiveDocumentTypeFilters] = useState([]);
|
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const documentsRouteMatch = useMatch('/documents');
|
const documentsRouteMatch = useMatch('/documents');
|
||||||
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
||||||
@@ -302,15 +300,6 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleDocumentTypeFilter = useCallback((documentTypeId) => {
|
|
||||||
setActiveDocumentTypeFilters((previous) => {
|
|
||||||
if (!documentTypeId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return previous.includes(documentTypeId) ? [] : [documentTypeId];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -346,7 +335,6 @@ const AppLayout = () => {
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
setActiveDocumentTypeFilters([]);
|
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -451,7 +439,6 @@ const AppLayout = () => {
|
|||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
setCorrespondents([]);
|
setCorrespondents([]);
|
||||||
setDocumentTypes([]);
|
|
||||||
setWebdavTokens([]);
|
setWebdavTokens([]);
|
||||||
setWebdavTokensLoading(false);
|
setWebdavTokensLoading(false);
|
||||||
setCreatingWebdavToken(false);
|
setCreatingWebdavToken(false);
|
||||||
@@ -460,7 +447,6 @@ const AppLayout = () => {
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
setActiveDocumentTypeFilters([]);
|
|
||||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||||
setActivePreviewId(null);
|
setActivePreviewId(null);
|
||||||
setDetailPanelOpen(false);
|
setDetailPanelOpen(false);
|
||||||
@@ -496,27 +482,6 @@ const AppLayout = () => {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [correspondents]);
|
}, [correspondents]);
|
||||||
|
|
||||||
const documentTypeLookupByName = useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
documentTypes.forEach((entry) => {
|
|
||||||
if (entry?.name) {
|
|
||||||
map.set(entry.name.toLowerCase(), entry);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [documentTypes]);
|
|
||||||
|
|
||||||
const documentTypeLookupById = useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
documentTypes.forEach((entry) => {
|
|
||||||
if (entry?.id) {
|
|
||||||
map.set(entry.id, entry);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [documentTypes]);
|
|
||||||
|
|
||||||
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
||||||
const nextSet = new Set(nextSelection);
|
const nextSet = new Set(nextSelection);
|
||||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||||
@@ -652,9 +617,8 @@ const AppLayout = () => {
|
|||||||
() =>
|
() =>
|
||||||
searchQuery.trim().length > 0 ||
|
searchQuery.trim().length > 0 ||
|
||||||
activeTagFilters.length > 0 ||
|
activeTagFilters.length > 0 ||
|
||||||
activeCorrespondentFilters.length > 0 ||
|
activeCorrespondentFilters.length > 0,
|
||||||
activeDocumentTypeFilters.length > 0,
|
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
||||||
[searchQuery, activeTagFilters, activeCorrespondentFilters, activeDocumentTypeFilters],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySelectedFolder = useCallback(
|
const applySelectedFolder = useCallback(
|
||||||
@@ -1479,22 +1443,6 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [notifyApiError]);
|
}, [notifyApiError]);
|
||||||
|
|
||||||
const refreshDocumentTypes = useCallback(async () => {
|
|
||||||
const requestTenantId = tenantIdRef.current;
|
|
||||||
try {
|
|
||||||
const { data } = await api.get('/document-types');
|
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDocumentTypes(data || []);
|
|
||||||
} catch (error) {
|
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifyApiError(error, 'Unable to load document types.');
|
|
||||||
}
|
|
||||||
}, [notifyApiError]);
|
|
||||||
|
|
||||||
const refreshWebdavTokens = useCallback(async () => {
|
const refreshWebdavTokens = useCallback(async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
@@ -1834,211 +1782,6 @@ const AppLayout = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTypeUpdate = useCallback(
|
|
||||||
async (documentTypeId, changes) => {
|
|
||||||
if (!documentTypeId) {
|
|
||||||
throw new Error('Missing document type identifier.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {};
|
|
||||||
if (typeof changes.name === 'string') {
|
|
||||||
const trimmed = changes.name.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
throw new Error('Document type name cannot be empty.');
|
|
||||||
}
|
|
||||||
payload.name = trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.patch(`/document-types/${documentTypeId}`, payload);
|
|
||||||
await refreshDocumentTypes();
|
|
||||||
setStatusMessage('Document type updated.', 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to update document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refreshDocumentTypes, notifyApiError, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTypeCreate = useCallback(
|
|
||||||
async ({ name }) => {
|
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (!trimmed) {
|
|
||||||
throw new Error('Document type name is required.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/document-types', { name: trimmed });
|
|
||||||
await refreshDocumentTypes();
|
|
||||||
setStatusMessage('Document type created.', 'success');
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to create document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refreshDocumentTypes, notifyApiError, setStatusMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTypeDelete = useCallback(
|
|
||||||
async (documentTypeId) => {
|
|
||||||
if (!documentTypeId) {
|
|
||||||
throw new Error('Missing document type identifier.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedEntry = documentTypeLookupById.get(documentTypeId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.delete(`/document-types/${documentTypeId}`);
|
|
||||||
|
|
||||||
mapDocumentCaches((doc) => {
|
|
||||||
if (!doc) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingType = doc.document_type;
|
|
||||||
const existingTypeId = doc.document_type_id ?? existingType?.id ?? null;
|
|
||||||
const existingTypeName = typeof existingType?.name === 'string' ? existingType.name : undefined;
|
|
||||||
|
|
||||||
const shouldClear =
|
|
||||||
existingTypeId === documentTypeId ||
|
|
||||||
(!!deletedEntry?.name &&
|
|
||||||
existingTypeName &&
|
|
||||||
existingTypeName.toLowerCase() === deletedEntry.name.toLowerCase());
|
|
||||||
|
|
||||||
if (!shouldClear) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...doc, document_type: null, document_type_id: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
await refreshDocumentTypes();
|
|
||||||
setStatusMessage('Document type deleted.', 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to delete document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
refreshDocumentTypes,
|
|
||||||
notifyApiError,
|
|
||||||
setStatusMessage,
|
|
||||||
mapDocumentCaches,
|
|
||||||
documentTypeLookupById,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTypeAssign = useCallback(
|
|
||||||
async ({ documentId, documentTypeId, documentType }, { notify = true } = {}) => {
|
|
||||||
if (!documentId) {
|
|
||||||
throw new Error('Missing document identifier.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = { document_type_id: documentTypeId ?? null };
|
|
||||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
|
||||||
const updatedDocument = extractDocumentFromResponse(data);
|
|
||||||
|
|
||||||
updateDocumentCaches(documentId, (doc) => {
|
|
||||||
if (updatedDocument) {
|
|
||||||
return { ...doc, ...updatedDocument };
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = { ...doc };
|
|
||||||
if (documentTypeId) {
|
|
||||||
const entry =
|
|
||||||
documentTypeLookupById.get(documentTypeId) ||
|
|
||||||
(documentType?.name ? documentType : null);
|
|
||||||
next.document_type = entry
|
|
||||||
? { id: entry.id ?? documentTypeId, name: entry.name }
|
|
||||||
: { id: documentTypeId, name: '' };
|
|
||||||
} else {
|
|
||||||
next.document_type = null;
|
|
||||||
}
|
|
||||||
next.document_type_id = documentTypeId ?? null;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notify) {
|
|
||||||
setStatusMessage(
|
|
||||||
documentTypeId ? 'Document type updated.' : 'Document type cleared.',
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to update document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
extractDocumentFromResponse,
|
|
||||||
updateDocumentCaches,
|
|
||||||
documentTypeLookupById,
|
|
||||||
notifyApiError,
|
|
||||||
setStatusMessage,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTypeClear = useCallback(
|
|
||||||
async ({ documentId }, options = {}) =>
|
|
||||||
handleDocumentTypeAssign({ documentId, documentTypeId: null }, options),
|
|
||||||
[handleDocumentTypeAssign],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDocumentTypeSet = useCallback(
|
|
||||||
async ({ document, name, input }) => {
|
|
||||||
if (!document?.id) {
|
|
||||||
throw new Error('Missing document for document type assignment.');
|
|
||||||
}
|
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (!trimmed) {
|
|
||||||
setStatusMessage('Document type name is required.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null;
|
|
||||||
if (!target) {
|
|
||||||
try {
|
|
||||||
target = await handleDocumentTypeCreate({ name: trimmed });
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target?.id) {
|
|
||||||
setStatusMessage('Unable to resolve document type.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await handleDocumentTypeAssign(
|
|
||||||
{ documentId: document.id, documentTypeId: target.id, documentType: target },
|
|
||||||
{ notify: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success && input) {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
handleDocumentTypeCreate,
|
|
||||||
handleDocumentTypeAssign,
|
|
||||||
documentTypeLookupByName,
|
|
||||||
setStatusMessage,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolveTargetDocumentIds = useCallback(
|
const resolveTargetDocumentIds = useCallback(
|
||||||
(candidateIds) => {
|
(candidateIds) => {
|
||||||
const normalized = Array.isArray(candidateIds)
|
const normalized = Array.isArray(candidateIds)
|
||||||
@@ -2052,90 +1795,6 @@ const AppLayout = () => {
|
|||||||
[selectedDocumentIds],
|
[selectedDocumentIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkDocumentTypeSet = useCallback(
|
|
||||||
async ({ name, input, documentIds }) => {
|
|
||||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (!trimmed) {
|
|
||||||
setStatusMessage('Document type name is required.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = resolveTargetDocumentIds(documentIds);
|
|
||||||
if (!targets.length) {
|
|
||||||
setStatusMessage('Select documents before assigning a document type.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = documentTypeLookupByName.get(trimmed.toLowerCase()) || null;
|
|
||||||
if (!target) {
|
|
||||||
try {
|
|
||||||
target = await handleDocumentTypeCreate({ name: trimmed });
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target?.id) {
|
|
||||||
setStatusMessage('Unable to resolve document type.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
targets.map((documentId) =>
|
|
||||||
api.patch(`/documents/${documentId}`, { document_type_id: target.id }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
const suffix = targets.length === 1 ? '' : 's';
|
|
||||||
setStatusMessage(
|
|
||||||
`Document type assigned to ${targets.length} document${suffix}.`,
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
if (input) {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to assign document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
documentTypeLookupByName,
|
|
||||||
handleDocumentTypeCreate,
|
|
||||||
refreshCurrentFolder,
|
|
||||||
resolveTargetDocumentIds,
|
|
||||||
setStatusMessage,
|
|
||||||
notifyApiError,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkDocumentTypeClear = useCallback(
|
|
||||||
async ({ documentIds }) => {
|
|
||||||
const targets = resolveTargetDocumentIds(documentIds);
|
|
||||||
if (!targets.length) {
|
|
||||||
setStatusMessage('Select documents before clearing the document type.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
targets.map((documentId) => api.patch(`/documents/${documentId}`, { document_type_id: null })),
|
|
||||||
);
|
|
||||||
await refreshCurrentFolder();
|
|
||||||
const suffix = targets.length === 1 ? '' : 's';
|
|
||||||
setStatusMessage(
|
|
||||||
`Document type cleared from ${targets.length} document${suffix}.`,
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.error || 'Failed to clear document type.';
|
|
||||||
notifyApiError(error, message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[resolveTargetDocumentIds, refreshCurrentFolder, setStatusMessage, notifyApiError],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTagDelete = useCallback(
|
const handleTagDelete = useCallback(
|
||||||
async (tagId) => {
|
async (tagId) => {
|
||||||
if (!tagId) {
|
if (!tagId) {
|
||||||
@@ -2336,7 +1995,7 @@ const AppLayout = () => {
|
|||||||
const initializeAfterLogin = useCallback(async () => {
|
const initializeAfterLogin = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]);
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||||||
await loadFolder(initialFolder, { showLoading: false });
|
await loadFolder(initialFolder, { showLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2345,7 +2004,7 @@ const AppLayout = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [refreshTags, refreshCorrespondents, refreshDocumentTypes, routeFolderId, loadFolder, notifyApiError]);
|
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -4038,12 +3697,7 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [creatingFolder, handleFolderCreate, setStatusMessage]);
|
}, [creatingFolder, handleFolderCreate, setStatusMessage]);
|
||||||
|
|
||||||
const {
|
const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({
|
||||||
managementModals,
|
|
||||||
openTagsModal,
|
|
||||||
openCorrespondentsModal,
|
|
||||||
openDocumentTypesModal,
|
|
||||||
} = useManagementModals({
|
|
||||||
locationPathname: location.pathname,
|
locationPathname: location.pathname,
|
||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
@@ -4055,11 +3709,6 @@ const AppLayout = () => {
|
|||||||
onCorrespondentCreate: handleCorrespondentCreate,
|
onCorrespondentCreate: handleCorrespondentCreate,
|
||||||
onCorrespondentUpdate: handleCorrespondentUpdate,
|
onCorrespondentUpdate: handleCorrespondentUpdate,
|
||||||
onCorrespondentDelete: handleCorrespondentDelete,
|
onCorrespondentDelete: handleCorrespondentDelete,
|
||||||
documentTypes,
|
|
||||||
refreshDocumentTypes,
|
|
||||||
onDocumentTypeCreate: handleDocumentTypeCreate,
|
|
||||||
onDocumentTypeUpdate: handleDocumentTypeUpdate,
|
|
||||||
onDocumentTypeDelete: handleDocumentTypeDelete,
|
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4095,9 +3744,6 @@ const AppLayout = () => {
|
|||||||
if (activeCorrespondentFilters.length) {
|
if (activeCorrespondentFilters.length) {
|
||||||
params.correspondents = activeCorrespondentFilters.join(',');
|
params.correspondents = activeCorrespondentFilters.join(',');
|
||||||
}
|
}
|
||||||
if (activeDocumentTypeFilters.length) {
|
|
||||||
params.document_types = activeDocumentTypeFilters.join(',');
|
|
||||||
}
|
|
||||||
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
||||||
if (folderIdentifier) {
|
if (folderIdentifier) {
|
||||||
params.folder_id = folderIdentifier;
|
params.folder_id = folderIdentifier;
|
||||||
@@ -4179,7 +3825,6 @@ const AppLayout = () => {
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
activeTagFilters,
|
activeTagFilters,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
activeDocumentTypeFilters,
|
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
assetManager,
|
assetManager,
|
||||||
@@ -4981,7 +4626,7 @@ const AppLayout = () => {
|
|||||||
setWorkspaceMode('table');
|
setWorkspaceMode('table');
|
||||||
navigate('/documents', { replace: true });
|
navigate('/documents', { replace: true });
|
||||||
|
|
||||||
await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]);
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||||
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||||
|
|
||||||
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||||
@@ -5002,7 +4647,6 @@ const AppLayout = () => {
|
|||||||
navigate,
|
navigate,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
refreshCorrespondents,
|
refreshCorrespondents,
|
||||||
refreshDocumentTypes,
|
|
||||||
loadFolder,
|
loadFolder,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -5128,10 +4772,6 @@ const AppLayout = () => {
|
|||||||
activeCorrespondentIds: activeCorrespondentFilters,
|
activeCorrespondentIds: activeCorrespondentFilters,
|
||||||
onToggleCorrespondentFilter: toggleCorrespondentFilter,
|
onToggleCorrespondentFilter: toggleCorrespondentFilter,
|
||||||
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
|
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
|
||||||
documentTypes,
|
|
||||||
activeDocumentTypeIds: activeDocumentTypeFilters,
|
|
||||||
onToggleDocumentTypeFilter: toggleDocumentTypeFilter,
|
|
||||||
onCreateDocumentType: (name) => handleDocumentTypeCreate({ name }),
|
|
||||||
appStatus,
|
appStatus,
|
||||||
loading,
|
loading,
|
||||||
previewActive,
|
previewActive,
|
||||||
@@ -5155,7 +4795,6 @@ const AppLayout = () => {
|
|||||||
clearFilters,
|
clearFilters,
|
||||||
correspondents,
|
correspondents,
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
documentTypes,
|
|
||||||
folderClickHandlers,
|
folderClickHandlers,
|
||||||
folderNodes,
|
folderNodes,
|
||||||
handleFolderDelete,
|
handleFolderDelete,
|
||||||
@@ -5181,13 +4820,10 @@ const AppLayout = () => {
|
|||||||
toggleTagFilter,
|
toggleTagFilter,
|
||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleCorrespondentCreate,
|
handleCorrespondentCreate,
|
||||||
toggleDocumentTypeFilter,
|
|
||||||
handleDocumentTypeCreate,
|
|
||||||
handlePromptCreateFolder,
|
handlePromptCreateFolder,
|
||||||
creatingFolder,
|
creatingFolder,
|
||||||
handleNeutralHueChange,
|
handleNeutralHueChange,
|
||||||
neutralHue,
|
neutralHue,
|
||||||
activeDocumentTypeFilters,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5222,11 +4858,6 @@ const AppLayout = () => {
|
|||||||
correspondents,
|
correspondents,
|
||||||
onCorrespondentAdd: handleCorrespondentAdd,
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
onCorrespondentRemove: handleCorrespondentRemove,
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
documentTypes,
|
|
||||||
onDocumentTypeSet: handleDocumentTypeSet,
|
|
||||||
onDocumentTypeClear: handleDocumentTypeClear,
|
|
||||||
onBulkDocumentTypeSet: handleBulkDocumentTypeSet,
|
|
||||||
onBulkDocumentTypeClear: handleBulkDocumentTypeClear,
|
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
onFolderNavigate: selectFolder,
|
onFolderNavigate: selectFolder,
|
||||||
onClose: handleDetailPanelClose,
|
onClose: handleDetailPanelClose,
|
||||||
@@ -5241,15 +4872,11 @@ const AppLayout = () => {
|
|||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
handleBulkCorrespondentAdd,
|
handleBulkCorrespondentAdd,
|
||||||
handleBulkCorrespondentRemove,
|
handleBulkCorrespondentRemove,
|
||||||
handleBulkDocumentTypeSet,
|
|
||||||
handleBulkDocumentTypeClear,
|
|
||||||
handleBulkSelectionReanalyze,
|
handleBulkSelectionReanalyze,
|
||||||
handleBulkTagAddFromDetail,
|
handleBulkTagAddFromDetail,
|
||||||
handleBulkTagRemoveFromDetail,
|
handleBulkTagRemoveFromDetail,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleDocumentTypeSet,
|
|
||||||
handleDocumentTypeClear,
|
|
||||||
handleDetailPanelClose,
|
handleDetailPanelClose,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleTagAdd,
|
handleTagAdd,
|
||||||
@@ -5262,7 +4889,6 @@ const AppLayout = () => {
|
|||||||
selectedPreviewEntry,
|
selectedPreviewEntry,
|
||||||
tags,
|
tags,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
documentTypes,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5321,16 +4947,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
documentTypes,
|
|
||||||
refreshDocumentTypes,
|
|
||||||
handleDocumentTypeUpdate,
|
|
||||||
handleDocumentTypeCreate,
|
|
||||||
handleDocumentTypeDelete,
|
|
||||||
handleDocumentTypeAssign,
|
|
||||||
handleDocumentTypeClear,
|
|
||||||
handleDocumentTypeSet,
|
|
||||||
handleBulkDocumentTypeSet,
|
|
||||||
handleBulkDocumentTypeClear,
|
|
||||||
webdavTokens,
|
webdavTokens,
|
||||||
webdavTokensLoading,
|
webdavTokensLoading,
|
||||||
creatingWebdavToken,
|
creatingWebdavToken,
|
||||||
@@ -5366,7 +4982,6 @@ const AppLayout = () => {
|
|||||||
notifyApiError,
|
notifyApiError,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
openDocumentTypesModal,
|
|
||||||
openSettings,
|
openSettings,
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
setDetailPanelOpen,
|
setDetailPanelOpen,
|
||||||
@@ -5392,16 +5007,6 @@ const AppLayout = () => {
|
|||||||
handleDocumentCorrespondentAttach,
|
handleDocumentCorrespondentAttach,
|
||||||
handleCorrespondentRemove,
|
handleCorrespondentRemove,
|
||||||
handleCorrespondentAdd,
|
handleCorrespondentAdd,
|
||||||
documentTypes,
|
|
||||||
refreshDocumentTypes,
|
|
||||||
handleDocumentTypeUpdate,
|
|
||||||
handleDocumentTypeCreate,
|
|
||||||
handleDocumentTypeDelete,
|
|
||||||
handleDocumentTypeAssign,
|
|
||||||
handleDocumentTypeClear,
|
|
||||||
handleDocumentTypeSet,
|
|
||||||
handleBulkDocumentTypeSet,
|
|
||||||
handleBulkDocumentTypeClear,
|
|
||||||
webdavTokens,
|
webdavTokens,
|
||||||
webdavTokensLoading,
|
webdavTokensLoading,
|
||||||
creatingWebdavToken,
|
creatingWebdavToken,
|
||||||
@@ -5436,7 +5041,6 @@ const AppLayout = () => {
|
|||||||
notifyApiError,
|
notifyApiError,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
openDocumentTypesModal,
|
|
||||||
openSettings,
|
openSettings,
|
||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
setDetailPanelOpen,
|
setDetailPanelOpen,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const DocumentsRoute = () => {
|
|||||||
skeuoWorkspaceProps,
|
skeuoWorkspaceProps,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
openDocumentTypesModal,
|
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
@@ -36,10 +35,9 @@ const DocumentsRoute = () => {
|
|||||||
...sidebarProps,
|
...sidebarProps,
|
||||||
onManageTags: openTagsModal,
|
onManageTags: openTagsModal,
|
||||||
onManageCorrespondents: openCorrespondentsModal,
|
onManageCorrespondents: openCorrespondentsModal,
|
||||||
onManageDocumentTypes: openDocumentTypesModal,
|
|
||||||
onCollapse: collapseSidebar,
|
onCollapse: collapseSidebar,
|
||||||
}),
|
}),
|
||||||
[sidebarProps, openTagsModal, openCorrespondentsModal, openDocumentTypesModal, collapseSidebar],
|
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TagsPanel from '../tags/TagsPanel';
|
import TagsPanel from '../tags/TagsPanel';
|
||||||
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
||||||
import DocumentTypesPanel from '../documentTypes/DocumentTypesPanel';
|
|
||||||
|
|
||||||
const TAGS_MODAL = 'tags';
|
const TAGS_MODAL = 'tags';
|
||||||
const CORRESPONDENTS_MODAL = 'correspondents';
|
const CORRESPONDENTS_MODAL = 'correspondents';
|
||||||
const DOCUMENT_TYPES_MODAL = 'document-types';
|
|
||||||
|
|
||||||
export const useManagementModals = ({
|
export const useManagementModals = ({
|
||||||
locationPathname,
|
locationPathname,
|
||||||
@@ -19,11 +17,6 @@ export const useManagementModals = ({
|
|||||||
onCorrespondentCreate,
|
onCorrespondentCreate,
|
||||||
onCorrespondentUpdate,
|
onCorrespondentUpdate,
|
||||||
onCorrespondentDelete,
|
onCorrespondentDelete,
|
||||||
documentTypes,
|
|
||||||
refreshDocumentTypes,
|
|
||||||
onDocumentTypeCreate,
|
|
||||||
onDocumentTypeUpdate,
|
|
||||||
onDocumentTypeDelete,
|
|
||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeModal, setActiveModal] = useState(null);
|
const [activeModal, setActiveModal] = useState(null);
|
||||||
@@ -33,10 +26,6 @@ export const useManagementModals = ({
|
|||||||
() => setActiveModal(CORRESPONDENTS_MODAL),
|
() => setActiveModal(CORRESPONDENTS_MODAL),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const openDocumentTypesModal = useCallback(
|
|
||||||
() => setActiveModal(DOCUMENT_TYPES_MODAL),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const closeActiveModal = useCallback(() => setActiveModal(null), []);
|
const closeActiveModal = useCallback(() => setActiveModal(null), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,58 +140,10 @@ export const useManagementModals = ({
|
|||||||
setStatusMessage,
|
setStatusMessage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const documentTypesModal = useMemo(() => {
|
|
||||||
if (activeModal !== DOCUMENT_TYPES_MODAL) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onClick={closeActiveModal}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal modal--panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="document-types-modal-title"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="panel-modal__header">
|
|
||||||
<h3 id="document-types-modal-title">Manage Document Types</h3>
|
|
||||||
<button type="button" className="secondary" onClick={closeActiveModal}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="panel-modal__body">
|
|
||||||
<DocumentTypesPanel
|
|
||||||
documentTypes={documentTypes}
|
|
||||||
onRefresh={refreshDocumentTypes}
|
|
||||||
onCreate={onDocumentTypeCreate}
|
|
||||||
onUpdate={onDocumentTypeUpdate}
|
|
||||||
onDelete={onDocumentTypeDelete}
|
|
||||||
onNotify={setStatusMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
activeModal,
|
|
||||||
closeActiveModal,
|
|
||||||
documentTypes,
|
|
||||||
onDocumentTypeCreate,
|
|
||||||
onDocumentTypeDelete,
|
|
||||||
onDocumentTypeUpdate,
|
|
||||||
refreshDocumentTypes,
|
|
||||||
setStatusMessage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const managementModals = (
|
const managementModals = (
|
||||||
<>
|
<>
|
||||||
{tagModal}
|
{tagModal}
|
||||||
{correspondentsModal}
|
{correspondentsModal}
|
||||||
{documentTypesModal}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -210,7 +151,6 @@ export const useManagementModals = ({
|
|||||||
managementModals,
|
managementModals,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
openDocumentTypesModal,
|
|
||||||
closeActiveModal,
|
closeActiveModal,
|
||||||
activeModal,
|
activeModal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,25 +34,6 @@ const sortCorrespondents = (entries = []) =>
|
|||||||
.map(({ id, name, count }) => ({ id, name, count }))
|
.map(({ id, name, count }) => ({ id, name, count }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
const resolveDocumentType = (doc) => {
|
|
||||||
if (!doc) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const type = doc.document_type;
|
|
||||||
const fallbackId = doc.document_type_id ?? null;
|
|
||||||
if (!type) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof type === 'object' && typeof type.name === 'string') {
|
|
||||||
const name = type.name.trim();
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { id: type.id ?? fallbackId, name };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||||
<div className="correspondent-list">
|
<div className="correspondent-list">
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
@@ -318,11 +299,6 @@ const DetailPanel = ({
|
|||||||
correspondents = [],
|
correspondents = [],
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
documentTypes = [],
|
|
||||||
onDocumentTypeSet,
|
|
||||||
onDocumentTypeClear,
|
|
||||||
onBulkDocumentTypeSet,
|
|
||||||
onBulkDocumentTypeClear,
|
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
onFolderNavigate = null,
|
onFolderNavigate = null,
|
||||||
resolveFolderPath = null,
|
resolveFolderPath = null,
|
||||||
@@ -617,28 +593,6 @@ const DetailPanel = ({
|
|||||||
}, []);
|
}, []);
|
||||||
}, [availableCorrespondents]);
|
}, [availableCorrespondents]);
|
||||||
|
|
||||||
const documentTypeOptions = useMemo(() => {
|
|
||||||
const seen = new Set();
|
|
||||||
return (documentTypes || []).reduce((options, entry) => {
|
|
||||||
if (typeof entry?.name !== 'string') {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
if (seen.has(lower)) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
seen.add(lower);
|
|
||||||
options.push(name);
|
|
||||||
return options;
|
|
||||||
}, []);
|
|
||||||
}, [documentTypes]);
|
|
||||||
|
|
||||||
const singleDocumentType = useMemo(() => resolveDocumentType(singleDoc), [singleDoc]);
|
|
||||||
|
|
||||||
const singleCorrespondents = useMemo(() => {
|
const singleCorrespondents = useMemo(() => {
|
||||||
if (!singleDoc) return [];
|
if (!singleDoc) return [];
|
||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
@@ -774,42 +728,6 @@ const DetailPanel = ({
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const bulkDocumentTypes = useMemo(() => {
|
|
||||||
if (!selectedDocuments.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map();
|
|
||||||
selectedDocuments.forEach((doc) => {
|
|
||||||
const type = resolveDocumentType(doc);
|
|
||||||
if (!type) return;
|
|
||||||
const key = type.id ?? type.name.toLowerCase();
|
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, { id: type.id ?? null, name: type.name, count: 0 });
|
|
||||||
}
|
|
||||||
map.get(key).count += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [selectedDocuments]);
|
|
||||||
|
|
||||||
const bulkDocumentTypeSummary = useMemo(() => {
|
|
||||||
if (!bulkDocumentTypes.length) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return bulkDocumentTypes
|
|
||||||
.map((entry) => {
|
|
||||||
if (!entry?.name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const suffix = entry.count === selectedDocuments.length ? '' : ` (${entry.count})`;
|
|
||||||
return `${entry.name}${suffix}`;
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
}, [bulkDocumentTypes, selectedDocuments.length]);
|
|
||||||
|
|
||||||
const handleBulkCorrespondentRemove = useCallback(
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
(entry) => {
|
(entry) => {
|
||||||
if (!entry?.id) return;
|
if (!entry?.id) return;
|
||||||
@@ -1177,53 +1095,6 @@ const DetailPanel = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-field">
|
|
||||||
<div className="detail-field__label">Document type</div>
|
|
||||||
<div className="detail-field__value">
|
|
||||||
{singleDocumentType?.name ? (
|
|
||||||
<span>{singleDocumentType.name}</span>
|
|
||||||
) : (
|
|
||||||
<span className="meta">None assigned.</span>
|
|
||||||
)}
|
|
||||||
{singleDocumentType && onDocumentTypeClear ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => onDocumentTypeClear?.({ documentId: singleDoc.id })}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{onDocumentTypeSet ? (
|
|
||||||
<form
|
|
||||||
className="inline"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const input = form.elements.documentType;
|
|
||||||
const value = input?.value?.trim();
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentTypeSet?.({ document: singleDoc, name: value, input });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="documentType"
|
|
||||||
placeholder="Assign or create type"
|
|
||||||
list="document-type-catalog-single"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
<button type="submit">Set</button>
|
|
||||||
<datalist id="document-type-catalog-single">
|
|
||||||
{documentTypeOptions.map((name) => (
|
|
||||||
<option key={name} value={name} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<TagSection
|
<TagSection
|
||||||
title="Tags"
|
title="Tags"
|
||||||
tags={tagsForDoc.map((tag) => ({
|
tags={tagsForDoc.map((tag) => ({
|
||||||
@@ -1348,47 +1219,6 @@ const DetailPanel = ({
|
|||||||
datalistOptions={tags}
|
datalistOptions={tags}
|
||||||
className="bulk-tags"
|
className="bulk-tags"
|
||||||
/>
|
/>
|
||||||
<div className="detail-field">
|
|
||||||
<div className="detail-field__label">Document type</div>
|
|
||||||
<div className="detail-field__value">
|
|
||||||
<span>{bulkDocumentTypeSummary || 'None assigned.'}</span>
|
|
||||||
{onBulkDocumentTypeClear && bulkDocumentTypes.length > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => onBulkDocumentTypeClear?.({ documentIds })}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{onBulkDocumentTypeSet ? (
|
|
||||||
<form
|
|
||||||
className="inline"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const input = form.elements.documentType;
|
|
||||||
const value = input?.value?.trim();
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onBulkDocumentTypeSet?.({ name: value, input, documentIds });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="documentType"
|
|
||||||
placeholder="Assign or create type"
|
|
||||||
list="document-type-catalog-bulk"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
<button type="submit">Set</button>
|
|
||||||
<datalist id="document-type-catalog-bulk">
|
|
||||||
{documentTypeOptions.map((name) => (<option key={name} value={name} />))}
|
|
||||||
</datalist>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<CorrespondentSection
|
<CorrespondentSection
|
||||||
title="Correspondents"
|
title="Correspondents"
|
||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
function DocumentTypesPanel({
|
|
||||||
documentTypes = [],
|
|
||||||
onRefresh,
|
|
||||||
onCreate,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
onNotify,
|
|
||||||
}) {
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [draftName, setDraftName] = useState('');
|
|
||||||
const [createName, setCreateName] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
|
||||||
|
|
||||||
const startEdit = useCallback((entry) => {
|
|
||||||
setEditingId(entry.id);
|
|
||||||
setDraftName(entry.name);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
|
||||||
setEditingId(null);
|
|
||||||
setDraftName('');
|
|
||||||
setSaving(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!editingId) return;
|
|
||||||
const trimmed = draftName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
onNotify?.('Document type name cannot be empty.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await onUpdate(editingId, { name: trimmed });
|
|
||||||
cancelEdit();
|
|
||||||
} catch (error) {
|
|
||||||
onNotify?.('Failed to update document type.', 'error');
|
|
||||||
console.error('[document-types] update failed', error);
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (entry) => {
|
|
||||||
if (!entry?.id) return;
|
|
||||||
setDeletingId(entry.id);
|
|
||||||
try {
|
|
||||||
await onDelete(entry.id);
|
|
||||||
if (editingId === entry.id) {
|
|
||||||
cancelEdit();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
onNotify?.('Failed to delete document type.', 'error');
|
|
||||||
console.error('[document-types] delete failed', error);
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onDelete, editingId, cancelEdit, onNotify],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreate = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const trimmed = createName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
onNotify?.('Document type name cannot be empty.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
await onCreate({ name: trimmed });
|
|
||||||
setCreateName('');
|
|
||||||
} catch (error) {
|
|
||||||
onNotify?.('Failed to create document type.', 'error');
|
|
||||||
console.error('[document-types] create failed', error);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createName, onCreate, onNotify],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
cancelEdit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSave, cancelEdit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderUsage = useCallback((usage) => {
|
|
||||||
if (!usage) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
|
||||||
return total.toString();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="correspondents-panel">
|
|
||||||
<div className="panel-section__header">
|
|
||||||
<div className="panel-section__titles">
|
|
||||||
<h2>Document Types</h2>
|
|
||||||
<div className="panel-section__subtitle">{documentTypes.length} total</div>
|
|
||||||
</div>
|
|
||||||
<div className="header-actions correspondents-actions">
|
|
||||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="New document type name"
|
|
||||||
value={createName}
|
|
||||||
onChange={(event) => setCreateName(event.target.value)}
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={creating || !createName.trim()}>
|
|
||||||
{creating ? 'Creating…' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={saving || creating || Boolean(deletingId)}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="panel-section__body tags-panel__body">
|
|
||||||
{documentTypes.length === 0 ? (
|
|
||||||
<div className="empty-state">No document types created yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="tags-table">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Name</th>
|
|
||||||
<th scope="col" className="numeric">
|
|
||||||
Usage
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="actions">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{documentTypes.map((entry) => {
|
|
||||||
const isEditing = editingId === entry.id;
|
|
||||||
return (
|
|
||||||
<tr key={entry.id} className={isEditing ? 'editing' : ''}>
|
|
||||||
<td className="tags-table__label">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
className="tags-table__label-input"
|
|
||||||
value={draftName}
|
|
||||||
onChange={(event) => setDraftName(event.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={saving}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{entry.name}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="numeric">{renderUsage(entry.usage)}</td>
|
|
||||||
<td className="actions">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="tags-table__edit-controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={cancelEdit}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="tags-table__row-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={() => startEdit(entry)}
|
|
||||||
title="Rename"
|
|
||||||
aria-label={`Rename document type ${entry.name}`}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={() => handleDelete(entry)}
|
|
||||||
disabled={deletingId === entry.id}
|
|
||||||
title="Delete"
|
|
||||||
aria-label={`Delete document type ${entry.name}`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DocumentTypesPanel;
|
|
||||||
@@ -56,29 +56,6 @@ const resolveCorrespondents = (doc) => {
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDocumentType = (doc) => {
|
|
||||||
if (!doc) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = doc.document_type;
|
|
||||||
const fallbackId = doc.document_type_id ?? null;
|
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof type === 'object' && typeof type.name === 'string') {
|
|
||||||
const name = type.name.trim();
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { id: type.id ?? fallbackId, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detects when an element becomes visible within a scroll container.
|
// Detects when an element becomes visible within a scroll container.
|
||||||
const useLazyVisibility = (rootRef, resetKey) => {
|
const useLazyVisibility = (rootRef, resetKey) => {
|
||||||
const targetRef = useRef(null);
|
const targetRef = useRef(null);
|
||||||
@@ -254,8 +231,6 @@ const DocumentsTable = ({
|
|||||||
getDownloadHref,
|
getDownloadHref,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onCorrespondentClick,
|
onCorrespondentClick,
|
||||||
activeDocumentTypeIds = [],
|
|
||||||
onDocumentTypeClick,
|
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
@@ -282,10 +257,6 @@ const DocumentsTable = ({
|
|||||||
() => new Set(activeCorrespondentIds || []),
|
() => new Set(activeCorrespondentIds || []),
|
||||||
[activeCorrespondentIds],
|
[activeCorrespondentIds],
|
||||||
);
|
);
|
||||||
const activeDocumentTypeIdSet = useMemo(
|
|
||||||
() => new Set(activeDocumentTypeIds || []),
|
|
||||||
[activeDocumentTypeIds],
|
|
||||||
);
|
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const suppressDocumentClickRef = useRef(false);
|
const suppressDocumentClickRef = useRef(false);
|
||||||
const [, forceVisibilityTick] = useState(0);
|
const [, forceVisibilityTick] = useState(0);
|
||||||
@@ -657,43 +628,10 @@ const DocumentsTable = ({
|
|||||||
const visibleTags = tagList.slice(0, 3);
|
const visibleTags = tagList.slice(0, 3);
|
||||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||||
const correspondents = resolveCorrespondents(doc);
|
const correspondents = resolveCorrespondents(doc);
|
||||||
const documentType = resolveDocumentType(doc);
|
|
||||||
const isDocumentTypeActive =
|
|
||||||
documentType?.id != null && activeDocumentTypeIdSet.has(documentType.id);
|
|
||||||
const canToggleDocumentType =
|
|
||||||
documentType?.id != null && typeof onDocumentTypeClick === 'function';
|
|
||||||
const cardClasses = ['document-card', 'document'];
|
const cardClasses = ['document-card', 'document'];
|
||||||
if (isSelected) cardClasses.push('selected');
|
if (isSelected) cardClasses.push('selected');
|
||||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||||
const titleText = doc.title || doc.original_name;
|
const titleText = doc.title || doc.original_name;
|
||||||
const documentTypeNode = documentType
|
|
||||||
? (
|
|
||||||
<span
|
|
||||||
className={`doc-type-inline${
|
|
||||||
isDocumentTypeActive ? ' active' : ''
|
|
||||||
}`}
|
|
||||||
role={canToggleDocumentType ? 'button' : undefined}
|
|
||||||
tabIndex={canToggleDocumentType ? 0 : undefined}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (canToggleDocumentType) {
|
|
||||||
onDocumentTypeClick?.(documentType.id, documentType);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (canToggleDocumentType) {
|
|
||||||
onDocumentTypeClick?.(documentType.id, documentType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
({documentType.name})
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -733,12 +671,6 @@ const DocumentsTable = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="doc-name__primary">{titleText}</span>
|
<span className="doc-name__primary">{titleText}</span>
|
||||||
{documentTypeNode ? (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
{documentTypeNode}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{visibleTags.length > 0 && (
|
{visibleTags.length > 0 && (
|
||||||
<div className="document-card__tags">
|
<div className="document-card__tags">
|
||||||
@@ -924,38 +856,7 @@ const DocumentsTable = ({
|
|||||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||||
const downloadHref = getDownloadHref?.(doc) || null;
|
const downloadHref = getDownloadHref?.(doc) || null;
|
||||||
const correspondents = resolveCorrespondents(doc);
|
const correspondents = resolveCorrespondents(doc);
|
||||||
const documentType = resolveDocumentType(doc);
|
|
||||||
const isRowDocumentTypeActive = documentType?.id != null && activeDocumentTypeIdSet.has(documentType.id);
|
|
||||||
const canToggleRowDocumentType = documentType?.id != null && typeof onDocumentTypeClick === 'function';
|
|
||||||
const titleText = doc.title || doc.original_name;
|
const titleText = doc.title || doc.original_name;
|
||||||
const documentTypeNode = documentType
|
|
||||||
? (
|
|
||||||
<span
|
|
||||||
className={`doc-type-inline${
|
|
||||||
isRowDocumentTypeActive ? ' active' : ''
|
|
||||||
}`}
|
|
||||||
role={canToggleRowDocumentType ? 'button' : undefined}
|
|
||||||
tabIndex={canToggleRowDocumentType ? 0 : undefined}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (canToggleRowDocumentType) {
|
|
||||||
onDocumentTypeClick?.(documentType.id, documentType);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (canToggleRowDocumentType) {
|
|
||||||
onDocumentTypeClick?.(documentType.id, documentType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
({documentType.name})
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -991,12 +892,6 @@ const DocumentsTable = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="doc-name__primary">{titleText}</span>
|
<span className="doc-name__primary">{titleText}</span>
|
||||||
{documentTypeNode ? (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
{documentTypeNode}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'
|
|||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
|
|
||||||
const resolveDocumentTypeName = (doc) => doc?.document_type?.name;
|
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
const PreviewWorkspace = ({
|
||||||
document,
|
document,
|
||||||
previewEntry,
|
previewEntry,
|
||||||
@@ -21,7 +19,6 @@ const PreviewWorkspace = ({
|
|||||||
const tags = Array.isArray(document.tags)
|
const tags = Array.isArray(document.tags)
|
||||||
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
|
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
|
||||||
: '';
|
: '';
|
||||||
const documentTypeName = resolveDocumentTypeName(document);
|
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
const formatDateTime = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -36,7 +33,6 @@ const PreviewWorkspace = ({
|
|||||||
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
||||||
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
||||||
{ label: 'Correspondent', value: correspondents || '—' },
|
{ label: 'Correspondent', value: correspondents || '—' },
|
||||||
{ label: 'Document Type', value: documentTypeName || '—' },
|
|
||||||
{
|
{
|
||||||
label: 'Filename',
|
label: 'Filename',
|
||||||
value: document.archive_path || document.filename || '—',
|
value: document.archive_path || document.filename || '—',
|
||||||
|
|||||||
@@ -151,15 +151,10 @@ const Sidebar = ({
|
|||||||
correspondents = [],
|
correspondents = [],
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
onToggleCorrespondentFilter,
|
onToggleCorrespondentFilter,
|
||||||
documentTypes = [],
|
|
||||||
activeDocumentTypeIds = [],
|
|
||||||
onToggleDocumentTypeFilter,
|
|
||||||
onManageTags,
|
onManageTags,
|
||||||
onManageCorrespondents,
|
onManageCorrespondents,
|
||||||
onManageDocumentTypes,
|
|
||||||
onCreateTag,
|
onCreateTag,
|
||||||
onCreateCorrespondent,
|
onCreateCorrespondent,
|
||||||
onCreateDocumentType,
|
|
||||||
searchQuery = '',
|
searchQuery = '',
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSearchSubmit,
|
onSearchSubmit,
|
||||||
@@ -187,24 +182,10 @@ const Sidebar = ({
|
|||||||
() => new Set(activeCorrespondentIds || []),
|
() => new Set(activeCorrespondentIds || []),
|
||||||
[activeCorrespondentIds],
|
[activeCorrespondentIds],
|
||||||
);
|
);
|
||||||
const sortedDocumentTypes = useMemo(() => {
|
|
||||||
if (!Array.isArray(documentTypes)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return documentTypes
|
|
||||||
.filter((entry) => entry?.name)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
||||||
}, [documentTypes]);
|
|
||||||
const activeDocumentTypeSet = useMemo(
|
|
||||||
() => new Set(activeDocumentTypeIds || []),
|
|
||||||
[activeDocumentTypeIds],
|
|
||||||
);
|
|
||||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
const handleManageTags = onManageTags || (() => {});
|
const handleManageTags = onManageTags || (() => {});
|
||||||
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
const handleManageDocumentTypes = onManageDocumentTypes || (() => {});
|
|
||||||
const handleCreateTag = useCallback(async () => {
|
const handleCreateTag = useCallback(async () => {
|
||||||
const input = window.prompt('New tag name');
|
const input = window.prompt('New tag name');
|
||||||
if (!input) {
|
if (!input) {
|
||||||
@@ -237,22 +218,6 @@ const Sidebar = ({
|
|||||||
}
|
}
|
||||||
}, [onCreateCorrespondent]);
|
}, [onCreateCorrespondent]);
|
||||||
|
|
||||||
const handleCreateDocumentType = useCallback(async () => {
|
|
||||||
const input = window.prompt('New document type name');
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await onCreateDocumentType?.(trimmed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[sidebar] failed to create document type', error);
|
|
||||||
}
|
|
||||||
}, [onCreateDocumentType]);
|
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(() => {
|
const handleCreateFolder = useCallback(() => {
|
||||||
if (creatingFolder) {
|
if (creatingFolder) {
|
||||||
return;
|
return;
|
||||||
@@ -629,58 +594,6 @@ const Sidebar = ({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
|
||||||
<div className="sidebar-section__header">
|
|
||||||
<h3>Document Types</h3>
|
|
||||||
<div className="sidebar-section__actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={handleCreateDocumentType}
|
|
||||||
aria-label="Create document type"
|
|
||||||
>
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={handleManageDocumentTypes}
|
|
||||||
aria-label="Manage document types"
|
|
||||||
>
|
|
||||||
<SettingsIcon size={16} />
|
|
||||||
</button>
|
|
||||||
<span className="meta">{documentTypes.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="sidebar-correspondent-list">
|
|
||||||
{sortedDocumentTypes.map((entry) => {
|
|
||||||
const isActive = activeDocumentTypeSet.has(entry.id);
|
|
||||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
|
||||||
const label = entry.name || 'Untitled';
|
|
||||||
const handleSelect = () => {
|
|
||||||
const nextId = isActive ? null : entry.id;
|
|
||||||
onToggleDocumentTypeFilter?.(nextId);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<li key={entry.id}>
|
|
||||||
<span
|
|
||||||
className={className}
|
|
||||||
role="button"
|
|
||||||
onClick={handleSelect}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleSelect();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
|
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section__header">
|
<div className="sidebar-section__header">
|
||||||
|
|||||||
@@ -2016,32 +2016,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-type-inline {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.2;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-type-inline.active {
|
|
||||||
color: var(--accent-strong, var(--accent));
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-type-inline[role='button'] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-type-inline[role='button']:hover,
|
|
||||||
.doc-type-inline[role='button']:focus-visible {
|
|
||||||
color: var(--accent-strong, var(--accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-type-inline[role='button']:focus-visible {
|
|
||||||
outline: 2px solid currentColor;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-correspondent-link {
|
.doc-correspondent-link {
|
||||||
background: none;
|
background: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
Reference in New Issue
Block a user