document types
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN created_at TO uploaded_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE documents
|
||||
RENAME COLUMN uploaded_at TO created_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS documents_document_type_idx;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN IF EXISTS document_type_id;
|
||||
|
||||
DROP TABLE IF EXISTS document_types;
|
||||
@@ -0,0 +1,11 @@
|
||||
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,6 +248,23 @@ pub struct Document {
|
||||
pub title: String,
|
||||
pub current_version_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)]
|
||||
@@ -263,6 +280,7 @@ pub struct NewDocument {
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub tenant_id: Uuid,
|
||||
pub document_type_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
|
||||
@@ -48,6 +48,10 @@ use uuid::Uuid;
|
||||
doc::create_tag,
|
||||
doc::update_tag,
|
||||
doc::delete_tag,
|
||||
doc::list_document_types,
|
||||
doc::create_document_type,
|
||||
doc::update_document_type,
|
||||
doc::delete_document_type,
|
||||
doc::list_correspondents,
|
||||
doc::create_correspondent,
|
||||
doc::update_correspondent,
|
||||
@@ -83,6 +87,9 @@ use uuid::Uuid;
|
||||
schemas::DocumentAssetSummary,
|
||||
schemas::DocumentAssetDetail,
|
||||
schemas::DocumentAssetObject,
|
||||
schemas::DocumentTypeResponse,
|
||||
schemas::CreateDocumentTypeRequest,
|
||||
schemas::UpdateDocumentTypeRequest,
|
||||
schemas::DocumentCorrespondent,
|
||||
schemas::DocumentTag,
|
||||
schemas::UpdateDocumentRequest,
|
||||
@@ -132,6 +139,7 @@ use uuid::Uuid;
|
||||
(name = "Assets", description = "Document assets"),
|
||||
(name = "Folders", description = "Folder management"),
|
||||
(name = "Tags", description = "Tag catalog"),
|
||||
(name = "DocumentTypes", description = "Document type catalog"),
|
||||
(name = "Correspondents", description = "Correspondent catalog"),
|
||||
(name = "Profile", description = "User profile and WebDAV tokens")
|
||||
)
|
||||
@@ -576,6 +584,42 @@ mod doc {
|
||||
)]
|
||||
pub(super) fn create_tag() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/document-types",
|
||||
responses((status = 200, description = "Document types", body = [DocumentTypeResponse])),
|
||||
tag = "DocumentTypes"
|
||||
)]
|
||||
pub(super) fn list_document_types() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/document-types",
|
||||
request_body = CreateDocumentTypeRequest,
|
||||
responses((status = 201, description = "Document type created", body = DocumentTypeResponse)),
|
||||
tag = "DocumentTypes"
|
||||
)]
|
||||
pub(super) fn create_document_type() {}
|
||||
|
||||
#[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(super) fn update_document_type() {}
|
||||
|
||||
#[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(super) fn delete_document_type() {}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/tags/{id}",
|
||||
@@ -773,6 +817,7 @@ pub mod schemas {
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
pub document_types: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
@@ -858,6 +903,9 @@ pub mod schemas {
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
pub use crate::routes::document_types::{CreateDocumentTypeRequest, UpdateDocumentTypeRequest};
|
||||
pub use crate::routes::documents::DocumentTypeResponse;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
@@ -875,6 +923,10 @@ pub mod schemas {
|
||||
#[schema(nullable)]
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
#[schema(nullable)]
|
||||
pub document_type_id: Option<Uuid>,
|
||||
#[schema(nullable)]
|
||||
pub document_type: Option<DocumentTypeResponse>,
|
||||
pub tags: Vec<DocumentTag>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondent>,
|
||||
@@ -909,6 +961,8 @@ pub mod schemas {
|
||||
pub issued_at: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
#[schema(nullable)]
|
||||
pub document_type_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
};
|
||||
|
||||
use super::documents::DocumentTypeResponse;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateDocumentTypeRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentTypeRequest {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_document_type(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateDocumentTypeRequest>,
|
||||
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let new_type = NewDocumentType {
|
||||
id: Uuid::new_v4(),
|
||||
tenant_id,
|
||||
name: name.to_string(),
|
||||
};
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
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 = payload
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| 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)),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use diesel::dsl::exists;
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select};
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -37,12 +37,13 @@ use crate::documents::{
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT};
|
||||
use crate::models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
|
||||
NewDocumentVersion, Tag,
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentType, DocumentVersion, NewDocument,
|
||||
NewDocumentTag, NewDocumentVersion, Tag,
|
||||
};
|
||||
use crate::schema::{
|
||||
document_asset_objects, document_assets, document_correspondents, document_tags,
|
||||
document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
||||
document_types, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl,
|
||||
tags,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{
|
||||
@@ -65,6 +66,7 @@ pub struct DocumentListQuery {
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
pub document_types: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
@@ -128,6 +130,21 @@ 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)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
@@ -141,6 +158,10 @@ pub struct DocumentResponse {
|
||||
pub deleted_at: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_type_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_type: Option<DocumentTypeResponse>,
|
||||
pub tags: Vec<TagResponse>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
||||
@@ -181,6 +202,9 @@ pub struct UpdateDocumentRequest {
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable, value_type = Option<Uuid>)]
|
||||
pub document_type_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -234,6 +258,7 @@ struct DocumentUpdateChangeset {
|
||||
issued_at: Option<Option<NaiveDateTime>>,
|
||||
metadata: Option<Value>,
|
||||
updated_at: Option<NaiveDateTime>,
|
||||
document_type_id: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||
@@ -346,6 +371,7 @@ pub async fn list_documents(
|
||||
query,
|
||||
tags,
|
||||
correspondents,
|
||||
document_types,
|
||||
status,
|
||||
} = params;
|
||||
|
||||
@@ -378,6 +404,11 @@ pub async fn list_documents(
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.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);
|
||||
|
||||
@@ -517,6 +548,20 @@ 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 set.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
@@ -565,6 +610,20 @@ pub async fn list_documents(
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
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>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|typ| (typ.id, typ))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
@@ -576,6 +635,9 @@ pub async fn list_documents(
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
||||
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());
|
||||
response.push(to_document_response(
|
||||
&state,
|
||||
user_id,
|
||||
@@ -583,6 +645,7 @@ pub async fn list_documents(
|
||||
tags,
|
||||
correspondents,
|
||||
current_version,
|
||||
doc_type,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -664,6 +727,8 @@ pub async fn get_document(
|
||||
.find(doc.current_version_id)
|
||||
.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 mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
|
||||
let version_id = current_version.id;
|
||||
@@ -680,6 +745,7 @@ pub async fn get_document(
|
||||
tags_map.get(&document_id).cloned(),
|
||||
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||
Some((version_response, assets)),
|
||||
doc_type,
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
@@ -1314,6 +1380,46 @@ 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 {
|
||||
return Err(AppError::bad_request("no changes provided"));
|
||||
}
|
||||
@@ -1347,6 +1453,8 @@ pub async fn update_document(
|
||||
.find(document.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
|
||||
|
||||
if title_changed {
|
||||
if let Err(err) = enqueue_job(
|
||||
&mut conn,
|
||||
@@ -1383,6 +1491,7 @@ pub async fn update_document(
|
||||
tags_map.get(&document_id).cloned(),
|
||||
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||
Some((version_response, assets)),
|
||||
doc_type,
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
@@ -2005,6 +2114,7 @@ async fn process_upload(
|
||||
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 mut correspondents_map =
|
||||
load_correspondents_for_documents(&mut conn, &[document.id])?;
|
||||
@@ -2028,6 +2138,7 @@ async fn process_upload(
|
||||
tags,
|
||||
correspondents,
|
||||
Some((version_response, assets)),
|
||||
doc_type,
|
||||
)?,
|
||||
}));
|
||||
}
|
||||
@@ -2067,6 +2178,7 @@ async fn process_upload(
|
||||
title: derived_title.clone(),
|
||||
metadata: metadata_value.clone(),
|
||||
tenant_id,
|
||||
document_type_id: None,
|
||||
};
|
||||
diesel::insert_into(documents::table)
|
||||
.values(&new_document)
|
||||
@@ -2125,6 +2237,7 @@ 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 mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?;
|
||||
let tags = tags_map.get(&doc_id).cloned();
|
||||
@@ -2139,6 +2252,7 @@ async fn process_upload(
|
||||
tags,
|
||||
correspondents,
|
||||
Some((to_version_response(version.clone()), Vec::new())),
|
||||
doc_type,
|
||||
)?,
|
||||
}
|
||||
};
|
||||
@@ -2171,6 +2285,7 @@ pub(crate) fn to_document_response(
|
||||
tags: Option<Vec<Tag>>,
|
||||
correspondents: Vec<DocumentCorrespondentResponse>,
|
||||
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
||||
doc_type: Option<DocumentType>,
|
||||
) -> AppResult<DocumentResponse> {
|
||||
let current_version = if let Some((version, assets)) = current_version {
|
||||
let download_path = build_download_path(state, &doc, user_id)?;
|
||||
@@ -2195,6 +2310,11 @@ pub(crate) fn to_document_response(
|
||||
deleted_at: doc.deleted_at.map(to_iso),
|
||||
issued_at: doc.issued_at.map(to_iso),
|
||||
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
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -2204,3 +2324,19 @@ pub(crate) fn to_document_response(
|
||||
current_version,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::models::{Document, DocumentType, Folder, NewFolder};
|
||||
use crate::schema::{document_types, documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
@@ -320,6 +322,20 @@ pub async fn list_folder_contents(
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
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>(&mut conn)?
|
||||
.into_iter()
|
||||
.map(|typ| (typ.id, typ))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
@@ -332,6 +348,9 @@ pub async fn list_folder_contents(
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
||||
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());
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user_id,
|
||||
@@ -339,6 +358,7 @@ pub async fn list_folder_contents(
|
||||
tags,
|
||||
correspondents,
|
||||
current_version,
|
||||
doc_type,
|
||||
)?);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
|
||||
|
||||
pub mod auth;
|
||||
pub mod correspondents;
|
||||
pub mod document_types;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod health;
|
||||
@@ -132,6 +133,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/", get(tags::list_tags).post(tags::create_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()
|
||||
.route(
|
||||
"/",
|
||||
@@ -159,6 +171,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/document-types", document_types_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/profile", profile_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
|
||||
@@ -62,6 +62,15 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_types (id) {
|
||||
id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
#[max_length = 100]
|
||||
name -> Varchar,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_versions (id) {
|
||||
id -> Uuid,
|
||||
@@ -97,6 +106,7 @@ diesel::table! {
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
document_type_id -> Nullable<Uuid>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +260,9 @@ diesel::joinable!(document_tags -> documents (document_id));
|
||||
diesel::joinable!(document_tags -> tags (tag_id));
|
||||
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||
diesel::joinable!(document_tags -> users (assigned_by));
|
||||
diesel::joinable!(document_types -> 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 -> tenants (tenant_id));
|
||||
diesel::joinable!(folders -> tenants (tenant_id));
|
||||
@@ -271,6 +283,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
document_assets,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
document_types,
|
||||
document_versions,
|
||||
documents,
|
||||
folders,
|
||||
|
||||
@@ -23,6 +23,10 @@ struct DocumentInfo {
|
||||
deleted_at: Option<String>,
|
||||
issued_at: Option<String>,
|
||||
metadata: Value,
|
||||
#[serde(default)]
|
||||
document_type_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
document_type: Option<DocumentTypeInfo>,
|
||||
tags: Vec<TagSummary>,
|
||||
#[serde(default)]
|
||||
correspondents: Vec<DocumentCorrespondentInfo>,
|
||||
@@ -57,6 +61,10 @@ struct DocumentAssetInfo {
|
||||
struct DocumentListItem {
|
||||
id: Uuid,
|
||||
#[serde(default)]
|
||||
document_type_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
document_type: Option<DocumentTypeInfo>,
|
||||
#[serde(default)]
|
||||
current_version: Option<DocumentVersionPayload>,
|
||||
}
|
||||
|
||||
@@ -87,6 +95,12 @@ struct DocumentCorrespondentInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentTypeInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnalyzeJobPayload {
|
||||
document_id: Uuid,
|
||||
@@ -1446,6 +1460,141 @@ async fn list_documents_by_status_filter() -> Result<()> {
|
||||
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)?;
|
||||
|
||||
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));
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
|
||||
@@ -37,6 +37,32 @@ const sortCorrespondents = (entries = []) =>
|
||||
}))
|
||||
.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 === 'string') {
|
||||
const name = type.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return { id: fallbackId, name };
|
||||
}
|
||||
if (typeof type === 'object') {
|
||||
const name = (type.name || type.label || '').trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return { id: type.id ?? fallbackId, name };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||
<div className="correspondent-list">
|
||||
{entries.length ? (
|
||||
@@ -302,6 +328,11 @@ const DetailPanel = ({
|
||||
correspondents = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
documentTypes = [],
|
||||
onDocumentTypeSet,
|
||||
onDocumentTypeClear,
|
||||
onBulkDocumentTypeSet,
|
||||
onBulkDocumentTypeClear,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
resolveFolderPath = null,
|
||||
@@ -590,6 +621,23 @@ const DetailPanel = ({
|
||||
});
|
||||
}, [availableCorrespondents]);
|
||||
|
||||
const documentTypeOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
return (documentTypes || [])
|
||||
.map((entry) => (entry?.name || '').trim())
|
||||
.filter((name) => {
|
||||
if (!name) return false;
|
||||
const lower = name.toLowerCase();
|
||||
if (seen.has(lower)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(lower);
|
||||
return true;
|
||||
});
|
||||
}, [documentTypes]);
|
||||
|
||||
const singleDocumentType = useMemo(() => resolveDocumentType(singleDoc), [singleDoc]);
|
||||
|
||||
const singleCorrespondents = useMemo(() => {
|
||||
if (!singleDoc) return [];
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
@@ -725,6 +773,42 @@ const DetailPanel = ({
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
}, [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(
|
||||
(entry) => {
|
||||
if (!entry?.id) return;
|
||||
@@ -1092,6 +1176,53 @@ const DetailPanel = ({
|
||||
);
|
||||
})}
|
||||
</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
|
||||
title="Tags"
|
||||
tags={tagsForDoc.map((tag) => ({
|
||||
@@ -1216,6 +1347,47 @@ const DetailPanel = ({
|
||||
datalistOptions={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
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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,6 +56,37 @@ const resolveCorrespondents = (doc) => {
|
||||
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 === 'string') {
|
||||
const name = type.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return { id: fallbackId, name };
|
||||
}
|
||||
|
||||
if (typeof type === 'object') {
|
||||
const name = (type.name || type.label || '').trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return { id: type.id ?? fallbackId, name };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Detects when an element becomes visible within a scroll container.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
@@ -231,6 +262,8 @@ const DocumentsTable = ({
|
||||
getDownloadHref,
|
||||
onTagClick,
|
||||
onCorrespondentClick,
|
||||
activeDocumentTypeIds = [],
|
||||
onDocumentTypeClick,
|
||||
isSearchLoading = false,
|
||||
onDocumentTagDrop,
|
||||
viewMode = 'list',
|
||||
@@ -257,6 +290,10 @@ const DocumentsTable = ({
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const activeDocumentTypeIdSet = useMemo(
|
||||
() => new Set(activeDocumentTypeIds || []),
|
||||
[activeDocumentTypeIds],
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const suppressDocumentClickRef = useRef(false);
|
||||
const [, forceVisibilityTick] = useState(0);
|
||||
@@ -628,6 +665,11 @@ const DocumentsTable = ({
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
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'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
@@ -663,6 +705,32 @@ const DocumentsTable = ({
|
||||
className="document-card__title"
|
||||
title={doc.title || doc.original_name}
|
||||
>
|
||||
{documentType ? (
|
||||
<span
|
||||
className={`doc-type-label${
|
||||
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}
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
@@ -854,6 +922,9 @@ const DocumentsTable = ({
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const downloadHref = getDownloadHref?.(doc) || null;
|
||||
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';
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -883,6 +954,32 @@ const DocumentsTable = ({
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
{documentType ? (
|
||||
<span
|
||||
className={`doc-type-label${
|
||||
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}
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
{renderCorrespondentLinks(correspondents)}
|
||||
|
||||
+489
-36
@@ -27,6 +27,7 @@ import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAsset
|
||||
import useApiError from './hooks/useApiError';
|
||||
import TagsPanel from './tags/TagsPanel';
|
||||
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||
import DocumentTypesPanel from './documentTypes/DocumentTypesPanel';
|
||||
import TagManager from './tag_manager';
|
||||
import Sidebar from './sidebar/Sidebar';
|
||||
import SettingsModal from './settings/SettingsModal';
|
||||
@@ -442,6 +443,7 @@ const AppLayout = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isTagsModalOpen, setTagsModalOpen] = useState(false);
|
||||
const [isCorrespondentsModalOpen, setCorrespondentsModalOpen] = useState(false);
|
||||
const [isDocumentTypesModalOpen, setDocumentTypesModalOpen] = useState(false);
|
||||
const [isSettingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderNodes, setFolderNodes] = useState(() => {
|
||||
@@ -531,6 +533,7 @@ const AppLayout = () => {
|
||||
const previewInflightRef = useRef(new Map());
|
||||
const [tags, setTags] = useState([]);
|
||||
const [correspondents, setCorrespondents] = useState([]);
|
||||
const [documentTypes, setDocumentTypes] = useState([]);
|
||||
const [webdavTokens, setWebdavTokens] = useState([]);
|
||||
const [webdavTokensLoading, setWebdavTokensLoading] = useState(false);
|
||||
const [creatingWebdavToken, setCreatingWebdavToken] = useState(false);
|
||||
@@ -539,6 +542,7 @@ const AppLayout = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||
const [activeDocumentTypeFilters, setActiveDocumentTypeFilters] = useState([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const documentsRouteMatch = useMatch('/documents');
|
||||
const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId');
|
||||
@@ -564,6 +568,15 @@ const AppLayout = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDocumentTypeFilter = useCallback((documentTypeId) => {
|
||||
setActiveDocumentTypeFilters((previous) => {
|
||||
if (!documentTypeId) {
|
||||
return [];
|
||||
}
|
||||
return previous.includes(documentTypeId) ? [] : [documentTypeId];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -599,6 +612,7 @@ const AppLayout = () => {
|
||||
setSearchQuery('');
|
||||
setActiveTagFilters([]);
|
||||
setActiveCorrespondentFilters([]);
|
||||
setActiveDocumentTypeFilters([]);
|
||||
setSearchLoading(false);
|
||||
}, []);
|
||||
|
||||
@@ -628,6 +642,17 @@ const AppLayout = () => {
|
||||
}
|
||||
const assetManager = assetManagerRef.current;
|
||||
|
||||
const extractDocumentFromResponse = useCallback(
|
||||
(payload) => {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const hydratedDetail = assetManager.hydrateDetail(payload);
|
||||
return hydratedDetail?.document || payload.document || payload;
|
||||
},
|
||||
[assetManager],
|
||||
);
|
||||
|
||||
const tagManagerRef = useRef(null);
|
||||
if (!tagManagerRef.current) {
|
||||
tagManagerRef.current = new TagManager();
|
||||
@@ -692,6 +717,7 @@ const AppLayout = () => {
|
||||
setSearchResults(null);
|
||||
setTags([]);
|
||||
setCorrespondents([]);
|
||||
setDocumentTypes([]);
|
||||
setWebdavTokens([]);
|
||||
setWebdavTokensLoading(false);
|
||||
setCreatingWebdavToken(false);
|
||||
@@ -699,6 +725,8 @@ const AppLayout = () => {
|
||||
setWebdavTokenSecret(null);
|
||||
setSearchQuery('');
|
||||
setActiveTagFilters([]);
|
||||
setActiveCorrespondentFilters([]);
|
||||
setActiveDocumentTypeFilters([]);
|
||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||
setActivePreviewId(null);
|
||||
setDetailPanelOpen(false);
|
||||
@@ -735,6 +763,26 @@ const AppLayout = () => {
|
||||
return map;
|
||||
}, [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 nextSet = new Set(nextSelection);
|
||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||
@@ -870,8 +918,9 @@ const AppLayout = () => {
|
||||
() =>
|
||||
searchQuery.trim().length > 0 ||
|
||||
activeTagFilters.length > 0 ||
|
||||
activeCorrespondentFilters.length > 0,
|
||||
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
||||
activeCorrespondentFilters.length > 0 ||
|
||||
activeDocumentTypeFilters.length > 0,
|
||||
[searchQuery, activeTagFilters, activeCorrespondentFilters, activeDocumentTypeFilters],
|
||||
);
|
||||
|
||||
const applySelectedFolder = useCallback(
|
||||
@@ -1696,6 +1745,22 @@ const AppLayout = () => {
|
||||
}
|
||||
}, [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 () => {
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -2049,6 +2114,308 @@ 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 = (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 ?? (typeof existingType === 'object' ? existingType?.id : null);
|
||||
const existingTypeName =
|
||||
typeof existingType === 'string'
|
||||
? existingType
|
||||
: existingType?.name || existingType?.label || '';
|
||||
|
||||
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 }, { 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);
|
||||
next.document_type = entry || { id: documentTypeId, name: entry?.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 = (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 },
|
||||
{ notify: true },
|
||||
);
|
||||
|
||||
if (success && input) {
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
[
|
||||
handleDocumentTypeCreate,
|
||||
handleDocumentTypeAssign,
|
||||
documentTypeLookupByName,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const resolveTargetDocumentIds = useCallback(
|
||||
(candidateIds) => {
|
||||
const normalized = Array.isArray(candidateIds)
|
||||
? candidateIds.filter(Boolean)
|
||||
: [];
|
||||
if (normalized.length) {
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
return selectedDocumentIds;
|
||||
},
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const handleBulkDocumentTypeSet = useCallback(
|
||||
async ({ name, input, documentIds }) => {
|
||||
const trimmed = (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(
|
||||
async (tagId) => {
|
||||
if (!tagId) {
|
||||
@@ -2249,7 +2616,7 @@ const AppLayout = () => {
|
||||
const initializeAfterLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]);
|
||||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||||
await loadFolder(initialFolder, { showLoading: false });
|
||||
} catch (error) {
|
||||
@@ -2258,7 +2625,7 @@ const AppLayout = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
|
||||
}, [refreshTags, refreshCorrespondents, refreshDocumentTypes, routeFolderId, loadFolder, notifyApiError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -2289,19 +2656,6 @@ const AppLayout = () => {
|
||||
selectFolder,
|
||||
]);
|
||||
|
||||
const resolveTargetDocumentIds = useCallback(
|
||||
(candidateIds) => {
|
||||
const normalized = Array.isArray(candidateIds)
|
||||
? candidateIds.filter(Boolean)
|
||||
: [];
|
||||
if (normalized.length) {
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
return selectedDocumentIds;
|
||||
},
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const handleBulkCorrespondentAdd = useCallback(
|
||||
async ({ name, input, documentIds }) => {
|
||||
const trimmed = (name || '').trim();
|
||||
@@ -3582,16 +3936,15 @@ const AppLayout = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
||||
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||
const updatedDocument = extractDocumentFromResponse(data);
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (hydratedDocument) {
|
||||
return { ...doc, ...hydratedDocument };
|
||||
}
|
||||
return { ...doc, title: trimmed };
|
||||
});
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, title: trimmed };
|
||||
});
|
||||
|
||||
setStatusMessage('Document title updated.', 'success');
|
||||
return true;
|
||||
@@ -3603,7 +3956,7 @@ const AppLayout = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
[notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse],
|
||||
);
|
||||
|
||||
const applyTagRemovalToCaches = useCallback(
|
||||
@@ -3967,6 +4320,7 @@ const AppLayout = () => {
|
||||
|
||||
const openTagsModal = useCallback(() => {
|
||||
setCorrespondentsModalOpen(false);
|
||||
setDocumentTypesModalOpen(false);
|
||||
setTagsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -3977,12 +4331,23 @@ const AppLayout = () => {
|
||||
const openCorrespondentsModal = useCallback(() => {
|
||||
setTagsModalOpen(false);
|
||||
setCorrespondentsModalOpen(true);
|
||||
setDocumentTypesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const closeCorrespondentsModal = useCallback(() => {
|
||||
setCorrespondentsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const openDocumentTypesModal = useCallback(() => {
|
||||
setTagsModalOpen(false);
|
||||
setCorrespondentsModalOpen(false);
|
||||
setDocumentTypesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDocumentTypesModal = useCallback(() => {
|
||||
setDocumentTypesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const openSettingsModal = useCallback(() => {
|
||||
setSettingsModalOpen(true);
|
||||
}, []);
|
||||
@@ -3994,11 +4359,12 @@ const AppLayout = () => {
|
||||
useEffect(() => {
|
||||
setTagsModalOpen(false);
|
||||
setCorrespondentsModalOpen(false);
|
||||
setDocumentTypesModalOpen(false);
|
||||
setSettingsModalOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTagsModalOpen && !isCorrespondentsModalOpen && !isSettingsModalOpen) {
|
||||
if (!isTagsModalOpen && !isCorrespondentsModalOpen && !isDocumentTypesModalOpen && !isSettingsModalOpen) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -4008,6 +4374,8 @@ const AppLayout = () => {
|
||||
closeTagsModal();
|
||||
} else if (isCorrespondentsModalOpen) {
|
||||
closeCorrespondentsModal();
|
||||
} else if (isDocumentTypesModalOpen) {
|
||||
closeDocumentTypesModal();
|
||||
} else if (isSettingsModalOpen) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
@@ -4020,9 +4388,11 @@ const AppLayout = () => {
|
||||
}, [
|
||||
isTagsModalOpen,
|
||||
isCorrespondentsModalOpen,
|
||||
isDocumentTypesModalOpen,
|
||||
isSettingsModalOpen,
|
||||
closeTagsModal,
|
||||
closeCorrespondentsModal,
|
||||
closeDocumentTypesModal,
|
||||
closeSettingsModal,
|
||||
]);
|
||||
|
||||
@@ -4054,6 +4424,9 @@ const AppLayout = () => {
|
||||
if (activeCorrespondentFilters.length) {
|
||||
params.correspondents = activeCorrespondentFilters.join(',');
|
||||
}
|
||||
if (activeDocumentTypeFilters.length) {
|
||||
params.document_types = activeDocumentTypeFilters.join(',');
|
||||
}
|
||||
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
||||
if (folderIdentifier) {
|
||||
params.folder_id = folderIdentifier;
|
||||
@@ -4135,6 +4508,7 @@ const AppLayout = () => {
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
activeDocumentTypeFilters,
|
||||
selectedFolder,
|
||||
notifyApiError,
|
||||
assetManager,
|
||||
@@ -4936,7 +5310,7 @@ const AppLayout = () => {
|
||||
setWorkspaceMode('table');
|
||||
navigate('/documents', { replace: true });
|
||||
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
await Promise.all([refreshTags(), refreshCorrespondents(), refreshDocumentTypes()]);
|
||||
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||
|
||||
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||
@@ -4957,6 +5331,7 @@ const AppLayout = () => {
|
||||
navigate,
|
||||
refreshTags,
|
||||
refreshCorrespondents,
|
||||
refreshDocumentTypes,
|
||||
loadFolder,
|
||||
],
|
||||
);
|
||||
@@ -5082,6 +5457,10 @@ const AppLayout = () => {
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
onToggleCorrespondentFilter: toggleCorrespondentFilter,
|
||||
onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }),
|
||||
documentTypes,
|
||||
activeDocumentTypeIds: activeDocumentTypeFilters,
|
||||
onToggleDocumentTypeFilter: toggleDocumentTypeFilter,
|
||||
onCreateDocumentType: (name) => handleDocumentTypeCreate({ name }),
|
||||
appStatus,
|
||||
loading,
|
||||
previewActive,
|
||||
@@ -5105,6 +5484,7 @@ const AppLayout = () => {
|
||||
clearFilters,
|
||||
correspondents,
|
||||
currentTenantId,
|
||||
documentTypes,
|
||||
folderClickHandlers,
|
||||
folderNodes,
|
||||
handleFolderDelete,
|
||||
@@ -5130,10 +5510,13 @@ const AppLayout = () => {
|
||||
toggleTagFilter,
|
||||
handleTagCreate,
|
||||
handleCorrespondentCreate,
|
||||
toggleDocumentTypeFilter,
|
||||
handleDocumentTypeCreate,
|
||||
handlePromptCreateFolder,
|
||||
creatingFolder,
|
||||
handleNeutralHueChange,
|
||||
neutralHue,
|
||||
activeDocumentTypeFilters,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5168,6 +5551,11 @@ const AppLayout = () => {
|
||||
correspondents,
|
||||
onCorrespondentAdd: handleCorrespondentAdd,
|
||||
onCorrespondentRemove: handleCorrespondentRemove,
|
||||
documentTypes,
|
||||
onDocumentTypeSet: handleDocumentTypeSet,
|
||||
onDocumentTypeClear: handleDocumentTypeClear,
|
||||
onBulkDocumentTypeSet: handleBulkDocumentTypeSet,
|
||||
onBulkDocumentTypeClear: handleBulkDocumentTypeClear,
|
||||
resolveApiPath,
|
||||
onFolderNavigate: selectFolder,
|
||||
onClose: handleDetailPanelClose,
|
||||
@@ -5182,11 +5570,15 @@ const AppLayout = () => {
|
||||
getDocumentAsset,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkDocumentTypeSet,
|
||||
handleBulkDocumentTypeClear,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleCorrespondentAdd,
|
||||
handleCorrespondentRemove,
|
||||
handleDocumentTypeSet,
|
||||
handleDocumentTypeClear,
|
||||
handleDetailPanelClose,
|
||||
handleDocumentTitleUpdate,
|
||||
handleTagAdd,
|
||||
@@ -5199,6 +5591,7 @@ const AppLayout = () => {
|
||||
selectedPreviewEntry,
|
||||
tags,
|
||||
tagLookupById,
|
||||
documentTypes,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5257,6 +5650,16 @@ const AppLayout = () => {
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
documentTypes,
|
||||
refreshDocumentTypes,
|
||||
handleDocumentTypeUpdate,
|
||||
handleDocumentTypeCreate,
|
||||
handleDocumentTypeDelete,
|
||||
handleDocumentTypeAssign,
|
||||
handleDocumentTypeClear,
|
||||
handleDocumentTypeSet,
|
||||
handleBulkDocumentTypeSet,
|
||||
handleBulkDocumentTypeClear,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
@@ -5275,6 +5678,7 @@ const AppLayout = () => {
|
||||
notifyApiError,
|
||||
openTagsModal,
|
||||
openCorrespondentsModal,
|
||||
openDocumentTypesModal,
|
||||
openSettingsModal,
|
||||
detailPanelOpen,
|
||||
setDetailPanelOpen,
|
||||
@@ -5300,6 +5704,16 @@ const AppLayout = () => {
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
documentTypes,
|
||||
refreshDocumentTypes,
|
||||
handleDocumentTypeUpdate,
|
||||
handleDocumentTypeCreate,
|
||||
handleDocumentTypeDelete,
|
||||
handleDocumentTypeAssign,
|
||||
handleDocumentTypeClear,
|
||||
handleDocumentTypeSet,
|
||||
handleBulkDocumentTypeSet,
|
||||
handleBulkDocumentTypeClear,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
@@ -5317,6 +5731,7 @@ const AppLayout = () => {
|
||||
notifyApiError,
|
||||
openTagsModal,
|
||||
openCorrespondentsModal,
|
||||
openDocumentTypesModal,
|
||||
openSettingsModal,
|
||||
detailPanelOpen,
|
||||
setDetailPanelOpen,
|
||||
@@ -5402,12 +5817,48 @@ const AppLayout = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-modal__body">
|
||||
<CorrespondentsPanel
|
||||
correspondents={correspondents}
|
||||
onRefresh={refreshCorrespondents}
|
||||
onCreate={handleCorrespondentCreate}
|
||||
onUpdate={handleCorrespondentUpdate}
|
||||
onDelete={handleCorrespondentDelete}
|
||||
<CorrespondentsPanel
|
||||
correspondents={correspondents}
|
||||
onRefresh={refreshCorrespondents}
|
||||
onCreate={handleCorrespondentCreate}
|
||||
onUpdate={handleCorrespondentUpdate}
|
||||
onDelete={handleCorrespondentDelete}
|
||||
onNotify={setStatusMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDocumentTypesModalOpen && (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={closeDocumentTypesModal}
|
||||
>
|
||||
<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={closeDocumentTypesModal}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-modal__body">
|
||||
<DocumentTypesPanel
|
||||
documentTypes={documentTypes}
|
||||
onRefresh={refreshDocumentTypes}
|
||||
onCreate={handleDocumentTypeCreate}
|
||||
onUpdate={handleDocumentTypeUpdate}
|
||||
onDelete={handleDocumentTypeDelete}
|
||||
onNotify={setStatusMessage}
|
||||
/>
|
||||
</div>
|
||||
@@ -5450,6 +5901,7 @@ const DocumentsRoute = () => {
|
||||
skeuoWorkspaceProps,
|
||||
openTagsModal,
|
||||
openCorrespondentsModal,
|
||||
openDocumentTypesModal,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
closeDocumentPreview,
|
||||
@@ -5471,9 +5923,10 @@ const DocumentsRoute = () => {
|
||||
...sidebarProps,
|
||||
onManageTags: openTagsModal,
|
||||
onManageCorrespondents: openCorrespondentsModal,
|
||||
onManageDocumentTypes: openDocumentTypesModal,
|
||||
onCollapse: collapseSidebar,
|
||||
}),
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal, openDocumentTypesModal, collapseSidebar],
|
||||
);
|
||||
|
||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
|
||||
const resolveDocumentTypeName = (doc) => doc?.document_type?.name;
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
previewEntry,
|
||||
@@ -19,6 +21,7 @@ const PreviewWorkspace = ({
|
||||
const tags = Array.isArray(document.tags)
|
||||
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
|
||||
: '';
|
||||
const documentTypeName = resolveDocumentTypeName(document);
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) {
|
||||
@@ -33,11 +36,15 @@ const PreviewWorkspace = ({
|
||||
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
||||
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
||||
{ label: 'Correspondent', value: correspondents || '—' },
|
||||
{ label: 'Document Type', value: document.document_type || '—' },
|
||||
{ label: 'Document Type', value: documentTypeName || '—' },
|
||||
{
|
||||
label: 'Filename',
|
||||
value: document.archive_path || document.filename || '—',
|
||||
},
|
||||
{
|
||||
label: 'Original Filename',
|
||||
value: document.original_name || '—',
|
||||
},
|
||||
{ label: 'Tags', value: tags || '—' },
|
||||
];
|
||||
|
||||
|
||||
@@ -151,10 +151,15 @@ const Sidebar = ({
|
||||
correspondents = [],
|
||||
activeCorrespondentIds = [],
|
||||
onToggleCorrespondentFilter,
|
||||
documentTypes = [],
|
||||
activeDocumentTypeIds = [],
|
||||
onToggleDocumentTypeFilter,
|
||||
onManageTags,
|
||||
onManageCorrespondents,
|
||||
onManageDocumentTypes,
|
||||
onCreateTag,
|
||||
onCreateCorrespondent,
|
||||
onCreateDocumentType,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
@@ -180,10 +185,22 @@ const Sidebar = ({
|
||||
() => new Set(activeCorrespondentIds || []),
|
||||
[activeCorrespondentIds],
|
||||
);
|
||||
const sortedDocumentTypes = useMemo(
|
||||
() =>
|
||||
[...documentTypes].sort((a, b) =>
|
||||
(a?.name || '').localeCompare(b?.name || '', undefined, { sensitivity: 'base' }),
|
||||
),
|
||||
[documentTypes],
|
||||
);
|
||||
const activeDocumentTypeSet = useMemo(
|
||||
() => new Set(activeDocumentTypeIds || []),
|
||||
[activeDocumentTypeIds],
|
||||
);
|
||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||
const activeTagSet = new Set(activeTagIds);
|
||||
const handleManageTags = onManageTags || (() => {});
|
||||
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||
const handleManageDocumentTypes = onManageDocumentTypes || (() => {});
|
||||
const handleCreateTag = useCallback(async () => {
|
||||
const input = window.prompt('New tag name');
|
||||
if (!input) {
|
||||
@@ -216,6 +233,22 @@ const Sidebar = ({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (creatingFolder) {
|
||||
return;
|
||||
@@ -592,6 +625,58 @@ const Sidebar = ({
|
||||
})}
|
||||
</ul>
|
||||
</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' ? (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
|
||||
@@ -2012,6 +2012,43 @@ button.danger:hover:not([disabled]) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.doc-type-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
cursor: default;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-type-label[role='button'] {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.doc-type-label[role='button']:hover,
|
||||
.doc-type-label[role='button']:focus-visible {
|
||||
color: var(--accent-strong, var(--accent));
|
||||
background: color-mix(in oklch, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.doc-type-label[role='button']:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.doc-type-label.active {
|
||||
background: color-mix(in oklch, var(--accent) 28%, transparent);
|
||||
color: var(--accent-strong, var(--accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-correspondent-link {
|
||||
background: none;
|
||||
background-color: transparent;
|
||||
@@ -2492,6 +2529,27 @@ button.danger:hover:not([disabled]) {
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
margin: 0.9rem 0;
|
||||
}
|
||||
|
||||
.detail-field__label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-field__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-field__value .meta {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user