refactor
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||||
|
ALTER TABLE document_correspondents ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'other';
|
||||||
|
UPDATE document_correspondents SET role = 'other';
|
||||||
|
ALTER TABLE document_correspondents ALTER COLUMN role DROP DEFAULT;
|
||||||
|
ALTER TABLE document_correspondents
|
||||||
|
ADD CONSTRAINT document_correspondents_pkey
|
||||||
|
PRIMARY KEY (document_id, correspondent_id, role);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
document_id,
|
||||||
|
correspondent_id,
|
||||||
|
role,
|
||||||
|
assigned_at,
|
||||||
|
assigned_by,
|
||||||
|
tenant_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY document_id, correspondent_id ORDER BY assigned_at DESC) AS rn
|
||||||
|
FROM document_correspondents
|
||||||
|
)
|
||||||
|
DELETE FROM document_correspondents dc
|
||||||
|
USING ranked r
|
||||||
|
WHERE dc.document_id = r.document_id
|
||||||
|
AND dc.correspondent_id = r.correspondent_id
|
||||||
|
AND dc.role = r.role
|
||||||
|
AND dc.tenant_id = r.tenant_id
|
||||||
|
AND r.rn > 1;
|
||||||
|
|
||||||
|
ALTER TABLE document_correspondents DROP CONSTRAINT document_correspondents_pkey;
|
||||||
|
ALTER TABLE document_correspondents DROP COLUMN role;
|
||||||
|
ALTER TABLE document_correspondents
|
||||||
|
ADD CONSTRAINT document_correspondents_pkey
|
||||||
|
PRIMARY KEY (document_id, correspondent_id);
|
||||||
@@ -281,11 +281,10 @@ pub struct NewCorrespondent {
|
|||||||
#[diesel(table_name = document_correspondents)]
|
#[diesel(table_name = document_correspondents)]
|
||||||
#[diesel(belongs_to(Document))]
|
#[diesel(belongs_to(Document))]
|
||||||
#[diesel(belongs_to(Correspondent))]
|
#[diesel(belongs_to(Correspondent))]
|
||||||
#[diesel(primary_key(document_id, correspondent_id, role))]
|
#[diesel(primary_key(document_id, correspondent_id))]
|
||||||
pub struct DocumentCorrespondent {
|
pub struct DocumentCorrespondent {
|
||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub assigned_at: NaiveDateTime,
|
pub assigned_at: NaiveDateTime,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -296,7 +295,6 @@ pub struct DocumentCorrespondent {
|
|||||||
pub struct NewDocumentCorrespondent {
|
pub struct NewDocumentCorrespondent {
|
||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-14
@@ -76,7 +76,6 @@ use uuid::Uuid;
|
|||||||
schemas::BulkCorrespondentsResponse,
|
schemas::BulkCorrespondentsResponse,
|
||||||
schemas::BulkCorrespondentAction,
|
schemas::BulkCorrespondentAction,
|
||||||
schemas::AssignCorrespondentsRequest,
|
schemas::AssignCorrespondentsRequest,
|
||||||
schemas::RemoveCorrespondentParams,
|
|
||||||
schemas::ReanalyzeRequest,
|
schemas::ReanalyzeRequest,
|
||||||
schemas::ReanalyzeResponse,
|
schemas::ReanalyzeResponse,
|
||||||
schemas::DocumentAssetRequestParams,
|
schemas::DocumentAssetRequestParams,
|
||||||
@@ -95,6 +94,7 @@ use uuid::Uuid;
|
|||||||
schemas::TagCatalogEntry,
|
schemas::TagCatalogEntry,
|
||||||
schemas::CreateTagRequest,
|
schemas::CreateTagRequest,
|
||||||
schemas::UpdateTagRequest,
|
schemas::UpdateTagRequest,
|
||||||
|
schemas::CorrespondentUsage,
|
||||||
schemas::CorrespondentCatalogEntry,
|
schemas::CorrespondentCatalogEntry,
|
||||||
schemas::CreateCorrespondentRequest,
|
schemas::CreateCorrespondentRequest,
|
||||||
schemas::UpdateCorrespondentRequest,
|
schemas::UpdateCorrespondentRequest,
|
||||||
@@ -351,8 +351,7 @@ mod doc {
|
|||||||
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
||||||
params(
|
params(
|
||||||
("id" = Uuid, Path, description = "Document ID"),
|
("id" = Uuid, Path, description = "Document ID"),
|
||||||
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
|
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
|
||||||
RemoveCorrespondentParams
|
|
||||||
),
|
),
|
||||||
responses((status = 204, description = "Correspondent removed")),
|
responses((status = 204, description = "Correspondent removed")),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
@@ -683,7 +682,6 @@ pub mod schemas {
|
|||||||
pub struct DocumentCorrespondent {
|
pub struct DocumentCorrespondent {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub role: String,
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub assigned_at: String,
|
pub assigned_at: String,
|
||||||
}
|
}
|
||||||
@@ -706,8 +704,8 @@ pub mod schemas {
|
|||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub tags: Vec<DocumentTag>,
|
pub tags: Vec<DocumentTag>,
|
||||||
#[schema(nullable)]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub correspondents: Option<Vec<DocumentCorrespondent>>,
|
pub correspondents: Vec<DocumentCorrespondent>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub current_version: Option<DocumentVersionDetailResponse>,
|
pub current_version: Option<DocumentVersionDetailResponse>,
|
||||||
}
|
}
|
||||||
@@ -787,7 +785,6 @@ pub mod schemas {
|
|||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CorrespondentAssignment {
|
pub struct CorrespondentAssignment {
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -822,12 +819,6 @@ pub mod schemas {
|
|||||||
pub removed: usize,
|
pub removed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
|
||||||
#[into_params(parameter_in = Query)]
|
|
||||||
pub struct RemoveCorrespondentParams {
|
|
||||||
pub role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ReanalyzeRequest {
|
pub struct ReanalyzeRequest {
|
||||||
pub document_ids: Vec<Uuid>,
|
pub document_ids: Vec<Uuid>,
|
||||||
@@ -981,12 +972,19 @@ pub mod schemas {
|
|||||||
pub color: Option<Option<String>>,
|
pub color: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CorrespondentUsage {
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CorrespondentCatalogEntry {
|
pub struct CorrespondentCatalogEntry {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub role_counts: Value,
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub usage: CorrespondentUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::{extract::Path, http::StatusCode, Json};
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -21,8 +21,6 @@ use crate::{
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CorrespondentUsage {
|
pub struct CorrespondentUsage {
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub by_role: BTreeMap<String, i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
|
|||||||
.order(correspondents::name.asc())
|
.order(correspondents::name.asc())
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by((
|
.group_by(document_correspondents::correspondent_id)
|
||||||
document_correspondents::correspondent_id,
|
.select((document_correspondents::correspondent_id, count_star()))
|
||||||
document_correspondents::role,
|
|
||||||
))
|
|
||||||
.select((
|
|
||||||
document_correspondents::correspondent_id,
|
|
||||||
document_correspondents::role,
|
|
||||||
count_star(),
|
|
||||||
))
|
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
|
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
|
||||||
for (correspondent_id, role, count) in usage_rows {
|
for (correspondent_id, count) in usage_rows {
|
||||||
usage_map
|
usage_map.insert(correspondent_id, count);
|
||||||
.entry(correspondent_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(role, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut response = Vec::with_capacity(correspondents_list.len());
|
let mut response = Vec::with_capacity(correspondents_list.len());
|
||||||
for correspondent in correspondents_list {
|
for correspondent in correspondents_list {
|
||||||
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
|
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
|
||||||
response.push(build_summary(correspondent, role_counts));
|
response.push(build_summary(correspondent, total));
|
||||||
}
|
}
|
||||||
|
|
||||||
response.into_json()
|
response.into_json()
|
||||||
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.one()?;
|
.one()?;
|
||||||
|
|
||||||
build_summary(correspondent, BTreeMap::new()).into_json()
|
build_summary(correspondent, 0).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_correspondent(
|
pub async fn update_correspondent(
|
||||||
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_summary(
|
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
|
||||||
correspondent: Correspondent,
|
|
||||||
role_counts: BTreeMap<String, i64>,
|
|
||||||
) -> CorrespondentSummary {
|
|
||||||
let total = role_counts.values().copied().sum();
|
|
||||||
CorrespondentSummary {
|
CorrespondentSummary {
|
||||||
id: correspondent.id,
|
id: correspondent.id,
|
||||||
name: correspondent.name,
|
name: correspondent.name,
|
||||||
metadata: correspondent.metadata,
|
metadata: correspondent.metadata,
|
||||||
created_at: to_iso(correspondent.created_at),
|
created_at: to_iso(correspondent.created_at),
|
||||||
updated_at: to_iso(correspondent.updated_at),
|
updated_at: to_iso(correspondent.updated_at),
|
||||||
usage: CorrespondentUsage {
|
usage: CorrespondentUsage { total },
|
||||||
total,
|
|
||||||
by_role: role_counts,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
|
|||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
correspondent_id: Uuid,
|
correspondent_id: Uuid,
|
||||||
) -> AppResult<BTreeMap<String, i64>> {
|
) -> AppResult<i64> {
|
||||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
let total: i64 = document_correspondents::table
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by(document_correspondents::role)
|
.select(count_star())
|
||||||
.select((document_correspondents::role, count_star()))
|
.get_result(conn)?;
|
||||||
.load(conn)?;
|
|
||||||
|
|
||||||
let mut map = BTreeMap::new();
|
Ok(total)
|
||||||
for (role, count) in rows {
|
|
||||||
map.insert(role, count);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-146
@@ -48,10 +48,7 @@ use asset_utils::{
|
|||||||
build_download_path, derive_document_title, filename_with_retained_extension,
|
build_download_path, derive_document_title, filename_with_retained_extension,
|
||||||
to_asset_detail_response, to_asset_object_response, to_asset_summary, to_version_response,
|
to_asset_detail_response, to_asset_object_response, to_asset_summary, to_version_response,
|
||||||
};
|
};
|
||||||
use correspondent_utils::{
|
use correspondent_utils::normalize_correspondent_assignments;
|
||||||
is_valid_correspondent_role, normalize_correspondent_assignments, normalize_role,
|
|
||||||
CORRESPONDENT_ROLES,
|
|
||||||
};
|
|
||||||
use search_utils::{build_quickwit_query, extract_document_id};
|
use search_utils::{build_quickwit_query, extract_document_id};
|
||||||
|
|
||||||
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
||||||
@@ -186,7 +183,6 @@ pub struct DocumentVersionDetailResponse {
|
|||||||
pub struct DocumentCorrespondentResponse {
|
pub struct DocumentCorrespondentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub role: String,
|
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub assigned_at: String,
|
pub assigned_at: String,
|
||||||
}
|
}
|
||||||
@@ -280,7 +276,6 @@ pub struct BulkCorrespondentResponse {
|
|||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct CorrespondentAssignmentInput {
|
pub struct CorrespondentAssignmentInput {
|
||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -319,12 +314,6 @@ pub struct BulkCorrespondentsRequest {
|
|||||||
pub action: BulkCorrespondentAction,
|
pub action: BulkCorrespondentAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
|
||||||
#[into_params(parameter_in = Query)]
|
|
||||||
pub struct CorrespondentRoleQuery {
|
|
||||||
pub role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct BulkReanalyzeSelectionRequest {
|
pub struct BulkReanalyzeSelectionRequest {
|
||||||
pub document_ids: Vec<Uuid>,
|
pub document_ids: Vec<Uuid>,
|
||||||
@@ -853,7 +842,7 @@ pub async fn upload_document(
|
|||||||
})?;
|
})?;
|
||||||
correspondents = serde_json::from_str(&value).map_err(|err| {
|
correspondents = serde_json::from_str(&value).map_err(|err| {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"correspondents must be a JSON array of {{correspondent_id, role}} objects: {err}"
|
"correspondents must be a JSON array of {{correspondent_id}} objects: {err}"
|
||||||
);
|
);
|
||||||
error!(error = %err, "invalid correspondents json");
|
error!(error = %err, "invalid correspondents json");
|
||||||
AppError::bad_request(msg)
|
AppError::bad_request(msg)
|
||||||
@@ -1672,8 +1661,7 @@ pub async fn assign_correspondents(
|
|||||||
return Err(AppError::bad_request("assignments must not be empty"));
|
return Err(AppError::bad_request("assignments must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (normalized_pairs, _correspondent_ids, roles_vec) =
|
let correspondent_ids = normalize_correspondent_assignments(&payload.assignments)?;
|
||||||
normalize_correspondent_assignments(&payload.assignments)?;
|
|
||||||
let replace = payload.replace;
|
let replace = payload.replace;
|
||||||
|
|
||||||
conn.transaction::<(), AppError, _>(|conn| {
|
conn.transaction::<(), AppError, _>(|conn| {
|
||||||
@@ -1685,21 +1673,41 @@ pub async fn assign_correspondents(
|
|||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut deleted = 0;
|
let mut updated = false;
|
||||||
if replace {
|
if replace {
|
||||||
deleted = diesel::delete(
|
use diesel::dsl::not;
|
||||||
document_correspondents::table
|
|
||||||
.filter(document_correspondents::document_id.eq(document_id))
|
let base = document_correspondents::table
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::document_id.eq(document_id))
|
||||||
.filter(document_correspondents::role.eq_any(&roles_vec)),
|
.filter(document_correspondents::tenant_id.eq(tenant_id));
|
||||||
)
|
|
||||||
.execute(conn)?;
|
let removed = if correspondent_ids.is_empty() {
|
||||||
|
diesel::delete(base).execute(conn)?
|
||||||
|
} else {
|
||||||
|
diesel::delete(base.filter(not(
|
||||||
|
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
|
||||||
|
)))
|
||||||
|
.execute(conn)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if removed > 0 {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inserted =
|
let inserted = insert_document_correspondents(
|
||||||
insert_document_correspondents(conn, tenant_id, &document, user_id, &normalized_pairs)?;
|
conn,
|
||||||
|
tenant_id,
|
||||||
|
document.id,
|
||||||
|
user_id,
|
||||||
|
&correspondent_ids,
|
||||||
|
)?;
|
||||||
|
|
||||||
if replace && deleted > 0 && inserted == 0 {
|
if inserted > 0 {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated && inserted == 0 {
|
||||||
diesel::update(
|
diesel::update(
|
||||||
documents::table
|
documents::table
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
@@ -1731,8 +1739,7 @@ pub async fn bulk_assign_correspondents(
|
|||||||
let mut document_ids = payload.document_ids;
|
let mut document_ids = payload.document_ids;
|
||||||
validate_bulk_ids(&mut document_ids, "document_ids")?;
|
validate_bulk_ids(&mut document_ids, "document_ids")?;
|
||||||
|
|
||||||
let (normalized_pairs, correspondents_vec, _roles_vec) =
|
let correspondent_ids = normalize_correspondent_assignments(&payload.assignments)?;
|
||||||
normalize_correspondent_assignments(&payload.assignments)?;
|
|
||||||
let action = payload.action;
|
let action = payload.action;
|
||||||
let user_id_val = user_id;
|
let user_id_val = user_id;
|
||||||
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
|
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
|
||||||
@@ -1754,12 +1761,12 @@ pub async fn bulk_assign_correspondents(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !correspondents_vec.is_empty() {
|
if !correspondent_ids.is_empty() {
|
||||||
let existing: Vec<Correspondent> = correspondents::table
|
let existing: Vec<Correspondent> = correspondents::table
|
||||||
.filter(correspondents::id.eq_any(&correspondents_vec))
|
.filter(correspondents::id.eq_any(&correspondent_ids))
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.load(conn)?;
|
.load(conn)?;
|
||||||
if existing.len() != correspondents_vec.len() {
|
if existing.len() != correspondent_ids.len() {
|
||||||
return Err(AppError::bad_request(
|
return Err(AppError::bad_request(
|
||||||
"one or more correspondents do not exist",
|
"one or more correspondents do not exist",
|
||||||
));
|
));
|
||||||
@@ -1768,92 +1775,34 @@ pub async fn bulk_assign_correspondents(
|
|||||||
|
|
||||||
match action {
|
match action {
|
||||||
BulkCorrespondentAction::Add => {
|
BulkCorrespondentAction::Add => {
|
||||||
use diesel::dsl::not;
|
let mut assigned_total = 0;
|
||||||
|
for (doc_id, _) in &docs {
|
||||||
let mut grouped_by_role: HashMap<String, Vec<Uuid>> = HashMap::new();
|
assigned_total += insert_document_correspondents(
|
||||||
for (correspondent_id, role) in &normalized_pairs {
|
conn,
|
||||||
grouped_by_role
|
tenant_id,
|
||||||
.entry(role.clone())
|
*doc_id,
|
||||||
.or_default()
|
user_id_val,
|
||||||
.push(*correspondent_id);
|
&correspondent_ids,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut removed = 0;
|
Ok((assigned_total, 0))
|
||||||
for (role, ids) in grouped_by_role.iter() {
|
|
||||||
if ids.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let maintained_ids = ids.clone();
|
|
||||||
let deleted = diesel::delete(
|
|
||||||
document_correspondents::table
|
|
||||||
.filter(document_correspondents::document_id.eq_any(&document_ids))
|
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.filter(document_correspondents::role.eq(role.as_str()))
|
|
||||||
.filter(not(
|
|
||||||
document_correspondents::correspondent_id.eq_any(maintained_ids)
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.execute(conn)?;
|
|
||||||
removed += deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len());
|
|
||||||
for doc_id in &document_ids {
|
|
||||||
for (correspondent_id, role) in &normalized_pairs {
|
|
||||||
new_rows.push(NewDocumentCorrespondent {
|
|
||||||
document_id: *doc_id,
|
|
||||||
correspondent_id: *correspondent_id,
|
|
||||||
role: role.clone(),
|
|
||||||
assigned_by: Some(user_id_val),
|
|
||||||
tenant_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let assigned = if new_rows.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
diesel::insert_into(document_correspondents::table)
|
|
||||||
.values(&new_rows)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
.execute(conn)?
|
|
||||||
};
|
|
||||||
|
|
||||||
if assigned > 0 || removed > 0 {
|
|
||||||
diesel::update(
|
|
||||||
documents::table
|
|
||||||
.filter(documents::id.eq_any(&document_ids))
|
|
||||||
.filter(documents::tenant_id.eq(tenant_id)),
|
|
||||||
)
|
|
||||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((assigned, removed))
|
|
||||||
}
|
}
|
||||||
BulkCorrespondentAction::Remove => {
|
BulkCorrespondentAction::Remove => {
|
||||||
let mut removed = 0;
|
if correspondent_ids.is_empty() {
|
||||||
if !normalized_pairs.is_empty() {
|
return Ok((0, 0));
|
||||||
let mut grouped: HashMap<String, Vec<Uuid>> = HashMap::new();
|
|
||||||
for (correspondent_id, role) in &normalized_pairs {
|
|
||||||
grouped
|
|
||||||
.entry(role.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(*correspondent_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (role, ids) in grouped {
|
|
||||||
removed += diesel::delete(
|
|
||||||
document_correspondents::table
|
|
||||||
.filter(document_correspondents::document_id.eq_any(&document_ids))
|
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
|
||||||
.filter(document_correspondents::role.eq(role.as_str()))
|
|
||||||
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
|
|
||||||
)
|
|
||||||
.execute(conn)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let removed = diesel::delete(
|
||||||
|
document_correspondents::table
|
||||||
|
.filter(document_correspondents::document_id.eq_any(&document_ids))
|
||||||
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
|
.filter(
|
||||||
|
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
diesel::update(
|
diesel::update(
|
||||||
documents::table
|
documents::table
|
||||||
@@ -1875,24 +1824,12 @@ pub async fn bulk_assign_correspondents(
|
|||||||
|
|
||||||
pub async fn remove_correspondent(
|
pub async fn remove_correspondent(
|
||||||
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
||||||
Query(query): Query<CorrespondentRoleQuery>,
|
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
let role = normalize_role(&query.role);
|
|
||||||
if role.is_empty() {
|
|
||||||
return Err(AppError::bad_request("role must not be empty"));
|
|
||||||
}
|
|
||||||
if !is_valid_correspondent_role(&role) {
|
|
||||||
return Err(AppError::bad_request(format!(
|
|
||||||
"invalid correspondent role '{role}'. Allowed roles: {}",
|
|
||||||
CORRESPONDENT_ROLES.join(", ")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let document: Document = documents::table
|
let document: Document = documents::table
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
.filter(documents::tenant_id.eq(tenant_id))
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
@@ -1905,8 +1842,7 @@ pub async fn remove_correspondent(
|
|||||||
document_correspondents::table
|
document_correspondents::table
|
||||||
.filter(document_correspondents::document_id.eq(document_id))
|
.filter(document_correspondents::document_id.eq(document_id))
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
|
||||||
.filter(document_correspondents::role.eq(&role)),
|
|
||||||
)
|
)
|
||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
@@ -2374,47 +2310,45 @@ fn assign_correspondents_internal(
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (normalized_pairs, _correspondent_ids, _roles) =
|
let ids = normalize_correspondent_assignments(assignments)?;
|
||||||
normalize_correspondent_assignments(assignments)?;
|
|
||||||
|
|
||||||
insert_document_correspondents(conn, tenant_id, document, user_id, &normalized_pairs)
|
insert_document_correspondents(conn, tenant_id, document.id, user_id, &ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_document_correspondents(
|
fn insert_document_correspondents(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
document: &Document,
|
document_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
normalized_pairs: &[(Uuid, String)],
|
correspondent_ids: &[Uuid],
|
||||||
) -> AppResult<usize> {
|
) -> AppResult<usize> {
|
||||||
if normalized_pairs.is_empty() {
|
if correspondent_ids.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut correspondent_ids: Vec<Uuid> = normalized_pairs.iter().map(|(id, _)| *id).collect();
|
let mut unique_ids: Vec<Uuid> = correspondent_ids.to_vec();
|
||||||
correspondent_ids.sort_unstable();
|
unique_ids.sort_unstable();
|
||||||
correspondent_ids.dedup();
|
unique_ids.dedup();
|
||||||
|
|
||||||
if !correspondent_ids.is_empty() {
|
if !unique_ids.is_empty() {
|
||||||
let existing: Vec<Uuid> = correspondents::table
|
let existing: Vec<Uuid> = correspondents::table
|
||||||
.filter(correspondents::id.eq_any(&correspondent_ids))
|
.filter(correspondents::id.eq_any(&unique_ids))
|
||||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.select(correspondents::id)
|
.select(correspondents::id)
|
||||||
.load(conn)?;
|
.load(conn)?;
|
||||||
|
|
||||||
if existing.len() != correspondent_ids.len() {
|
if existing.len() != unique_ids.len() {
|
||||||
return Err(AppError::bad_request(
|
return Err(AppError::bad_request(
|
||||||
"one or more correspondents do not exist",
|
"one or more correspondents do not exist",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_rows: Vec<NewDocumentCorrespondent> = normalized_pairs
|
let new_rows: Vec<NewDocumentCorrespondent> = unique_ids
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|(correspondent_id, role)| NewDocumentCorrespondent {
|
.map(|correspondent_id| NewDocumentCorrespondent {
|
||||||
document_id: document.id,
|
document_id,
|
||||||
correspondent_id: *correspondent_id,
|
correspondent_id,
|
||||||
role: role.clone(),
|
|
||||||
assigned_by: Some(user_id),
|
assigned_by: Some(user_id),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
})
|
})
|
||||||
@@ -2432,7 +2366,7 @@ fn insert_document_correspondents(
|
|||||||
if inserted > 0 {
|
if inserted > 0 {
|
||||||
diesel::update(
|
diesel::update(
|
||||||
documents::table
|
documents::table
|
||||||
.find(document.id)
|
.find(document_id)
|
||||||
.filter(documents::tenant_id.eq(tenant_id)),
|
.filter(documents::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||||
@@ -2490,7 +2424,6 @@ pub(crate) fn load_correspondents_for_documents(
|
|||||||
.filter(document_correspondents::document_id.eq_any(document_ids))
|
.filter(document_correspondents::document_id.eq_any(document_ids))
|
||||||
.order((
|
.order((
|
||||||
document_correspondents::document_id.asc(),
|
document_correspondents::document_id.asc(),
|
||||||
document_correspondents::role.asc(),
|
|
||||||
document_correspondents::assigned_at.asc(),
|
document_correspondents::assigned_at.asc(),
|
||||||
))
|
))
|
||||||
.load(conn)?;
|
.load(conn)?;
|
||||||
@@ -2502,7 +2435,6 @@ pub(crate) fn load_correspondents_for_documents(
|
|||||||
.push(DocumentCorrespondentResponse {
|
.push(DocumentCorrespondentResponse {
|
||||||
id: correspondent.id,
|
id: correspondent.id,
|
||||||
name: correspondent.name,
|
name: correspondent.name,
|
||||||
role: assignment.role,
|
|
||||||
metadata: correspondent.metadata,
|
metadata: correspondent.metadata,
|
||||||
assigned_at: to_iso(assignment.assigned_at),
|
assigned_at: to_iso(assignment.assigned_at),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,56 +6,23 @@ use crate::error::{AppError, AppResult};
|
|||||||
|
|
||||||
use super::CorrespondentAssignmentInput;
|
use super::CorrespondentAssignmentInput;
|
||||||
|
|
||||||
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
|
|
||||||
|
|
||||||
pub fn normalize_role(value: &str) -> String {
|
|
||||||
value.trim().to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_valid_correspondent_role(role: &str) -> bool {
|
|
||||||
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_correspondent_assignments(
|
pub fn normalize_correspondent_assignments(
|
||||||
assignments: &[CorrespondentAssignmentInput],
|
assignments: &[CorrespondentAssignmentInput],
|
||||||
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
|
) -> AppResult<Vec<Uuid>> {
|
||||||
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
|
let mut unique_ids = HashSet::new();
|
||||||
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut role_set: HashSet<String> = HashSet::new();
|
|
||||||
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
|
|
||||||
|
|
||||||
for assignment in assignments {
|
for assignment in assignments {
|
||||||
let role = normalize_role(&assignment.role);
|
if unique_ids.insert(assignment.correspondent_id) {
|
||||||
if role.is_empty() {
|
result.push(assignment.correspondent_id);
|
||||||
return Err(AppError::bad_request("role must not be empty"));
|
|
||||||
}
|
}
|
||||||
if !is_valid_correspondent_role(&role) {
|
|
||||||
return Err(AppError::bad_request(format!(
|
|
||||||
"invalid correspondent role '{role}'. Allowed roles: {}",
|
|
||||||
CORRESPONDENT_ROLES.join(", ")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized_pairs.push((assignment.correspondent_id, role.clone()));
|
|
||||||
role_set.insert(role);
|
|
||||||
correspondent_ids.insert(assignment.correspondent_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized_pairs.is_empty() {
|
if result.is_empty() {
|
||||||
return Err(AppError::bad_request(
|
return Err(AppError::bad_request(
|
||||||
"assignments must contain at least one unique correspondent/role pair",
|
"assignments must contain at least one correspondent",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
|
Ok(result)
|
||||||
correspondents_vec.sort();
|
|
||||||
|
|
||||||
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
|
|
||||||
roles_vec.sort();
|
|
||||||
|
|
||||||
Ok((normalized_pairs, correspondents_vec, roles_vec))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
document_correspondents (document_id, correspondent_id, role) {
|
document_correspondents (document_id, correspondent_id) {
|
||||||
document_id -> Uuid,
|
document_id -> Uuid,
|
||||||
correspondent_id -> Uuid,
|
correspondent_id -> Uuid,
|
||||||
#[max_length = 32]
|
|
||||||
role -> Varchar,
|
|
||||||
assigned_at -> Timestamptz,
|
assigned_at -> Timestamptz,
|
||||||
assigned_by -> Nullable<Uuid>,
|
assigned_by -> Nullable<Uuid>,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentDetail {
|
||||||
|
document: DocumentSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentSummary {
|
||||||
|
id: Uuid,
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
correspondents: Vec<DocumentCorrespondentSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentCorrespondentSummary {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CorrespondentSummary {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BulkCorrespondentResult {
|
||||||
|
assigned: usize,
|
||||||
|
removed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestContext {
|
||||||
|
app: TestApp,
|
||||||
|
token: String,
|
||||||
|
document_ids: Vec<Uuid>,
|
||||||
|
sender_id: Uuid,
|
||||||
|
receiver_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestContext {
|
||||||
|
const SENDER_NAME: &'static str = "Acme Corp";
|
||||||
|
const RECEIVER_NAME: &'static str = "Bank Ltd";
|
||||||
|
|
||||||
|
async fn new(prefix: &str) -> Result<Self> {
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
let username = format!("{prefix}_user");
|
||||||
|
let password = format!("{prefix}_pw");
|
||||||
|
app.insert_user(&username, &password, "admin").await?;
|
||||||
|
let token = app.login_token(&username, &password).await?;
|
||||||
|
|
||||||
|
let first_id =
|
||||||
|
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
|
||||||
|
let second_id =
|
||||||
|
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
|
||||||
|
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
|
||||||
|
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
app,
|
||||||
|
token,
|
||||||
|
document_ids: vec![first_id, second_id],
|
||||||
|
sender_id,
|
||||||
|
receiver_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
|
||||||
|
self.assign_with_action(correspondent_ids, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assign_with_action(
|
||||||
|
&self,
|
||||||
|
correspondent_ids: &[Uuid],
|
||||||
|
action: Option<&str>,
|
||||||
|
) -> Result<BulkCorrespondentResult> {
|
||||||
|
let assignments: Vec<_> = correspondent_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| json!({ "correspondent_id": id }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut payload = json!({
|
||||||
|
"document_ids": self.document_ids,
|
||||||
|
"assignments": assignments,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
if let Some(obj) = payload.as_object_mut() {
|
||||||
|
obj.insert("action".to_string(), json!(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/correspondents",
|
||||||
|
&payload,
|
||||||
|
Some(&self.token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_correspondents(
|
||||||
|
&self,
|
||||||
|
document_id: Uuid,
|
||||||
|
) -> Result<Vec<DocumentCorrespondentSummary>> {
|
||||||
|
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
|
||||||
|
Ok(detail.document.correspondents)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
|
||||||
|
create_correspondent(&self.app, &self.token, name).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let context = TestContext::new("corresp_add").await?;
|
||||||
|
|
||||||
|
let result = context
|
||||||
|
.assign(&[context.sender_id, context.receiver_id])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(result.assigned, 4);
|
||||||
|
assert_eq!(result.removed, 0);
|
||||||
|
|
||||||
|
for doc_id in &context.document_ids {
|
||||||
|
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||||
|
let names: Vec<_> = correspondents
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&TestContext::SENDER_NAME));
|
||||||
|
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||||
|
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||||
|
assert!(ids.contains(&context.sender_id));
|
||||||
|
assert!(ids.contains(&context.receiver_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
context.app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let context = TestContext::new("corresp_idempotent").await?;
|
||||||
|
|
||||||
|
context
|
||||||
|
.assign(&[context.sender_id, context.receiver_id])
|
||||||
|
.await?;
|
||||||
|
let repeat = context
|
||||||
|
.assign(&[context.sender_id, context.receiver_id])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(repeat.assigned, 0);
|
||||||
|
assert_eq!(repeat.removed, 0);
|
||||||
|
|
||||||
|
context.app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let context = TestContext::new("corresp_remove").await?;
|
||||||
|
|
||||||
|
context
|
||||||
|
.assign(&[context.sender_id, context.receiver_id])
|
||||||
|
.await?;
|
||||||
|
let removal = context
|
||||||
|
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(removal.assigned, 0);
|
||||||
|
assert_eq!(removal.removed, 2);
|
||||||
|
|
||||||
|
for doc_id in &context.document_ids {
|
||||||
|
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||||
|
assert_eq!(correspondents.len(), 1);
|
||||||
|
let entry = &correspondents[0];
|
||||||
|
assert_eq!(entry.id, context.receiver_id);
|
||||||
|
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let context = TestContext::new("corresp_append").await?;
|
||||||
|
|
||||||
|
context
|
||||||
|
.assign(&[context.sender_id, context.receiver_id])
|
||||||
|
.await?;
|
||||||
|
context
|
||||||
|
.assign_with_action(&[context.sender_id], Some("remove"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let charlie_name = "Charlie";
|
||||||
|
let charlie_id = context.create_correspondent(charlie_name).await?;
|
||||||
|
let add_result = context.assign(&[charlie_id]).await?;
|
||||||
|
assert_eq!(add_result.assigned, 2);
|
||||||
|
assert_eq!(add_result.removed, 0);
|
||||||
|
|
||||||
|
for doc_id in &context.document_ids {
|
||||||
|
let correspondents = context.fetch_correspondents(*doc_id).await?;
|
||||||
|
assert_eq!(correspondents.len(), 2);
|
||||||
|
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
|
||||||
|
assert!(ids.contains(&context.receiver_id));
|
||||||
|
assert!(ids.contains(&charlie_id));
|
||||||
|
let names: Vec<_> = correspondents
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&TestContext::RECEIVER_NAME));
|
||||||
|
assert!(names.contains(&charlie_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
context.app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_document(
|
||||||
|
app: &TestApp,
|
||||||
|
token: &str,
|
||||||
|
filename: &str,
|
||||||
|
contents: &[u8],
|
||||||
|
) -> Result<Uuid> {
|
||||||
|
let response = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
filename,
|
||||||
|
"text/plain",
|
||||||
|
contents,
|
||||||
|
None,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||||
|
Ok(detail.document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
|
||||||
|
.await?;
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
|
||||||
|
Ok(summary.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_document_detail(
|
||||||
|
app: &TestApp,
|
||||||
|
token: &str,
|
||||||
|
document_id: Uuid,
|
||||||
|
) -> Result<DocumentDetail> {
|
||||||
|
let response = app
|
||||||
|
.get(&format!("/api/documents/{document_id}"), Some(token))
|
||||||
|
.await?;
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
@@ -83,19 +83,8 @@ struct TagSummary {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentCorrespondentInfo {
|
struct DocumentCorrespondentInfo {
|
||||||
name: String,
|
|
||||||
role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CorrespondentSummary {
|
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
}
|
name: String,
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct BulkCorrespondentResult {
|
|
||||||
assigned: usize,
|
|
||||||
removed: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -809,300 +798,6 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|
||||||
let _lock = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let password = "bulkcorresp";
|
|
||||||
app.insert_user("corra", password, "admin").await?;
|
|
||||||
let token = app.login_token("corra", password).await?;
|
|
||||||
|
|
||||||
let first = app
|
|
||||||
.upload_document(
|
|
||||||
"/api/documents",
|
|
||||||
"letter-one.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"letter one",
|
|
||||||
None,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = first.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let first_body = body_to_vec(first.into_body()).await?;
|
|
||||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
|
||||||
|
|
||||||
let second = app
|
|
||||||
.upload_document(
|
|
||||||
"/api/documents",
|
|
||||||
"letter-two.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"letter two",
|
|
||||||
None,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = second.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let second_body = body_to_vec(second.into_body()).await?;
|
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
|
||||||
|
|
||||||
let sender = app
|
|
||||||
.post_json(
|
|
||||||
"/api/correspondents",
|
|
||||||
&serde_json::json!({ "name": "Acme Corp" }),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = sender.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let sender_body = body_to_vec(sender.into_body()).await?;
|
|
||||||
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
|
|
||||||
|
|
||||||
let receiver = app
|
|
||||||
.post_json(
|
|
||||||
"/api/correspondents",
|
|
||||||
&serde_json::json!({ "name": "Bank Ltd" }),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = receiver.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let receiver_body = body_to_vec(receiver.into_body()).await?;
|
|
||||||
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
|
|
||||||
|
|
||||||
let assign_payload = serde_json::json!({
|
|
||||||
"document_ids": [
|
|
||||||
first_detail.document.id,
|
|
||||||
second_detail.document.id
|
|
||||||
],
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"correspondent_id": sender_summary.id,
|
|
||||||
"role": "sender"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"correspondent_id": receiver_summary.id,
|
|
||||||
"role": "receiver"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let assign_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/documents/bulk/correspondents",
|
|
||||||
&assign_payload,
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = assign_resp.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let assign_body = body_to_vec(assign_resp.into_body()).await?;
|
|
||||||
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
|
|
||||||
assert_eq!(assign_result.assigned, 4);
|
|
||||||
assert_eq!(assign_result.removed, 0);
|
|
||||||
|
|
||||||
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
|
||||||
let refreshed = app
|
|
||||||
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = refreshed.status();
|
|
||||||
assert!(status == StatusCode::OK || status == StatusCode::CREATED);
|
|
||||||
}
|
|
||||||
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
|
||||||
assert_eq!(detail.document.correspondents.len(), 2);
|
|
||||||
assert!(detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "sender" && entry.name == "Acme Corp"));
|
|
||||||
assert!(detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let duplicate_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/documents/bulk/correspondents",
|
|
||||||
&assign_payload,
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = duplicate_resp.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
|
|
||||||
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
|
|
||||||
assert_eq!(duplicate_result.assigned, 0);
|
|
||||||
assert_eq!(duplicate_result.removed, 0);
|
|
||||||
|
|
||||||
let replacement = app
|
|
||||||
.post_json(
|
|
||||||
"/api/correspondents",
|
|
||||||
&serde_json::json!({ "name": "Charlie" }),
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = replacement.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let replacement_body = body_to_vec(replacement.into_body()).await?;
|
|
||||||
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
|
|
||||||
|
|
||||||
let replace_payload = serde_json::json!({
|
|
||||||
"document_ids": [
|
|
||||||
first_detail.document.id,
|
|
||||||
second_detail.document.id
|
|
||||||
],
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"correspondent_id": replacement_summary.id,
|
|
||||||
"role": "sender"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let replace_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/documents/bulk/correspondents",
|
|
||||||
&replace_payload,
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = replace_resp.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let replace_body = body_to_vec(replace_resp.into_body()).await?;
|
|
||||||
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
|
|
||||||
assert_eq!(replace_result.assigned, 2);
|
|
||||||
assert_eq!(replace_result.removed, 2);
|
|
||||||
|
|
||||||
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
|
||||||
let refreshed = app
|
|
||||||
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
|
||||||
.await?;
|
|
||||||
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
|
||||||
assert_eq!(detail.document.correspondents.len(), 2);
|
|
||||||
assert!(detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
|
|
||||||
assert!(detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let remove_payload = serde_json::json!({
|
|
||||||
"document_ids": [
|
|
||||||
first_detail.document.id,
|
|
||||||
second_detail.document.id
|
|
||||||
],
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"correspondent_id": receiver_summary.id,
|
|
||||||
"role": "receiver"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"action": "remove"
|
|
||||||
});
|
|
||||||
|
|
||||||
let remove_resp = app
|
|
||||||
.post_json(
|
|
||||||
"/api/documents/bulk/correspondents",
|
|
||||||
&remove_payload,
|
|
||||||
Some(&token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
{
|
|
||||||
let status = remove_resp.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let remove_body = body_to_vec(remove_resp.into_body()).await?;
|
|
||||||
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
|
|
||||||
assert_eq!(remove_result.assigned, 0);
|
|
||||||
assert_eq!(remove_result.removed, 2);
|
|
||||||
|
|
||||||
for doc_id in [first_detail.document.id, second_detail.document.id] {
|
|
||||||
let refreshed = app
|
|
||||||
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
|
||||||
.await?;
|
|
||||||
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
|
||||||
assert_eq!(detail.document.correspondents.len(), 1);
|
|
||||||
assert!(detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
|
|
||||||
assert!(!detail
|
|
||||||
.document
|
|
||||||
.correspondents
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "receiver"));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -1803,6 +1498,7 @@ async fn list_document_versions_and_fetch_detail() -> Result<()> {
|
|||||||
let versions: Vec<DocumentVersionListItem> = serde_json::from_slice(&list_body)?;
|
let versions: Vec<DocumentVersionListItem> = serde_json::from_slice(&list_body)?;
|
||||||
assert_eq!(versions.len(), 1);
|
assert_eq!(versions.len(), 1);
|
||||||
assert_eq!(versions[0].id, version_id);
|
assert_eq!(versions[0].id, version_id);
|
||||||
|
assert_eq!(versions[0].version_number, 1);
|
||||||
|
|
||||||
let detail_resp = app
|
let detail_resp = app
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
|
||||||
|
|
||||||
export default CORRESPONDENT_ROLES;
|
|
||||||
@@ -107,14 +107,7 @@ function CorrespondentsPanel({
|
|||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
const total = typeof usage.total === 'number' ? usage.total : 0;
|
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||||
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
return total.toString();
|
||||||
if (!entries.length) {
|
|
||||||
return total.toString();
|
|
||||||
}
|
|
||||||
const roleSummary = entries
|
|
||||||
.map(([role, count]) => `${role}: ${count}`)
|
|
||||||
.join(', ');
|
|
||||||
return `${total} (${roleSummary})`;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,13 +14,10 @@ import { getTagColorStyle } from '../utils/colors';
|
|||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
|
||||||
|
|
||||||
const derivePreviewOrientation = (metadata) => {
|
const derivePreviewOrientation = (metadata) => {
|
||||||
const width = Number(metadata?.width);
|
const width = Number(metadata?.width);
|
||||||
const height = Number(metadata?.height);
|
const height = Number(metadata?.height);
|
||||||
@@ -30,56 +27,41 @@ const derivePreviewOrientation = (metadata) => {
|
|||||||
return 'landscape';
|
return 'landscape';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatRoleLabel = (role) => {
|
|
||||||
const normalized = normalizeRole(role);
|
|
||||||
if (!normalized) return 'Other';
|
|
||||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const compareCorrespondents = (a, b) => {
|
|
||||||
const indexA = CORRESPONDENT_ROLES.indexOf(a.role);
|
|
||||||
const indexB = CORRESPONDENT_ROLES.indexOf(b.role);
|
|
||||||
const rankedA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
|
|
||||||
const rankedB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
|
|
||||||
if (rankedA !== rankedB) {
|
|
||||||
return rankedA - rankedB;
|
|
||||||
}
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortCorrespondents = (entries = []) =>
|
const sortCorrespondents = (entries = []) =>
|
||||||
entries
|
entries
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name || '',
|
name: entry.name || '',
|
||||||
role: normalizeRole(entry.role),
|
count: entry.count,
|
||||||
}))
|
}))
|
||||||
.sort(compareCorrespondents);
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
|
||||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
||||||
<div className="correspondent-list">
|
<div className="correspondent-list">
|
||||||
{entries.length ? (
|
{entries.length ? (
|
||||||
entries.map((entry) => (
|
entries.map((entry, index) => {
|
||||||
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
const key = entry.id ?? `${entry.name}-${index}`;
|
||||||
<span className="correspondent-pill__label">
|
return (
|
||||||
<strong>{formatRoleLabel(entry.role)}</strong>
|
<span key={key} className="correspondent-pill">
|
||||||
<span>
|
<span className="correspondent-pill__label">
|
||||||
{entry.name}
|
<span>
|
||||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
{entry.name}
|
||||||
|
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() => onRemove(entry)}
|
||||||
|
aria-label={`Remove ${entry.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{onRemove ? (
|
);
|
||||||
<button
|
})
|
||||||
type="button"
|
|
||||||
className="correspondent-pill__remove"
|
|
||||||
onClick={() => onRemove(entry)}
|
|
||||||
aria-label={`Remove ${entry.name} as ${formatRoleLabel(entry.role)}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<span className="meta">No correspondents yet.</span>
|
<span className="meta">No correspondents yet.</span>
|
||||||
)}
|
)}
|
||||||
@@ -174,11 +156,9 @@ const CorrespondentSection = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
const nameInput = form.elements.correspondent;
|
const nameInput = form.elements.correspondent;
|
||||||
const roleSelect = form.elements.role;
|
|
||||||
const value = nameInput.value.trim();
|
const value = nameInput.value.trim();
|
||||||
const role = roleSelect.value;
|
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
onAdd({ name: value, role, input: nameInput });
|
onAdd({ name: value, input: nameInput });
|
||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -187,13 +167,6 @@ const CorrespondentSection = ({
|
|||||||
placeholder={addPlaceholder}
|
placeholder={addPlaceholder}
|
||||||
list={datalistId}
|
list={datalistId}
|
||||||
/>
|
/>
|
||||||
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
|
||||||
{CORRESPONDENT_ROLES.map((role) => (
|
|
||||||
<option key={role} value={role}>
|
|
||||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit">{addButtonLabel}</button>
|
<button type="submit">{addButtonLabel}</button>
|
||||||
{datalistId ? (
|
{datalistId ? (
|
||||||
<datalist id={datalistId}>
|
<datalist id={datalistId}>
|
||||||
@@ -705,17 +678,14 @@ const DetailPanel = ({
|
|||||||
if (!doc?.id) return;
|
if (!doc?.id) return;
|
||||||
(doc.correspondents || []).forEach((entry) => {
|
(doc.correspondents || []).forEach((entry) => {
|
||||||
if (!entry?.id) return;
|
if (!entry?.id) return;
|
||||||
const normalizedRole = normalizeRole(entry.role);
|
if (!map.has(entry.id)) {
|
||||||
const key = `${entry.id}:${normalizedRole}`;
|
map.set(entry.id, {
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, {
|
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name || '',
|
name: entry.name || '',
|
||||||
role: normalizedRole,
|
|
||||||
documentIds: new Set(),
|
documentIds: new Set(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
map.get(key).documentIds.add(doc.id);
|
map.get(entry.id).documentIds.add(doc.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -723,23 +693,20 @@ const DetailPanel = ({
|
|||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
role: entry.role,
|
|
||||||
documentIds: [...entry.documentIds],
|
documentIds: [...entry.documentIds],
|
||||||
count: entry.documentIds.size,
|
count: entry.documentIds.size,
|
||||||
}))
|
}))
|
||||||
.sort(compareCorrespondents);
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const handleBulkCorrespondentRemove = useCallback(
|
const handleBulkCorrespondentRemove = useCallback(
|
||||||
(entry) => {
|
(entry) => {
|
||||||
if (!entry?.id) return;
|
if (!entry?.id) return;
|
||||||
const normalizedRole = normalizeRole(entry.role);
|
|
||||||
if (onBulkCorrespondentRemove) {
|
if (onBulkCorrespondentRemove) {
|
||||||
return onBulkCorrespondentRemove({
|
return onBulkCorrespondentRemove({
|
||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
correspondent_id: entry.id,
|
correspondent_id: entry.id,
|
||||||
role: normalizedRole,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
documentIds: entry.documentIds,
|
documentIds: entry.documentIds,
|
||||||
@@ -750,11 +717,7 @@ const DetailPanel = ({
|
|||||||
const targets = entry.documentIds && entry.documentIds.length
|
const targets = entry.documentIds && entry.documentIds.length
|
||||||
? entry.documentIds
|
? entry.documentIds
|
||||||
: selectedDocuments
|
: selectedDocuments
|
||||||
.filter((doc) =>
|
.filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id))
|
||||||
(doc.correspondents || []).some(
|
|
||||||
(item) => item.id === entry.id && normalizeRole(item.role) === normalizedRole,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map((doc) => doc.id);
|
.map((doc) => doc.id);
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
@@ -762,12 +725,11 @@ const DetailPanel = ({
|
|||||||
onCorrespondentRemove({
|
onCorrespondentRemove({
|
||||||
documentId,
|
documentId,
|
||||||
correspondentId: entry.id,
|
correspondentId: entry.id,
|
||||||
role: normalizedRole,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
},
|
},
|
||||||
[bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
[onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openZoomPreview = useCallback((config) => {
|
const openZoomPreview = useCallback((config) => {
|
||||||
@@ -1158,14 +1120,12 @@ const DetailPanel = ({
|
|||||||
onCorrespondentRemove?.({
|
onCorrespondentRemove?.({
|
||||||
documentId: singleDoc.id,
|
documentId: singleDoc.id,
|
||||||
correspondentId: entry.id,
|
correspondentId: entry.id,
|
||||||
role: entry.role,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onAdd={({ name, role, input }) =>
|
onAdd={({ name, input }) =>
|
||||||
onCorrespondentAdd?.({
|
onCorrespondentAdd?.({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
name,
|
name,
|
||||||
role,
|
|
||||||
input,
|
input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1310,8 +1270,8 @@ const DetailPanel = ({
|
|||||||
title="Correspondents"
|
title="Correspondents"
|
||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
onRemove={handleBulkCorrespondentRemove}
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
onAdd={({ name, role, input }) =>
|
onAdd={({ name, input }) =>
|
||||||
onBulkCorrespondentAdd?.({ name, role, input })
|
onBulkCorrespondentAdd?.({ name, input })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add correspondent to selection"
|
addPlaceholder="Add correspondent to selection"
|
||||||
datalistId="correspondent-catalog-bulk"
|
datalistId="correspondent-catalog-bulk"
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
|
ViewListIcon,
|
||||||
|
ViewGridIcon,
|
||||||
|
FolderIcon,
|
||||||
|
TrashIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
MinusVerticalIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
@@ -39,7 +50,6 @@ const resolveCorrespondents = (doc) => {
|
|||||||
results.push({
|
results.push({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
role: entry.role || null,
|
|
||||||
key: id ?? `${name}-${index}`,
|
key: id ?? `${name}-${index}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1048,3 +1058,127 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
export default DocumentsTable;
|
export default DocumentsTable;
|
||||||
export { DocumentThumbnailImage };
|
export { DocumentThumbnailImage };
|
||||||
|
|
||||||
|
export const createDocumentsTableHeaderActions = ({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRequestCreateFolder,
|
||||||
|
creatingFolder,
|
||||||
|
onRefresh,
|
||||||
|
onShowSkeuoWorkspace,
|
||||||
|
}) => {
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||||
|
onClick={() => onViewModeChange?.('list')}
|
||||||
|
aria-pressed={!isGridView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onRequestCreateFolder}
|
||||||
|
disabled={creatingFolder}
|
||||||
|
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||||
|
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
||||||
|
>
|
||||||
|
<FolderPlusIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||||
|
Desk View
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentsSurface = ({
|
||||||
|
tableProps,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
renderSidebarToggle,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
currentFolderName,
|
||||||
|
searchResults,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRequestCreateFolder,
|
||||||
|
creatingFolder,
|
||||||
|
onRefresh,
|
||||||
|
onShowSkeuoWorkspace,
|
||||||
|
} = tableProps;
|
||||||
|
|
||||||
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
|
const subtitle = Array.isArray(searchResults)
|
||||||
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const actions = createDocumentsTableHeaderActions({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRequestCreateFolder,
|
||||||
|
creatingFolder,
|
||||||
|
onRefresh,
|
||||||
|
onShowSkeuoWorkspace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const parentControl = parentBreadcrumb
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onNavigateParent}
|
||||||
|
aria-label="Go to parent folder"
|
||||||
|
title="Go to parent folder"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const leading = sidebarToggle || parentControl
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{sidebarToggle}
|
||||||
|
{parentControl}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'documents',
|
||||||
|
variant: 'documents',
|
||||||
|
header: { title, subtitle, leading, actions },
|
||||||
|
content: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||||
|
supportsDetail: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+166
-294
@@ -25,29 +25,15 @@ import {
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import useApiError from './hooks/useApiError';
|
import useApiError from './hooks/useApiError';
|
||||||
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
|
||||||
import PreviewWorkspace from './preview/PreviewWorkspace';
|
|
||||||
import DetailPanel from './detail/DetailPanel';
|
import DetailPanel from './detail/DetailPanel';
|
||||||
import TagsPanel from './tags/TagsPanel';
|
import TagsPanel from './tags/TagsPanel';
|
||||||
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||||
import { CORRESPONDENT_ROLES } from './constants/correspondents';
|
|
||||||
import TagManager from './tag_manager';
|
import TagManager from './tag_manager';
|
||||||
import Sidebar from './sidebar/Sidebar';
|
import Sidebar from './sidebar/Sidebar';
|
||||||
import {
|
import { ChevronsRightIcon } from './ui/icons';
|
||||||
ChevronsRightIcon,
|
import { createDocumentsSurface } from './documents/DocumentsTable';
|
||||||
ChevronsLeftIcon,
|
import { createPreviewSurface } from './preview/PreviewWorkspace';
|
||||||
ViewListIcon,
|
import { createSkeuomorphicSurface } from './skeuomorphic_ws';
|
||||||
ViewGridIcon,
|
|
||||||
FolderPlusIcon,
|
|
||||||
RefreshIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
MinusVerticalIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
TextScanIcon,
|
|
||||||
AnalyzeIcon,
|
|
||||||
CloseIcon,
|
|
||||||
} from './ui/icons';
|
|
||||||
import DocumentsTable from './documents/DocumentsTable';
|
|
||||||
import { AppShellContext, useAppShell } from './appShellContext';
|
import { AppShellContext, useAppShell } from './appShellContext';
|
||||||
|
|
||||||
const runtimeApiBase =
|
const runtimeApiBase =
|
||||||
@@ -416,6 +402,33 @@ const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MainContentShell = ({
|
||||||
|
className,
|
||||||
|
header = {},
|
||||||
|
sidebarToggleButton = null,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { title = '', subtitle = null, leading = null, actions = null } = header || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="panel-header main-content__header">
|
||||||
|
<div className="panel-actions main-content__actions">
|
||||||
|
{sidebarToggleButton}
|
||||||
|
{leading}
|
||||||
|
<h2 className="main-content__title">
|
||||||
|
{title}
|
||||||
|
{subtitle ? <span className="main-content__subtitle">{subtitle}</span> : null}
|
||||||
|
</h2>
|
||||||
|
<div className="spacer" />
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AppLayout = () => {
|
const AppLayout = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -1856,15 +1869,15 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleDocumentCorrespondentAttach(
|
async function handleDocumentCorrespondentAttach(
|
||||||
{ documentId, correspondentId, role },
|
{ documentId, correspondentId },
|
||||||
{ notify = true, refresh = true } = {},
|
{ notify = true, refresh = true } = {},
|
||||||
) {
|
) {
|
||||||
if (!documentId || !correspondentId || !role) {
|
if (!documentId || !correspondentId) {
|
||||||
throw new Error('Missing document, correspondent or role.');
|
throw new Error('Missing document or correspondent.');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.post(`/documents/${documentId}/correspondents`, {
|
await api.post(`/documents/${documentId}/correspondents`, {
|
||||||
assignments: [{ correspondent_id: correspondentId, role }],
|
assignments: [{ correspondent_id: correspondentId }],
|
||||||
replace: false,
|
replace: false,
|
||||||
});
|
});
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
@@ -1882,16 +1895,14 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCorrespondentRemove(
|
async function handleCorrespondentRemove(
|
||||||
{ documentId, correspondentId, role },
|
{ documentId, correspondentId },
|
||||||
{ notify = true, refresh = true } = {},
|
{ notify = true, refresh = true } = {},
|
||||||
) {
|
) {
|
||||||
if (!documentId || !correspondentId || !role) {
|
if (!documentId || !correspondentId) {
|
||||||
throw new Error('Missing document, correspondent or role.');
|
throw new Error('Missing document or correspondent.');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, {
|
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||||
params: { role },
|
|
||||||
});
|
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
}
|
}
|
||||||
@@ -1907,7 +1918,7 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCorrespondentAdd = useCallback(
|
const handleCorrespondentAdd = useCallback(
|
||||||
async ({ document, name, role, input }) => {
|
async ({ document, name, input }) => {
|
||||||
if (!document?.id) {
|
if (!document?.id) {
|
||||||
throw new Error('Missing document for correspondent assignment.');
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
}
|
}
|
||||||
@@ -1916,11 +1927,6 @@ const AppLayout = () => {
|
|||||||
setStatusMessage('Correspondent name is required.', 'error');
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedRole = (role || '').trim().toLowerCase();
|
|
||||||
if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
|
|
||||||
setStatusMessage('Select a valid correspondent role.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -1940,7 +1946,6 @@ const AppLayout = () => {
|
|||||||
await handleDocumentCorrespondentAttach({
|
await handleDocumentCorrespondentAttach({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
correspondentId: target.id,
|
correspondentId: target.id,
|
||||||
role: normalizedRole,
|
|
||||||
});
|
});
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -2228,7 +2233,7 @@ const AppLayout = () => {
|
|||||||
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
||||||
|
|
||||||
const handleBulkCorrespondentAdd = useCallback(
|
const handleBulkCorrespondentAdd = useCallback(
|
||||||
async ({ name, role, input }) => {
|
async ({ name, input }) => {
|
||||||
const trimmed = (name || '').trim();
|
const trimmed = (name || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setStatusMessage('Correspondent name is required.', 'error');
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
@@ -2238,11 +2243,6 @@ const AppLayout = () => {
|
|||||||
setStatusMessage('Select documents before assigning correspondents.', 'error');
|
setStatusMessage('Select documents before assigning correspondents.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedRole = (role || '').trim().toLowerCase();
|
|
||||||
if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
|
|
||||||
setStatusMessage('Select a valid correspondent role.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -2263,7 +2263,6 @@ const AppLayout = () => {
|
|||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
correspondent_id: target.id,
|
correspondent_id: target.id,
|
||||||
role: normalizedRole,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
action: 'add',
|
action: 'add',
|
||||||
@@ -2318,7 +2317,6 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const normalizedAssignments = assignments.map((entry) => ({
|
const normalizedAssignments = assignments.map((entry) => ({
|
||||||
correspondent_id: entry.correspondent_id,
|
correspondent_id: entry.correspondent_id,
|
||||||
role: (entry.role || '').toLowerCase(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await api.post('/documents/bulk/correspondents', {
|
const response = await api.post('/documents/bulk/correspondents', {
|
||||||
@@ -5414,7 +5412,6 @@ const DocumentsRoute = () => {
|
|||||||
skeuoWorkspaceProps,
|
skeuoWorkspaceProps,
|
||||||
openTagsModal,
|
openTagsModal,
|
||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
exitSkeuoWorkspace,
|
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
@@ -5440,281 +5437,156 @@ const DocumentsRoute = () => {
|
|||||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||||
currentFolderName,
|
const parentBreadcrumb = useMemo(() => {
|
||||||
viewMode,
|
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
|
||||||
onViewModeChange,
|
return null;
|
||||||
onRequestCreateFolder,
|
}
|
||||||
creatingFolder,
|
return breadcrumbs[breadcrumbs.length - 2];
|
||||||
onRefresh,
|
}, [breadcrumbs]);
|
||||||
onShowSkeuoWorkspace,
|
|
||||||
searchResults,
|
const handleNavigateParent = useCallback(() => {
|
||||||
breadcrumbs,
|
if (!parentBreadcrumb) {
|
||||||
} = documentsTableProps;
|
return;
|
||||||
|
}
|
||||||
|
const target = parentBreadcrumb.id === 'root'
|
||||||
|
? '/documents'
|
||||||
|
: `/documents/folder/${parentBreadcrumb.id}`;
|
||||||
|
navigate(target);
|
||||||
|
}, [navigate, parentBreadcrumb]);
|
||||||
|
|
||||||
const isWorkspace = workspaceMode === 'skeuo';
|
const isWorkspace = workspaceMode === 'skeuo';
|
||||||
const isPreviewWorkspace = Boolean(previewWorkspaceDocument);
|
const isPreviewWorkspace = Boolean(previewWorkspaceDocument);
|
||||||
const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace;
|
const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace;
|
||||||
const isGridView = viewMode === 'grid';
|
|
||||||
const showingSearchResults = Array.isArray(searchResults);
|
|
||||||
let headerTitle;
|
|
||||||
let headerSubtitle = null;
|
|
||||||
if (showPreviewWorkspace) {
|
|
||||||
const previewTitle = previewWorkspaceDocument?.title
|
|
||||||
|| previewWorkspaceDocument?.original_name
|
|
||||||
|| 'Document preview';
|
|
||||||
headerTitle = previewTitle;
|
|
||||||
} else if (showingSearchResults) {
|
|
||||||
headerTitle = 'Search results';
|
|
||||||
headerSubtitle = `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`;
|
|
||||||
} else {
|
|
||||||
headerTitle = currentFolderName;
|
|
||||||
}
|
|
||||||
const parentBreadcrumb = breadcrumbs && breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
|
|
||||||
const handleNavigateParent = parentBreadcrumb
|
|
||||||
? () => {
|
|
||||||
const target = parentBreadcrumb.id === 'root'
|
|
||||||
? '/documents'
|
|
||||||
: `/documents/folder/${parentBreadcrumb.id}`;
|
|
||||||
navigate(target);
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const detailHasContent = !isWorkspace && !showPreviewWorkspace
|
const renderSidebarToggle = useCallback(() => {
|
||||||
&& detailPanelProps.selectedDocuments?.length > 0;
|
if (!sidebarCollapsed) {
|
||||||
const toggleSidebar = sidebarCollapsed ? expandSidebar : collapseSidebar;
|
|
||||||
const ToggleIcon = sidebarCollapsed ? ChevronsRightIcon : ChevronsLeftIcon;
|
|
||||||
const toggleLabel = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
|
|
||||||
const mainContentClass = (() => {
|
|
||||||
if (isWorkspace) {
|
|
||||||
return 'main-content main-content--workspace';
|
|
||||||
}
|
|
||||||
if (showPreviewWorkspace) {
|
|
||||||
return 'main-content main-content--preview';
|
|
||||||
}
|
|
||||||
return `main-content main-content--documents${
|
|
||||||
detailHasContent ? ' main-content--has-detail' : ''
|
|
||||||
}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const bodyClass = (() => {
|
|
||||||
if (isWorkspace) {
|
|
||||||
return 'main-content__body main-content__body--workspace';
|
|
||||||
}
|
|
||||||
if (showPreviewWorkspace) {
|
|
||||||
return 'main-content__body main-content__body--preview';
|
|
||||||
}
|
|
||||||
return `main-content__body main-content__body--documents${
|
|
||||||
detailHasContent ? ' main-content__body--has-detail' : ''
|
|
||||||
}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const previewDownloadHref = useMemo(() => {
|
|
||||||
if (!previewWorkspaceDocument?.current_version?.download_path) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveApiPath(previewWorkspaceDocument.current_version.download_path);
|
return (
|
||||||
}, [previewWorkspaceDocument, resolveApiPath]);
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={expandSidebar}
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
title="Expand sidebar"
|
||||||
|
>
|
||||||
|
<ChevronsRightIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}, [sidebarCollapsed, expandSidebar]);
|
||||||
|
|
||||||
const previewHasOcr = useMemo(
|
const documentsSurface = useMemo(() => {
|
||||||
() => Boolean(
|
if (!documentsTableProps) {
|
||||||
previewWorkspaceDocument && getDocumentAsset(previewWorkspaceDocument, 'ocr-text'),
|
return null;
|
||||||
),
|
|
||||||
[previewWorkspaceDocument, getDocumentAsset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreviewOcr = useCallback(async () => {
|
|
||||||
if (!previewWorkspaceDocument) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const asset = getDocumentAsset(previewWorkspaceDocument, 'ocr-text');
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const ensured = await ensureAssetUrl(previewWorkspaceDocument.id, asset, { force: false });
|
|
||||||
const entry = ensured || asset;
|
|
||||||
const url = entry?.url
|
|
||||||
|| resolveDocumentAssetUrl(previewWorkspaceDocument, 'ocr-text', {
|
|
||||||
ensureAssetUrl,
|
|
||||||
getAsset: getDocumentAsset,
|
|
||||||
});
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('OCR text URL unavailable.');
|
|
||||||
}
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Unable to open OCR text.');
|
|
||||||
}
|
}
|
||||||
|
return createDocumentsSurface({
|
||||||
|
tableProps: documentsTableProps,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
|
||||||
|
renderSidebarToggle,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
|
documentsTableProps,
|
||||||
|
parentBreadcrumb,
|
||||||
|
handleNavigateParent,
|
||||||
|
renderSidebarToggle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const previewSurface = useMemo(() => {
|
||||||
|
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createPreviewSurface({
|
||||||
|
document: previewWorkspaceDocument,
|
||||||
|
previewEntry: previewWorkspaceEntry,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
onRegenerate: handleThumbnailRegeneration,
|
||||||
|
onClose: closeDocumentPreview,
|
||||||
|
renderSidebarToggle,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
showPreviewWorkspace,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
|
previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
|
handleThumbnailRegeneration,
|
||||||
|
closeDocumentPreview,
|
||||||
|
renderSidebarToggle,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const workspaceSurface = useMemo(() => {
|
||||||
|
if (!isWorkspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createSkeuomorphicSurface({
|
||||||
|
workspaceProps: skeuoWorkspaceProps,
|
||||||
|
renderSidebarToggle,
|
||||||
|
});
|
||||||
|
}, [isWorkspace, skeuoWorkspaceProps, renderSidebarToggle]);
|
||||||
|
|
||||||
|
const surface = showPreviewWorkspace
|
||||||
|
? previewSurface
|
||||||
|
: isWorkspace
|
||||||
|
? workspaceSurface
|
||||||
|
: documentsSurface;
|
||||||
|
|
||||||
|
if (!surface) {
|
||||||
|
return (
|
||||||
|
<DocumentsLayout
|
||||||
|
sidebarProps={sidebarPropsWithActions}
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
|
>
|
||||||
|
<div className="main-content main-content--documents">
|
||||||
|
<div className="main-content__body main-content__body--documents" />
|
||||||
|
</div>
|
||||||
|
</DocumentsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsDetail = Boolean(surface.supportsDetail);
|
||||||
|
const detailCount = detailPanelProps.selectedDocuments?.length || 0;
|
||||||
|
const showDetailPanel = supportsDetail && detailCount > 0;
|
||||||
|
const variant = surface.variant || 'documents';
|
||||||
|
|
||||||
|
const mainContentClass = `main-content main-content--${variant}${
|
||||||
|
showDetailPanel ? ' main-content--has-detail' : ''
|
||||||
|
}`;
|
||||||
|
const bodyClass = `main-content__body main-content__body--${variant}${
|
||||||
|
showDetailPanel ? ' main-content__body--has-detail' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const header = surface.header || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentsLayout
|
<DocumentsLayout
|
||||||
sidebarProps={sidebarPropsWithActions}
|
sidebarProps={sidebarPropsWithActions}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
>
|
>
|
||||||
<div className={mainContentClass}>
|
<div className={mainContentClass}>
|
||||||
<div className="panel-header main-content__header">
|
{header ? (
|
||||||
<div className="panel-actions main-content__actions">
|
<div className="panel-header main-content__header">
|
||||||
{sidebarCollapsed ? (
|
<div className="panel-actions main-content__actions">
|
||||||
<button
|
{header.leading}
|
||||||
type="button"
|
<h2 className="main-content__title">
|
||||||
className="icon-button ghost"
|
{header.title}
|
||||||
onClick={toggleSidebar}
|
{header.subtitle ? (
|
||||||
aria-label={toggleLabel}
|
<span className="main-content__subtitle">{header.subtitle}</span>
|
||||||
title={toggleLabel}
|
|
||||||
>
|
|
||||||
<ToggleIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{parentBreadcrumb && !showPreviewWorkspace ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={handleNavigateParent}
|
|
||||||
aria-label="Go to parent folder"
|
|
||||||
title="Go to parent folder"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<h2 className="main-content__title">
|
|
||||||
{headerTitle}
|
|
||||||
{headerSubtitle ? (
|
|
||||||
<span className="main-content__subtitle">{headerSubtitle}</span>
|
|
||||||
) : null}
|
|
||||||
</h2>
|
|
||||||
<div className="spacer" />
|
|
||||||
{isWorkspace ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={skeuoWorkspaceProps.onRefresh}
|
|
||||||
aria-label="Refresh"
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshIcon />
|
|
||||||
</button>
|
|
||||||
<button type="button" className="secondary" onClick={exitSkeuoWorkspace}>
|
|
||||||
Back to List
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : showPreviewWorkspace ? (
|
|
||||||
<>
|
|
||||||
{previewDownloadHref ? (
|
|
||||||
<a
|
|
||||||
className="icon-button"
|
|
||||||
href={previewDownloadHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Download document"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</a>
|
|
||||||
) : null}
|
) : null}
|
||||||
{previewHasOcr ? (
|
</h2>
|
||||||
<button
|
<div className="spacer" />
|
||||||
type="button"
|
{header.actions}
|
||||||
className="icon-button ghost"
|
</div>
|
||||||
onClick={handlePreviewOcr}
|
|
||||||
aria-label="View OCR text"
|
|
||||||
title="View OCR text"
|
|
||||||
>
|
|
||||||
<TextScanIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={() => handleThumbnailRegeneration(previewWorkspaceDocument.id)}
|
|
||||||
aria-label="Re-run analysis"
|
|
||||||
title="Re-run analysis"
|
|
||||||
>
|
|
||||||
<AnalyzeIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={() => closeDocumentPreview()}
|
|
||||||
aria-label="Close preview"
|
|
||||||
title="Close preview"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
|
||||||
onClick={() => onViewModeChange?.('list')}
|
|
||||||
aria-pressed={!isGridView}
|
|
||||||
title="List view"
|
|
||||||
>
|
|
||||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
|
||||||
onClick={() => onViewModeChange?.('grid')}
|
|
||||||
aria-pressed={isGridView}
|
|
||||||
title="Icons view"
|
|
||||||
>
|
|
||||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="main-content__actions-divider" aria-hidden="true">
|
|
||||||
<MinusVerticalIcon />
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={onRequestCreateFolder}
|
|
||||||
disabled={creatingFolder}
|
|
||||||
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
|
||||||
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
|
||||||
>
|
|
||||||
<FolderPlusIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={onRefresh}
|
|
||||||
aria-label="Refresh"
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshIcon />
|
|
||||||
</button>
|
|
||||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
|
||||||
Desk View
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className={bodyClass}>
|
|
||||||
{isWorkspace ? (
|
|
||||||
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
|
||||||
) : showPreviewWorkspace ? (
|
|
||||||
<PreviewWorkspace
|
|
||||||
document={previewWorkspaceDocument}
|
|
||||||
previewEntry={previewWorkspaceEntry}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentsTable {...documentsTableProps} showHeader={false} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isWorkspace && !showPreviewWorkspace && detailHasContent ? (
|
|
||||||
<DetailPanel {...detailPanelProps} />
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className={bodyClass}>{surface.content}</div>
|
||||||
|
{showDetailPanel ? <DetailPanel {...detailPanelProps} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</DocumentsLayout>
|
</DocumentsLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
|
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
const PreviewWorkspace = ({
|
||||||
document,
|
document,
|
||||||
@@ -42,3 +44,133 @@ const PreviewWorkspace = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PreviewWorkspace;
|
export default PreviewWorkspace;
|
||||||
|
|
||||||
|
export const createPreviewWorkspaceHeaderActions = ({
|
||||||
|
document,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
onRegenerate,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadHref = document.current_version?.download_path
|
||||||
|
? resolveApiPath(document.current_version.download_path)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const ocrAsset = getDocumentAsset(document, 'ocr-text');
|
||||||
|
const hasOcr = Boolean(ocrAsset);
|
||||||
|
|
||||||
|
const handleOcrClick = async () => {
|
||||||
|
if (!ocrAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
|
||||||
|
const entry = ensured || ocrAsset;
|
||||||
|
const url = entry?.url
|
||||||
|
|| resolveDocumentAssetUrl(document, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('OCR text URL unavailable.');
|
||||||
|
}
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Unable to open OCR text.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{downloadHref ? (
|
||||||
|
<a
|
||||||
|
className="icon-button"
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Download document"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{hasOcr ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={handleOcrClick}
|
||||||
|
aria-label="View OCR text"
|
||||||
|
title="View OCR text"
|
||||||
|
>
|
||||||
|
<TextScanIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={() => onRegenerate(document.id)}
|
||||||
|
aria-label="Re-run analysis"
|
||||||
|
title="Re-run analysis"
|
||||||
|
>
|
||||||
|
<AnalyzeIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={() => onClose?.()}
|
||||||
|
aria-label="Close preview"
|
||||||
|
title="Close preview"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPreviewSurface = ({
|
||||||
|
document,
|
||||||
|
previewEntry,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
onRegenerate,
|
||||||
|
onClose,
|
||||||
|
renderSidebarToggle,
|
||||||
|
}) => {
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.title || document.original_name || 'Document preview';
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||||
|
const header = {
|
||||||
|
title,
|
||||||
|
subtitle: null,
|
||||||
|
leading,
|
||||||
|
actions: createPreviewWorkspaceHeaderActions({
|
||||||
|
document,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
onRegenerate,
|
||||||
|
onClose,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'preview',
|
||||||
|
variant: 'preview',
|
||||||
|
header,
|
||||||
|
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
||||||
|
supportsDetail: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
import { ArrowLeftIcon, ArrowRightIcon, RefreshIcon } from './ui/icons';
|
||||||
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
||||||
import { getTagColorStyle } from './utils/colors';
|
import { getTagColorStyle } from './utils/colors';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
@@ -1785,3 +1785,43 @@ const SkeuomorphicWorkspace = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default SkeuomorphicWorkspace;
|
export default SkeuomorphicWorkspace;
|
||||||
|
|
||||||
|
export const createSkeuomorphicSurface = ({ workspaceProps, renderSidebarToggle }) => {
|
||||||
|
if (!workspaceProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentFolderName, searchResults, onRefresh, onExit } = workspaceProps;
|
||||||
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
|
const subtitle = Array.isArray(searchResults)
|
||||||
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary" onClick={onExit}>
|
||||||
|
Back to List
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'workspace',
|
||||||
|
variant: 'workspace',
|
||||||
|
header: { title, subtitle, leading, actions },
|
||||||
|
content: <SkeuomorphicWorkspace {...workspaceProps} />,
|
||||||
|
supportsDetail: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user