From 813ce24aeb47aa6f191737d39d5dc082fbf8a668 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 00:12:51 +0100 Subject: [PATCH] document types --- .../down.sql | 2 + .../up.sql | 2 + .../202510300002_add_document_types/down.sql | 6 + .../202510300002_add_document_types/up.sql | 11 + backend/src/models.rs | 18 + backend/src/openapi.rs | 54 ++ backend/src/routes/document_types.rs | 142 +++++ backend/src/routes/documents.rs | 144 ++++- backend/src/routes/folders.rs | 24 +- backend/src/routes/mod.rs | 13 + backend/src/schema.rs | 13 + backend/tests/documents_flow.rs | 149 +++++ frontend/src/detail/DetailPanel.jsx | 172 ++++++ .../src/documentTypes/DocumentTypesPanel.jsx | 233 ++++++++ frontend/src/documents/DocumentsTable.jsx | 97 ++++ frontend/src/index.jsx | 525 ++++++++++++++++-- frontend/src/preview/PreviewWorkspace.jsx | 9 +- frontend/src/sidebar/Sidebar.jsx | 85 +++ frontend/src/styles.css | 58 ++ 19 files changed, 1714 insertions(+), 43 deletions(-) create mode 100644 backend/migrations/202510300001_rename_document_uploaded_at/down.sql create mode 100644 backend/migrations/202510300001_rename_document_uploaded_at/up.sql create mode 100644 backend/migrations/202510300002_add_document_types/down.sql create mode 100644 backend/migrations/202510300002_add_document_types/up.sql create mode 100644 backend/src/routes/document_types.rs create mode 100644 frontend/src/documentTypes/DocumentTypesPanel.jsx diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/down.sql b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql new file mode 100644 index 0000000..9a70eb4 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN created_at TO uploaded_at; diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/up.sql b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql new file mode 100644 index 0000000..7fbf576 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN uploaded_at TO created_at; diff --git a/backend/migrations/202510300002_add_document_types/down.sql b/backend/migrations/202510300002_add_document_types/down.sql new file mode 100644 index 0000000..a2ba105 --- /dev/null +++ b/backend/migrations/202510300002_add_document_types/down.sql @@ -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; diff --git a/backend/migrations/202510300002_add_document_types/up.sql b/backend/migrations/202510300002_add_document_types/up.sql new file mode 100644 index 0000000..7f72e15 --- /dev/null +++ b/backend/migrations/202510300002_add_document_types/up.sql @@ -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); diff --git a/backend/src/models.rs b/backend/src/models.rs index 3273eaa..fd4ec24 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -248,6 +248,23 @@ pub struct Document { pub title: String, pub current_version_id: Uuid, pub tenant_id: Uuid, + pub document_type_id: Option, +} + +#[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, pub title: String, pub tenant_id: Uuid, + pub document_type_id: Option, } #[derive(Debug, Clone, Queryable, Identifiable, Associations)] diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index ed8a59c..8cef517 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -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, pub tags: Option, pub correspondents: Option, + pub document_types: Option, #[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, pub metadata: Value, + #[schema(nullable)] + pub document_type_id: Option, + #[schema(nullable)] + pub document_type: Option, pub tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub correspondents: Vec, @@ -909,6 +961,8 @@ pub mod schemas { pub issued_at: Option, #[schema(nullable)] pub metadata: Option, + #[schema(nullable)] + pub document_type_id: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/document_types.rs b/backend/src/routes/document_types.rs new file mode 100644 index 0000000..be808b7 --- /dev/null +++ b/backend/src/routes/document_types.rs @@ -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, +} + +pub async fn list_document_types( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let types: Vec = 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, +) -> AppResult<(StatusCode, Json)> { + 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::(&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, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + 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::(&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, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + 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) +} diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index cea7bb8..4c0b003 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -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, pub tags: Option, pub correspondents: Option, + pub document_types: Option, #[serde(default = "default_document_status_filter")] pub status: DocumentStatusFilter, } @@ -128,6 +130,21 @@ impl From for TagResponse { } } +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct DocumentTypeResponse { + pub id: Uuid, + pub name: String, +} + +impl From 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, pub issued_at: Option, pub metadata: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_type_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_type: Option, pub tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub correspondents: Vec, @@ -181,6 +202,9 @@ pub struct UpdateDocumentRequest { #[serde(default)] #[schema(nullable)] pub metadata: Option, + #[serde(default)] + #[schema(nullable, value_type = Option)] + pub document_type_id: Option, } #[derive(Serialize, ToSchema)] @@ -234,6 +258,7 @@ struct DocumentUpdateChangeset { issued_at: Option>, metadata: Option, updated_at: Option, + document_type_id: Option>, } #[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, _> = 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> = 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 = docs.iter().filter_map(|doc| doc.document_type_id).collect(); + let doc_type_map: HashMap = if type_ids.is_empty() { + HashMap::new() + } else { + let ids: Vec = type_ids.iter().copied().collect(); + document_types::table + .filter(document_types::tenant_id.eq(tenant_id)) + .filter(document_types::id.eq_any(&ids)) + .load::(&mut conn)? + .into_iter() + .map(|typ| (typ.id, typ)) + .collect() + }; + let doc_ids: Vec = 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::(&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>, correspondents: Vec, current_version: Option<(DocumentVersionResponse, Vec)>, + doc_type: Option, ) -> AppResult { 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, +) -> diesel::QueryResult> { + if let Some(id) = type_id { + document_types::table + .filter(document_types::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .optional() + } else { + Ok(None) + } +} diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 6593003..e808666 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -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 = docs.iter().filter_map(|doc| doc.document_type_id).collect(); + let doc_type_map: HashMap = if type_ids.is_empty() { + HashMap::new() + } else { + let ids: Vec = type_ids.iter().copied().collect(); + document_types::table + .filter(document_types::tenant_id.eq(tenant_id)) + .filter(document_types::id.eq_any(&ids)) + .load::(&mut conn)? + .into_iter() + .map(|typ| (typ.id, typ)) + .collect() + }; + let doc_ids: Vec = 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, )?); } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index c5b163c..7f468cb 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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) diff --git a/backend/src/schema.rs b/backend/src/schema.rs index bcb4116..ffd0299 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -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, } } @@ -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, diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 7569a29..56230e5 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -23,6 +23,10 @@ struct DocumentInfo { deleted_at: Option, issued_at: Option, metadata: Value, + #[serde(default)] + document_type_id: Option, + #[serde(default)] + document_type: Option, tags: Vec, #[serde(default)] correspondents: Vec, @@ -57,6 +61,10 @@ struct DocumentAssetInfo { struct DocumentListItem { id: Uuid, #[serde(default)] + document_type_id: Option, + #[serde(default)] + document_type: Option, + #[serde(default)] current_version: Option, } @@ -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 = 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 = 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 = serde_json::from_slice(&combined_body)?; + let combined_ids: Vec = 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; diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 4b74640..bdd71d2 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -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 }) => (
{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 = ({ ); })}
+
+
Document type
+
+ {singleDocumentType?.name ? ( + {singleDocumentType.name} + ) : ( + None assigned. + )} + {singleDocumentType && onDocumentTypeClear ? ( + + ) : null} +
+ {onDocumentTypeSet ? ( +
{ + 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 }); + }} + > + + + + {documentTypeOptions.map((name) => ( + +
+ ) : null} +
({ @@ -1216,6 +1347,47 @@ const DetailPanel = ({ datalistOptions={tags} className="bulk-tags" /> +
+
Document type
+
+ {bulkDocumentTypeSummary || 'None assigned.'} + {onBulkDocumentTypeClear && bulkDocumentTypes.length > 0 ? ( + + ) : null} +
+ {onBulkDocumentTypeSet ? ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const input = form.elements.documentType; + const value = input?.value?.trim(); + if (!value) { + return; + } + onBulkDocumentTypeSet?.({ name: value, input, documentIds }); + }} + > + + + + {documentTypeOptions.map((name) => ( +
+ ) : null} +
{ + 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 ( +
+
+
+

Document Types

+
{documentTypes.length} total
+
+
+
+ setCreateName(event.target.value)} + disabled={creating} + /> + +
+ +
+
+
+ {documentTypes.length === 0 ? ( +
No document types created yet.
+ ) : ( +
+ + + + + + + + + + {documentTypes.map((entry) => { + const isEditing = editingId === entry.id; + return ( + + + + + + ); + })} + +
Name + Usage + + Actions +
+ {isEditing ? ( + setDraftName(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving} + autoFocus + /> + ) : ( + {entry.name} + )} + {renderUsage(entry.usage)} + {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +} + +export default DocumentTypesPanel; diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 98eda97..83e9b8f 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -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 ? ( + { + 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} + + ) : null} {correspondents.length > 0 ? ( {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 (
+ {documentType ? ( + { + 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} + + ) : null} {correspondents.length > 0 ? ( {renderCorrespondentLinks(correspondents)} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 75a91e3..8b0070d 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -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 = () => {
- +
+ + +)} + {isDocumentTypesModalOpen && ( +
+
event.stopPropagation()} + > +
+

Manage Document Types

+ +
+
+
@@ -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; diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index 924fc63..cdf59ee 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -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 || '—' }, ]; diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 26ca9f6..9c91ec8 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -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 = ({ })}
+
+
+

Document Types

+
+ + + {documentTypes.length} +
+
+
    + {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 ( +
  • + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(); + } + }} + > + {label} + +
  • + ); + })} +
+
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2796232..1c0c906 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }