diff --git a/backend/dgraph/src/barcode/barcode.rs b/backend/dgraph/src/barcode/barcode.rs new file mode 100644 index 000000000..ee3535a7b --- /dev/null +++ b/backend/dgraph/src/barcode/barcode.rs @@ -0,0 +1,42 @@ +use gql_client::GraphQLError; +use serde::Serialize; + +use crate::{Barcode, BarcodeData, DgraphClient}; + +#[derive(Serialize, Debug, Clone)] +struct BarcodeVars { + gtin: String, +} + +pub async fn barcode_by_gtin( + client: &DgraphClient, + gtin: String, +) -> Result, GraphQLError> { + let query = r#" +query barcode($gtin: String!) { + data: queryBarcode(filter: {gtin: {eq: $gtin}}) { + gtin + manufacturer + entity { + description + name + code + } + } +} +"#; + let vars = BarcodeVars { gtin }; + + let result = client + .gql + .query_with_vars::(query, vars) + .await?; + + match result { + Some(result) => match result.data.first() { + Some(barcode) => Ok(Some(barcode.clone())), + None => Ok(None), + }, + None => Ok(None), + } +} diff --git a/backend/dgraph/src/barcode/barcodes.rs b/backend/dgraph/src/barcode/barcodes.rs new file mode 100644 index 000000000..353f0a962 --- /dev/null +++ b/backend/dgraph/src/barcode/barcodes.rs @@ -0,0 +1,38 @@ +use gql_client::GraphQLError; +use serde::Serialize; + +use crate::{BarcodeData, DgraphClient}; + +#[derive(Serialize, Debug)] +pub struct BarcodeQueryVars { + pub first: Option, + pub offset: Option, +} + +pub async fn barcodes( + client: &DgraphClient, + vars: BarcodeQueryVars, +) -> Result, GraphQLError> { + let query = r#" +query barcodes($first: Int, $offset: Int) { + data: queryBarcode(first: $first, offset: $offset) { + manufacturer + gtin + entity { + description + name + code + } + } + aggregates: aggregateBarcode { + count + } +} +"#; + let data = client + .gql + .query_with_vars::(query, vars) + .await?; + + Ok(data) +} diff --git a/backend/dgraph/src/barcode/delete_barcode.rs b/backend/dgraph/src/barcode/delete_barcode.rs new file mode 100644 index 000000000..c23c67781 --- /dev/null +++ b/backend/dgraph/src/barcode/delete_barcode.rs @@ -0,0 +1,106 @@ +use gql_client::GraphQLError; +use serde::Serialize; + +use crate::{DeleteResponse, DeleteResponseData, DgraphClient}; + +#[derive(Serialize, Debug, Clone)] +struct DeleteVars { + gtin: String, +} + +pub async fn delete_barcode( + client: &DgraphClient, + gtin: String, +) -> Result { + let query = r#" +mutation DeleteBarcode($gtin: String) { + data: deleteBarcode(filter: {gtin: {eq: $gtin}}) { + numUids + } +}"#; + let variables = DeleteVars { gtin }; + + let result = client + .query_with_retry::(&query, variables) + .await?; + + match result { + Some(result) => { + return Ok(DeleteResponse { + numUids: result.data.numUids, + }) + } + None => return Ok(DeleteResponse { numUids: 0 }), + } +} + +#[cfg(test)] +#[cfg(feature = "dgraph-tests")] +mod tests { + use util::uuid::uuid; + + use crate::{ + barcode::barcode::barcode_by_gtin, + insert_barcode::{insert_barcode, BarcodeInput, EntityCode}, + }; + + use super::*; + + #[tokio::test] + async fn test_delete_barcode() { + let client = DgraphClient::new("http://localhost:8080/graphql"); + + let barcode_input = BarcodeInput { + manufacturer: "test_manufacturer".to_string(), + gtin: uuid(), + entity: EntityCode { + code: "c7750265".to_string(), + }, + }; + + let result = insert_barcode(&client, barcode_input.clone(), true).await; + if result.is_err() { + println!( + "insert_barcode err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + + // Barcode exists + let result = barcode_by_gtin(&client, barcode_input.gtin.clone()).await; + + if result.is_err() { + println!( + "barcode_by_gtin err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + + assert!(result.unwrap().is_some()); + + let result = delete_barcode(&client, barcode_input.gtin.clone()).await; + if result.is_err() { + println!( + "delete_barcode err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + assert!(result.is_ok()); + + // Barcode no longer exists + let result = barcode_by_gtin(&client, barcode_input.gtin.clone()).await; + + if result.is_err() { + println!( + "barcode_by_gtin err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + + assert!(result.unwrap().is_none()); + } +} diff --git a/backend/dgraph/src/barcode/insert_barcode.rs b/backend/dgraph/src/barcode/insert_barcode.rs new file mode 100644 index 000000000..8f1aa3a94 --- /dev/null +++ b/backend/dgraph/src/barcode/insert_barcode.rs @@ -0,0 +1,104 @@ +use gql_client::GraphQLError; +use serde::Serialize; + +use crate::{DgraphClient, UpsertResponse, UpsertResponseData}; + +#[derive(Serialize, Debug, Clone)] +pub struct EntityCode { + pub code: String, +} + +#[derive(Serialize, Debug, Clone)] +pub struct BarcodeInput { + pub manufacturer: String, + pub gtin: String, + pub entity: EntityCode, +} + +#[derive(Serialize, Debug, Clone)] +struct UpsertVars { + input: BarcodeInput, + upsert: bool, +} + +pub async fn insert_barcode( + client: &DgraphClient, + barcode: BarcodeInput, + upsert: bool, +) -> Result { + let query = r#" +mutation AddBarcode($input: [AddBarcodeInput!]!, $upsert: Boolean = false) { + data: addBarcode(input: $input, upsert: $upsert) { + numUids + } +}"#; + let variables = UpsertVars { + input: barcode, + upsert, + }; + + let result = client + .query_with_retry::(&query, variables) + .await?; + + match result { + Some(result) => { + return Ok(UpsertResponse { + numUids: result.data.numUids, + }) + } + None => return Ok(UpsertResponse { numUids: 0 }), + } +} + +#[cfg(test)] +#[cfg(feature = "dgraph-tests")] +mod tests { + use crate::barcode::barcode::barcode_by_gtin; + use crate::barcode::delete_barcode::delete_barcode; + use util::uuid::uuid; + + use super::*; + + #[tokio::test] + async fn test_insert_barcode() { + // Create a DgraphClient instance + let client = DgraphClient::new("http://localhost:8080/graphql"); + + let barcode_input = BarcodeInput { + manufacturer: "test_manufacturer".to_string(), + gtin: uuid(), + entity: EntityCode { + code: "c7750265".to_string(), + }, + }; + + let result = insert_barcode(&client, barcode_input.clone(), true).await; + if result.is_err() { + println!( + "insert_barcode err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + + // Check if the new record can be found by querying for the gtin + let result = barcode_by_gtin(&client, barcode_input.gtin.clone()).await; + + if result.is_err() { + println!( + "barcode_by_gtin err: {:#?} {:#?}", + result, + result.clone().unwrap_err().json() + ); + }; + + let data = result.unwrap().unwrap(); + assert_eq!(data.manufacturer, barcode_input.manufacturer); + + // Delete the record + let _result = delete_barcode(&client, barcode_input.gtin.clone()) + .await + .unwrap(); + } +} diff --git a/backend/dgraph/src/barcode/mod.rs b/backend/dgraph/src/barcode/mod.rs new file mode 100644 index 000000000..366af9737 --- /dev/null +++ b/backend/dgraph/src/barcode/mod.rs @@ -0,0 +1,22 @@ +use serde::Deserialize; + +use crate::{AggregateResult, Entity}; + +pub mod barcode; +pub mod barcodes; +pub mod delete_barcode; +pub mod insert_barcode; + +#[derive(Deserialize, Debug, Clone)] +pub struct Barcode { + pub gtin: String, + pub manufacturer: String, + pub entity: Entity, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BarcodeData { + pub data: Vec, + #[serde(default)] + pub aggregates: Option, +} diff --git a/backend/dgraph/src/entity.rs b/backend/dgraph/src/entity.rs index d4e62ef71..cec952c21 100644 --- a/backend/dgraph/src/entity.rs +++ b/backend/dgraph/src/entity.rs @@ -18,6 +18,10 @@ pub async fn entity_by_code( name description alternative_names + barcodes { + gtin + manufacturer + } type __typename properties { @@ -87,6 +91,10 @@ pub async fn entity_with_parents_by_code( code name description + barcodes { + gtin + manufacturer + } alternative_names type __typename diff --git a/backend/dgraph/src/lib.rs b/backend/dgraph/src/lib.rs index d7337c021..b3d748c81 100644 --- a/backend/dgraph/src/lib.rs +++ b/backend/dgraph/src/lib.rs @@ -28,6 +28,8 @@ pub mod upsert_entity; pub use upsert_entity::*; pub mod link_codes; pub use link_codes::*; +pub mod barcode; +pub use barcode::*; pub use gql_client::GraphQLError; @@ -74,6 +76,8 @@ pub struct Entity { #[serde(default)] pub alternative_names: Option, #[serde(default)] + pub barcodes: Vec, + #[serde(default)] pub properties: Vec, #[serde(default)] pub children: Vec, @@ -91,6 +95,12 @@ pub struct Property { pub value: String, } +#[derive(Deserialize, Debug, Clone)] +pub struct BarcodeInfo { + pub gtin: String, + pub manufacturer: String, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] pub enum ChangeType { Change, diff --git a/backend/graphql/Cargo.toml b/backend/graphql/Cargo.toml index bf31198b0..8da484d64 100644 --- a/backend/graphql/Cargo.toml +++ b/backend/graphql/Cargo.toml @@ -14,6 +14,7 @@ util = { path = "../util" } graphql_configuration = { path = "configuration" } graphql_drug_interactions = { path = "drug_interactions" } graphql_core = { path = "core" } +graphql_barcode = { path = "barcode" } graphql_types = { path = "types" } graphql_general = { path = "general" } graphql_user_account = { path = "user_account" } diff --git a/backend/graphql/barcode/Cargo.toml b/backend/graphql/barcode/Cargo.toml new file mode 100644 index 000000000..bca968464 --- /dev/null +++ b/backend/graphql/barcode/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "graphql_barcode" +version = "0.1.0" +edition = "2018" + +[lib] +path = "src/lib.rs" +doctest = false + +[dependencies] + +graphql_core = { path = "../core" } +dgraph = { path = "../../dgraph" } +service = { path = "../../service" } +async-graphql = { version = "3.0.35", features = ["dataloader", "chrono"] } +graphql_universal_codes_v1 = { path = "../../graphql_v1/universal_codes" } diff --git a/backend/graphql/barcode/src/lib.rs b/backend/graphql/barcode/src/lib.rs new file mode 100644 index 000000000..5e6dbdeab --- /dev/null +++ b/backend/graphql/barcode/src/lib.rs @@ -0,0 +1,51 @@ +mod mutations; +use self::mutations::*; +mod types; + +use async_graphql::*; +use graphql_core::ContextExt; +use types::{ + AddBarcodeInput, BarcodeCollectionConnector, BarcodeCollectionResponse, BarcodeResponse, +}; + +#[derive(Default, Clone)] +pub struct BarcodeQueries; + +#[Object] +impl BarcodeQueries { + /// Get all barcodes + pub async fn barcodes( + &self, + ctx: &Context<'_>, + first: Option, + offset: Option, + ) -> Result { + let result = ctx + .service_provider() + .barcode_service + .barcodes(first, offset) + .await?; + + Ok(BarcodeCollectionResponse::Response( + BarcodeCollectionConnector::from_domain(result), + )) + } +} + +#[derive(Default, Clone)] +pub struct BarcodeMutations; + +#[Object] +impl BarcodeMutations { + async fn add_barcode( + &self, + ctx: &Context<'_>, + input: AddBarcodeInput, + ) -> Result { + add_barcode(ctx, input).await + } + + async fn delete_barcode(&self, ctx: &Context<'_>, gtin: String) -> Result { + delete_barcode(ctx, gtin).await + } +} diff --git a/backend/graphql/barcode/src/mutations/delete.rs b/backend/graphql/barcode/src/mutations/delete.rs new file mode 100644 index 000000000..6495de62c --- /dev/null +++ b/backend/graphql/barcode/src/mutations/delete.rs @@ -0,0 +1,54 @@ +use async_graphql::*; + +use graphql_core::{ + standard_graphql_error::{validate_auth, StandardGraphqlError}, + ContextExt, +}; + +use service::{ + auth::{Resource, ResourceAccessRequest}, + barcodes::ModifyBarcodeError, +}; + +pub async fn delete_barcode(ctx: &Context<'_>, gtin: String) -> Result { + let user = validate_auth( + ctx, + &ResourceAccessRequest { + resource: Resource::MutateUniversalCodes, + }, + )?; + + let service_context = ctx.service_context(Some(&user))?; + match service_context + .service_provider + .barcode_service + .delete_barcode( + ctx.service_provider(), + service_context.user_id.clone(), + gtin, + ) + .await + { + Ok(affected_items) => Ok(affected_items), + Err(error) => map_modify_barcode_error(error), + } +} + +fn map_modify_barcode_error(error: ModifyBarcodeError) -> Result { + use StandardGraphqlError::*; + let formatted_error = format!("{:#?}", error); + + let graphql_error = match error { + ModifyBarcodeError::BarcodeAlreadyExists => BadUserInput(formatted_error), + ModifyBarcodeError::BarcodeDoesNotExist => BadUserInput(formatted_error), + ModifyBarcodeError::UniversalCodeDoesNotExist => BadUserInput(formatted_error), + ModifyBarcodeError::BadUserInput(message) => BadUserInput(message), + ModifyBarcodeError::InternalError(message) => InternalError(message), + ModifyBarcodeError::DatabaseError(_) => InternalError(formatted_error), + ModifyBarcodeError::DgraphError(gql_error) => { + InternalError(format!("{:#?} - {:?}", gql_error, gql_error.json())) + } + }; + + Err(graphql_error.extend()) +} diff --git a/backend/graphql/barcode/src/mutations/insert.rs b/backend/graphql/barcode/src/mutations/insert.rs new file mode 100644 index 000000000..3703edf31 --- /dev/null +++ b/backend/graphql/barcode/src/mutations/insert.rs @@ -0,0 +1,34 @@ +use async_graphql::*; + +use graphql_core::{standard_graphql_error::validate_auth, ContextExt}; + +use service::auth::{Resource, ResourceAccessRequest}; + +use crate::{ + map_modify_barcode_error, + types::{AddBarcodeInput, BarcodeNode, BarcodeResponse}, +}; + +pub async fn add_barcode(ctx: &Context<'_>, input: AddBarcodeInput) -> Result { + let user = validate_auth( + ctx, + &ResourceAccessRequest { + resource: Resource::MutateUniversalCodes, + }, + )?; + + let service_context = ctx.service_context(Some(&user))?; + match service_context + .service_provider + .barcode_service + .add_barcode( + ctx.service_provider(), + service_context.user_id.clone(), + input.into(), + ) + .await + { + Ok(barcode) => Ok(BarcodeResponse::Response(BarcodeNode::from_domain(barcode))), + Err(error) => map_modify_barcode_error(error), + } +} diff --git a/backend/graphql/barcode/src/mutations/mod.rs b/backend/graphql/barcode/src/mutations/mod.rs new file mode 100644 index 000000000..1156d6bac --- /dev/null +++ b/backend/graphql/barcode/src/mutations/mod.rs @@ -0,0 +1,29 @@ +use async_graphql::*; +use graphql_core::standard_graphql_error::StandardGraphqlError; +use service::barcodes::ModifyBarcodeError; + +mod delete; +pub use delete::*; +mod insert; +pub use insert::*; + +use crate::types::BarcodeResponse; + +pub fn map_modify_barcode_error(error: ModifyBarcodeError) -> Result { + use StandardGraphqlError::*; + let formatted_error = format!("{:#?}", error); + + let graphql_error = match error { + ModifyBarcodeError::BarcodeAlreadyExists => BadUserInput(formatted_error), + ModifyBarcodeError::BarcodeDoesNotExist => BadUserInput(formatted_error), + ModifyBarcodeError::UniversalCodeDoesNotExist => BadUserInput(formatted_error), + ModifyBarcodeError::BadUserInput(message) => BadUserInput(message), + ModifyBarcodeError::InternalError(message) => InternalError(message), + ModifyBarcodeError::DatabaseError(_) => InternalError(formatted_error), + ModifyBarcodeError::DgraphError(gql_error) => { + InternalError(format!("{:#?} - {:?}", gql_error, gql_error.json())) + } + }; + + Err(graphql_error.extend()) +} diff --git a/backend/graphql/barcode/src/types/barcode.rs b/backend/graphql/barcode/src/types/barcode.rs new file mode 100644 index 000000000..b55a517dd --- /dev/null +++ b/backend/graphql/barcode/src/types/barcode.rs @@ -0,0 +1,60 @@ +use async_graphql::*; +use dgraph::Barcode; +use graphql_universal_codes_v1::EntityType; +use service::barcodes::BarcodeCollection; + +#[derive(Clone, Debug)] +pub struct BarcodeNode { + pub row: Barcode, +} + +impl BarcodeNode { + pub fn from_domain(barcode: Barcode) -> BarcodeNode { + BarcodeNode { row: barcode } + } +} + +#[Object] +impl BarcodeNode { + pub async fn id(&self) -> &str { + &self.row.gtin + } + + pub async fn gtin(&self) -> &str { + &self.row.gtin + } + pub async fn manufacturer(&self) -> &str { + &self.row.manufacturer + } + pub async fn entity(&self) -> EntityType { + EntityType::from_domain(self.row.entity.clone()) + } +} + +#[derive(Debug, SimpleObject)] +pub struct BarcodeCollectionConnector { + pub data: Vec, + pub total_count: u32, +} + +impl BarcodeCollectionConnector { + pub fn from_domain(results: BarcodeCollection) -> BarcodeCollectionConnector { + BarcodeCollectionConnector { + total_count: results.total_length, + data: results + .data + .into_iter() + .map(BarcodeNode::from_domain) + .collect(), + } + } +} + +#[derive(Union)] +pub enum BarcodeResponse { + Response(BarcodeNode), +} +#[derive(Union)] +pub enum BarcodeCollectionResponse { + Response(BarcodeCollectionConnector), +} diff --git a/backend/graphql/barcode/src/types/inputs.rs b/backend/graphql/barcode/src/types/inputs.rs new file mode 100644 index 000000000..00afda3f0 --- /dev/null +++ b/backend/graphql/barcode/src/types/inputs.rs @@ -0,0 +1,19 @@ +use async_graphql::*; +use service::barcodes::upsert::AddBarcode; + +#[derive(InputObject, Clone)] +pub struct AddBarcodeInput { + pub gtin: String, + pub manufacturer: String, + pub entity_code: String, +} + +impl From for AddBarcode { + fn from(input: AddBarcodeInput) -> Self { + AddBarcode { + gtin: input.gtin, + manufacturer: input.manufacturer, + entity_code: input.entity_code, + } + } +} diff --git a/backend/graphql/barcode/src/types/mod.rs b/backend/graphql/barcode/src/types/mod.rs new file mode 100644 index 000000000..c3469dce7 --- /dev/null +++ b/backend/graphql/barcode/src/types/mod.rs @@ -0,0 +1,4 @@ +mod barcode; +pub use barcode::*; +mod inputs; +pub use inputs::*; diff --git a/backend/graphql/lib.rs b/backend/graphql/lib.rs index 5169c4a47..f568c1ba7 100644 --- a/backend/graphql/lib.rs +++ b/backend/graphql/lib.rs @@ -7,6 +7,7 @@ use actix_web::HttpResponse; use actix_web::{guard, HttpRequest}; use async_graphql::{EmptySubscription, MergedObject, SchemaBuilder}; use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse}; +use graphql_barcode::{BarcodeMutations, BarcodeQueries}; use graphql_configuration::{ConfigurationMutations, ConfigurationQueries}; use graphql_core::loader::LoaderRegistry; use graphql_core::{refresh_token_from_cookie, RefreshTokenData, SelfRequest}; @@ -30,6 +31,7 @@ pub struct FullQuery( pub UniversalCodesQueries, pub UniversalCodesV1Queries, pub ConfigurationQueries, + pub BarcodeQueries, pub DrugInteractionQueries, ); @@ -38,6 +40,7 @@ pub struct FullMutation( pub UserAccountMutations, pub UniversalCodesMutations, pub ConfigurationMutations, + pub BarcodeMutations, pub DrugInteractionMutations, ); @@ -51,6 +54,7 @@ pub fn full_query() -> FullQuery { UniversalCodesQueries, UniversalCodesV1Queries, ConfigurationQueries, + BarcodeQueries, DrugInteractionQueries, ) } @@ -60,6 +64,7 @@ pub fn full_mutation() -> FullMutation { UserAccountMutations, UniversalCodesMutations, ConfigurationMutations, + BarcodeMutations, DrugInteractionMutations, ) } diff --git a/backend/graphql/types/src/types/log.rs b/backend/graphql/types/src/types/log.rs index 30d35274c..bbc689b96 100644 --- a/backend/graphql/types/src/types/log.rs +++ b/backend/graphql/types/src/types/log.rs @@ -28,6 +28,8 @@ pub enum LogNodeType { UniversalCodeChangeApproved, UniversalCodeChangeRejected, UniversalCodeChangeRequested, + BarcodeCreated, + BarcodeDeleted, ConfigurationItemCreated, ConfigurationItemDeleted, PropertyConfigurationItemUpserted, @@ -97,6 +99,8 @@ impl LogNodeType { LogType::PropertyConfigurationItemUpserted => { LogNodeType::PropertyConfigurationItemUpserted } + LogType::BarcodeCreated => LogNodeType::BarcodeCreated, + LogType::BarcodeDeleted => LogNodeType::BarcodeDeleted, LogType::InteractionGroupUpserted => LogNodeType::InteractionGroupUpserted, LogType::InteractionGroupDeleted => LogNodeType::InteractionGroupDeleted, } @@ -120,6 +124,8 @@ impl LogNodeType { LogNodeType::PropertyConfigurationItemUpserted => { LogType::PropertyConfigurationItemUpserted } + LogNodeType::BarcodeCreated => LogType::BarcodeCreated, + LogNodeType::BarcodeDeleted => LogType::BarcodeDeleted, LogNodeType::InteractionGroupUpserted => LogType::InteractionGroupUpserted, LogNodeType::InteractionGroupDeleted => LogType::InteractionGroupDeleted, } diff --git a/backend/graphql_v1/universal_codes/src/types/barcode.rs b/backend/graphql_v1/universal_codes/src/types/barcode.rs new file mode 100644 index 000000000..bade37b0f --- /dev/null +++ b/backend/graphql_v1/universal_codes/src/types/barcode.rs @@ -0,0 +1,35 @@ +use async_graphql::*; +use dgraph::BarcodeInfo; + +#[derive(Clone, Debug)] +pub struct BarcodeType { + pub manufacturer: String, + pub gtin: String, +} + +#[Object] +impl BarcodeType { + pub async fn id(&self) -> &str { + &self.gtin + } + + pub async fn manufacturer(&self) -> &str { + &self.manufacturer + } + + pub async fn gtin(&self) -> &str { + &self.gtin + } +} + +impl BarcodeType { + pub fn from_domain(entity_barcodes: Vec) -> Vec { + entity_barcodes + .into_iter() + .map(|g| BarcodeType { + gtin: g.gtin, + manufacturer: g.manufacturer, + }) + .collect() + } +} diff --git a/backend/graphql_v1/universal_codes/src/types/entity.rs b/backend/graphql_v1/universal_codes/src/types/entity.rs index d9ff9ea9f..cef1d7e6f 100644 --- a/backend/graphql_v1/universal_codes/src/types/entity.rs +++ b/backend/graphql_v1/universal_codes/src/types/entity.rs @@ -2,6 +2,7 @@ use async_graphql::*; use dgraph::Entity; use crate::AlternativeNameType; +use crate::BarcodeType; use super::DrugInteractionType; use super::PropertiesType; @@ -15,6 +16,7 @@ pub struct EntityType { pub r#type: String, pub category: String, pub alternative_names: Vec, + pub barcodes: Vec, pub properties: Vec, pub children: Vec, pub parents: Vec, @@ -29,6 +31,7 @@ impl EntityType { description: entity.description, r#type: entity.r#type, category: entity.category, + barcodes: BarcodeType::from_domain(entity.barcodes), properties: PropertiesType::from_domain(entity.properties), alternative_names: match entity.alternative_names { Some(names) => AlternativeNameType::from_domain(names), @@ -68,6 +71,10 @@ impl EntityType { get_type_for_entity(&self) } + pub async fn barcodes(&self) -> &Vec { + &self.barcodes + } + pub async fn properties(&self) -> &Vec { &self.properties } diff --git a/backend/graphql_v1/universal_codes/src/types/mod.rs b/backend/graphql_v1/universal_codes/src/types/mod.rs index 031e3ba2c..ee9f6dc03 100644 --- a/backend/graphql_v1/universal_codes/src/types/mod.rs +++ b/backend/graphql_v1/universal_codes/src/types/mod.rs @@ -10,3 +10,5 @@ mod entity_search_input; pub use entity_search_input::*; mod entity_sort; pub use entity_sort::*; +mod barcode; +pub use barcode::*; diff --git a/backend/repository/src/db_diesel/audit_log_row.rs b/backend/repository/src/db_diesel/audit_log_row.rs index e8ebeb486..ec3381b2b 100644 --- a/backend/repository/src/db_diesel/audit_log_row.rs +++ b/backend/repository/src/db_diesel/audit_log_row.rs @@ -28,6 +28,8 @@ pub enum LogType { UniversalCodeChangeRequested, UniversalCodeCreated, UniversalCodeUpdated, + BarcodeCreated, + BarcodeDeleted, ConfigurationItemCreated, ConfigurationItemDeleted, PropertyConfigurationItemUpserted, diff --git a/backend/service/src/barcodes/delete.rs b/backend/service/src/barcodes/delete.rs new file mode 100644 index 000000000..719c95862 --- /dev/null +++ b/backend/service/src/barcodes/delete.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use crate::{ + audit_log::audit_log_entry, + service_provider::{ServiceContext, ServiceProvider}, +}; +use chrono::Utc; +use dgraph::{ + barcode::barcode::barcode_by_gtin, delete_barcode::delete_barcode as dgraph_delete_barcode, +}; +use repository::LogType; + +use super::ModifyBarcodeError; + +pub async fn delete_barcode( + sp: Arc, + user_id: String, + client: dgraph::DgraphClient, + gtin: String, +) -> Result { + validate(&client, gtin.clone()).await?; + + let result = dgraph_delete_barcode(&client, gtin.clone()).await?; + + // Audit logging + let service_context = ServiceContext::with_user(sp.clone(), user_id)?; + audit_log_entry( + &service_context, + LogType::BarcodeDeleted, + Some(gtin), + Utc::now().naive_utc(), + )?; + + Ok(result.numUids) +} + +async fn validate(client: &dgraph::DgraphClient, gtin: String) -> Result<(), ModifyBarcodeError> { + // Check that the barcode does exist + let result = barcode_by_gtin(client, gtin.clone()).await.map_err(|e| { + ModifyBarcodeError::InternalError(format!("Failed to get barcode by gtin: {}", e.message())) + })?; + + match result { + Some(_) => {} + None => { + return Err(ModifyBarcodeError::BarcodeDoesNotExist); + } + } + + Ok(()) +} diff --git a/backend/service/src/barcodes/mod.rs b/backend/service/src/barcodes/mod.rs new file mode 100644 index 000000000..8c64f3b27 --- /dev/null +++ b/backend/service/src/barcodes/mod.rs @@ -0,0 +1,136 @@ +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; + +use dgraph::{ + barcode::barcode::barcode_by_gtin, + barcodes::{barcodes, BarcodeQueryVars}, + Barcode, DgraphClient, GraphQLError, +}; +use repository::RepositoryError; + +use crate::{service_provider::ServiceProvider, settings::Settings}; + +#[derive(Debug)] +pub enum ModifyBarcodeError { + BarcodeDoesNotExist, + BarcodeAlreadyExists, + UniversalCodeDoesNotExist, + BadUserInput(String), + InternalError(String), + DatabaseError(RepositoryError), + DgraphError(GraphQLError), +} + +impl From for ModifyBarcodeError { + fn from(error: RepositoryError) -> Self { + ModifyBarcodeError::DatabaseError(error) + } +} + +impl From for ModifyBarcodeError { + fn from(error: GraphQLError) -> Self { + ModifyBarcodeError::DgraphError(error) + } +} + +mod tests; + +pub mod delete; +pub mod upsert; + +pub struct BarcodeService { + client: DgraphClient, +} + +#[derive(Debug)] +pub enum BarcodeServiceError { + InternalError(String), + BadUserInput(String), +} + +impl Display for BarcodeServiceError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BarcodeServiceError::InternalError(details) => { + write!(f, "Internal error: {}", details) + } + BarcodeServiceError::BadUserInput(details) => { + write!(f, "Bad user input: {}", details) + } + } + } +} + +pub struct BarcodeCollection { + pub data: Vec, + pub total_length: u32, +} + +impl BarcodeService { + pub fn new(settings: Settings) -> Self { + let url = format!( + "{}:{}/graphql", + settings.dgraph.host.clone(), + settings.dgraph.port + ); + + BarcodeService { + client: DgraphClient::new(&url), + } + } + + pub async fn barcodes( + &self, + first: Option, + offset: Option, + ) -> Result { + let result = barcodes(&self.client, BarcodeQueryVars { first, offset }) + .await + .map_err(|e| BarcodeServiceError::InternalError(e.message().to_string()))?; // TODO: Improve error handling? + + match result { + Some(data) => Ok(BarcodeCollection { + data: data.data, + total_length: data.aggregates.unwrap_or_default().count, + }), + None => Ok(BarcodeCollection { + data: vec![], + total_length: 0, + }), + } + } + + pub async fn barcode_by_gtin( + &self, + gtin: String, + ) -> Result, BarcodeServiceError> { + let result = barcode_by_gtin(&self.client, gtin) + .await + .map_err(|e| BarcodeServiceError::InternalError(e.message().to_string()))?; // TODO: Improve error handling? + + match result { + Some(result) => Ok(Some(result)), + None => Ok(None), + } + } + + pub async fn add_barcode( + &self, + sp: Arc, + user_id: String, + item: upsert::AddBarcode, + ) -> Result { + upsert::add_barcode(sp, user_id, self.client.clone(), item).await + } + + pub async fn delete_barcode( + &self, + sp: Arc, + user_id: String, + gtin: String, + ) -> Result { + delete::delete_barcode(sp, user_id, self.client.clone(), gtin).await + } +} diff --git a/backend/service/src/barcodes/tests/delete.rs b/backend/service/src/barcodes/tests/delete.rs new file mode 100644 index 000000000..deb27330b --- /dev/null +++ b/backend/service/src/barcodes/tests/delete.rs @@ -0,0 +1,79 @@ +#[cfg(test)] +#[cfg(feature = "dgraph-tests")] +mod test { + use repository::{mock::MockDataInserts, test_db::setup_all}; + use std::sync::Arc; + use util::uuid::uuid; + + use crate::barcodes::upsert::AddBarcode; + use crate::service_provider::ServiceContext; + use crate::service_provider::ServiceProvider; + + use crate::test_utils::get_test_settings; + + #[actix_rt::test] + async fn delete_barcode_success() { + let (_, _, connection_manager, _) = + setup_all("delete_barcode_success", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + let new_gtin = uuid(); + let input = AddBarcode { + gtin: new_gtin.clone(), + manufacturer: "test_manufacturer".to_string(), + entity_code: "c7750265".to_string(), + }; + + let result = service + .add_barcode(service_provider.clone(), context.user_id.clone(), input) + .await + .unwrap(); + assert_eq!(result.gtin, new_gtin); + + // Delete the newly created barcode + let _result = service + .delete_barcode( + service_provider.clone(), + context.user_id.clone(), + new_gtin.clone(), + ) + .await + .unwrap(); + + // Check the barcode no longer exists + let result = service.barcode_by_gtin(new_gtin.clone()).await.unwrap(); + assert!(result.is_none()); + } + + #[actix_rt::test] + async fn delete_barcode_gtin_doesnt_exist() { + let (_, _, connection_manager, _) = + setup_all("delete_barcode_gtin_doesnt_exist", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + let some_gtin = uuid(); + + // Try delete non-existent barcode + let result = service + .delete_barcode( + service_provider.clone(), + context.user_id.clone(), + some_gtin.clone(), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/backend/service/src/barcodes/tests/mod.rs b/backend/service/src/barcodes/tests/mod.rs new file mode 100644 index 000000000..6e4171442 --- /dev/null +++ b/backend/service/src/barcodes/tests/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +mod upsert; + +#[cfg(test)] +mod delete; diff --git a/backend/service/src/barcodes/tests/upsert.rs b/backend/service/src/barcodes/tests/upsert.rs new file mode 100644 index 000000000..0e41736be --- /dev/null +++ b/backend/service/src/barcodes/tests/upsert.rs @@ -0,0 +1,151 @@ +#[cfg(test)] +#[cfg(feature = "dgraph-tests")] +mod test { + use repository::{mock::MockDataInserts, test_db::setup_all}; + use std::sync::Arc; + use util::uuid::uuid; + + use crate::barcodes::upsert::AddBarcode; + use crate::service_provider::ServiceContext; + use crate::service_provider::ServiceProvider; + + use crate::test_utils::get_test_settings; + + #[actix_rt::test] + async fn add_barcode_success() { + let (_, _, connection_manager, _) = + setup_all("add_barcode_success", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + let new_gtin = uuid(); + let input = AddBarcode { + gtin: new_gtin.clone(), + manufacturer: "test_manufacturer".to_string(), + entity_code: "c7750265".to_string(), + }; + + let result = service + .add_barcode(service_provider.clone(), context.user_id.clone(), input) + .await + .unwrap(); + assert_eq!(result.gtin, new_gtin); + + // Delete the newly created barcode + let _result = service + .delete_barcode( + service_provider.clone(), + context.user_id.clone(), + new_gtin.clone(), + ) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn add_barcode_no_gtin() { + let (_, _, connection_manager, _) = + setup_all("add_barcode_no_gtin", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + let input = AddBarcode { + gtin: "".to_string(), + manufacturer: "test_manufacturer".to_string(), + entity_code: "c7750265".to_string(), + }; + + let result = service + .add_barcode(service_provider.clone(), context.user_id.clone(), input) + .await; + assert!(result.is_err()); + } + + #[actix_rt::test] + async fn add_barcode_already_exists() { + let (_, _, connection_manager, _) = + setup_all("add_barcode_already_exists", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + // Add new barcode + let new_gtin = uuid(); + let input = AddBarcode { + gtin: new_gtin.clone(), + manufacturer: "test_manufacturer".to_string(), + entity_code: "c7750265".to_string(), + }; + + let result = service + .add_barcode( + service_provider.clone(), + context.user_id.clone(), + input.clone(), + ) + .await + .unwrap(); + assert_eq!(result.gtin, new_gtin); + + // Try add another with same GTIN + let input = AddBarcode { + gtin: new_gtin.clone(), + manufacturer: "another_manufacturer".to_string(), + entity_code: "6d8482f7".to_string(), + }; + let result = service + .add_barcode(service_provider.clone(), context.user_id.clone(), input) + .await; + + assert!(result.is_err()); + + // Delete the newly created barcode + let _result = service + .delete_barcode( + service_provider.clone(), + context.user_id.clone(), + new_gtin.clone(), + ) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn add_barcode_entity_code_not_found() { + let (_, _, connection_manager, _) = + setup_all("add_barcode_entity_code_not_found", MockDataInserts::none()).await; + + let service_provider = Arc::new(ServiceProvider::new( + connection_manager, + get_test_settings(""), + )); + let context = ServiceContext::as_server_admin(service_provider.clone()).unwrap(); + let service = &context.service_provider.barcode_service; + + let new_gtin = uuid(); + let input = AddBarcode { + gtin: new_gtin.clone(), + manufacturer: "test_manufacturer".to_string(), + entity_code: "doesn't exist".to_string(), + }; + + let result = service + .add_barcode(service_provider.clone(), context.user_id.clone(), input) + .await; + assert!(result.is_err()); + } +} diff --git a/backend/service/src/barcodes/upsert.rs b/backend/service/src/barcodes/upsert.rs new file mode 100644 index 000000000..b27c59eff --- /dev/null +++ b/backend/service/src/barcodes/upsert.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use crate::{ + audit_log::audit_log_entry, + service_provider::{ServiceContext, ServiceProvider}, +}; +use chrono::Utc; +use dgraph::{ + barcode::barcode::barcode_by_gtin, + entity, + insert_barcode::{insert_barcode, BarcodeInput, EntityCode}, + Barcode, +}; +use repository::LogType; + +use super::ModifyBarcodeError; + +#[derive(Clone, Debug)] +pub struct AddBarcode { + pub gtin: String, + pub manufacturer: String, + pub entity_code: String, +} + +pub async fn add_barcode( + sp: Arc, + user_id: String, + client: dgraph::DgraphClient, + new_barcode: AddBarcode, +) -> Result { + // Validate + validate(&client, &new_barcode).await?; + + // Generate + let barcode_input = generate(new_barcode); + + let _result = insert_barcode(&client, barcode_input.clone(), true).await?; + + // Audit logging + let service_context = ServiceContext::with_user(sp.clone(), user_id)?; + audit_log_entry( + &service_context, + LogType::BarcodeCreated, + Some(barcode_input.gtin.clone()), + Utc::now().naive_utc(), + )?; + + // Query to get the newly created barcode + let result = barcode_by_gtin(&client, barcode_input.gtin) + .await + .map_err(|e| { + ModifyBarcodeError::InternalError(format!( + "Failed to get newly created barcode by gtin: {}", + e.message() + )) + })?; + + let result = match result { + Some(result) => result, + None => { + return Err(ModifyBarcodeError::InternalError( + "Unable to find newly created barcode".to_string(), + )) + } + }; + + Ok(result) +} + +pub fn generate(new_barcode: AddBarcode) -> BarcodeInput { + BarcodeInput { + gtin: new_barcode.gtin.clone(), + manufacturer: new_barcode.manufacturer.clone(), + entity: EntityCode { + code: new_barcode.entity_code.clone(), + }, + } +} + +pub async fn validate( + _client: &dgraph::DgraphClient, + new_barcode: &AddBarcode, +) -> Result<(), ModifyBarcodeError> { + if new_barcode.gtin.is_empty() { + return Err(ModifyBarcodeError::BadUserInput( + "GTIN is required".to_string(), + )); + } + + if new_barcode.manufacturer.is_empty() { + return Err(ModifyBarcodeError::BadUserInput( + "Manufacturer is required".to_string(), + )); + } + + if new_barcode.entity_code.is_empty() { + return Err(ModifyBarcodeError::BadUserInput( + "Entity code is required".to_string(), + )); + } + + let existing = barcode_by_gtin(_client, new_barcode.gtin.clone()).await?; + + match existing { + Some(_) => return Err(ModifyBarcodeError::BarcodeAlreadyExists), + None => {} + } + + let entity = entity::entity_by_code(_client, new_barcode.entity_code.clone()).await?; + + match entity { + Some(_) => {} + None => return Err(ModifyBarcodeError::UniversalCodeDoesNotExist), + } + + Ok(()) +} diff --git a/backend/service/src/lib.rs b/backend/service/src/lib.rs index cff52ec6d..c06e8a9bc 100644 --- a/backend/service/src/lib.rs +++ b/backend/service/src/lib.rs @@ -4,6 +4,7 @@ use repository::{Pagination, PaginationOption, DEFAULT_PAGINATION_LIMIT}; pub mod audit_log; pub mod auth; pub mod auth_data; +pub mod barcodes; pub mod configuration; pub mod drug_interactions; pub mod email; diff --git a/backend/service/src/service_provider.rs b/backend/service/src/service_provider.rs index 7321a5c68..597065806 100644 --- a/backend/service/src/service_provider.rs +++ b/backend/service/src/service_provider.rs @@ -4,6 +4,7 @@ use repository::{RepositoryError, StorageConnection, StorageConnectionManager}; use crate::{ auth::{AuthService, AuthServiceTrait}, + barcodes::BarcodeService, configuration::ConfigurationService, drug_interactions::DrugInteractionService, email::{EmailService, EmailServiceTrait}, @@ -18,6 +19,7 @@ pub struct ServiceProvider { pub email_service: Box, pub universal_codes_service: Box, pub configuration_service: Box, + pub barcode_service: Box, pub drug_interaction_service: Box, pub validation_service: Box, pub user_account_service: Box, @@ -72,6 +74,7 @@ impl ServiceProvider { email_service: Box::new(EmailService::new(settings.clone())), universal_codes_service: Box::new(UniversalCodesService::new(settings.clone())), configuration_service: Box::new(ConfigurationService::new(settings.clone())), + barcode_service: Box::new(BarcodeService::new(settings.clone())), drug_interaction_service: Box::new(DrugInteractionService::new(settings.clone())), validation_service: Box::new(AuthService::new()), user_account_service: Box::new(UserAccountService {}), diff --git a/data-loader/data/v2/schema.graphql b/data-loader/data/v2/schema.graphql index 30714373c..afa342467 100644 --- a/data-loader/data/v2/schema.graphql +++ b/data-loader/data/v2/schema.graphql @@ -17,6 +17,7 @@ type Entity { properties: [Property] @dgraph(pred: "properties") children: [Entity] @dgraph(pred: "children") parents: [Entity] @dgraph(pred: "~children") + barcodes: [Barcode] @dgraph(pred: "barcodes") @hasInverse(field: entity) interaction_groups: [DrugInteractionGroup] @dgraph(pred: "interaction_groups") @hasInverse(field: drugs) @@ -74,6 +75,13 @@ type PropertyConfigurationItem { url: String @dgraph(pred: "url") } +type Barcode { + id: ID! + gtin: String! @id @dgraph(pred: "code") @search(by: [exact, trigram]) + manufacturer: String! @dgraph(pred: "manufacturer") + entity: Entity! @dgraph(pred: "entity") +} + enum DrugInteractionSeverity { Severe Moderate diff --git a/frontend/common/src/intl/locales/en/common.json b/frontend/common/src/intl/locales/en/common.json index 0dc7a9ebf..e508b7090 100644 --- a/frontend/common/src/intl/locales/en/common.json +++ b/frontend/common/src/intl/locales/en/common.json @@ -89,6 +89,7 @@ "label.expand-all": "Expand all", "label.expiry": "Expiry", "label.event": "Event", + "label.gtin": "Barcode/GTIN", "label.hours": "Hours", "label.hours_one": "Hour", "label.hours_other": "Hours", @@ -104,6 +105,7 @@ "label.location": "Location", "label.log": "Log", "label.manage": "Manage", + "label.manufacturer": "Manufacturer", "label.message": "Message", "label.minutes": "Minute(s)", "label.minutes_one": "Minute", @@ -116,6 +118,7 @@ "label.notes": "Notes", "label.number": "Number", "label.of": "of", + "label.pack-size": "Pack Size", "label.phone": "Phone", "label.please-specify": "Please specify", "label.quantity": "Quantity", @@ -126,6 +129,7 @@ "label.daily": "Daily", "label.weekly": "Weekly", "label.monthly": "Monthly", + "label.product": "Product", "label.request-for": "Request For", "label.requested-by": "Requested By", "label.select": "Select", diff --git a/frontend/common/src/intl/locales/en/host.json b/frontend/common/src/intl/locales/en/host.json index cb02b33c3..22fdd1746 100644 --- a/frontend/common/src/intl/locales/en/host.json +++ b/frontend/common/src/intl/locales/en/host.json @@ -10,6 +10,7 @@ "auth.timeout-message": "You have been logged out of your session due to inactivity. Click OK to return to the login screen.", "auth.timeout-title": "Session Timed Out", "auth.unauthenticated-message": "You are not currently logged in. Click OK to return to the login screen.", + "barcodes": "Barcodes", "browse": "Browse", "button.activate-account": "Activate Account", "button.close-the-menu": "Close the menu", diff --git a/frontend/common/src/intl/locales/en/system.json b/frontend/common/src/intl/locales/en/system.json index 4a870223e..c7ab4e478 100644 --- a/frontend/common/src/intl/locales/en/system.json +++ b/frontend/common/src/intl/locales/en/system.json @@ -20,6 +20,7 @@ "error.unknown-error": "Unknown Error", "error.username-invalid-characters": "Username must not contain any special characters or spaces", "error.name-invalid-characters": "Name must not contain any special characters or spaces, and must start with a letter", + "error.no-barcodes": "No barcodes were found", "error.no-users": "No users", "error.no-pending-changes": "No changes are pending right now", "error.username-too-short": "Username must be at least 3 characters long", @@ -28,6 +29,7 @@ "helper-text.website-placeholder": "You can use a placeholder for the property code: e.g. https://link.com?search={{code}}", "helper-text.you-cant-change-this": "Careful! You won't be able to change this value.", "label.about": "About", + "label.add-barcode": "Add Barcode", "label.add-brand": "Add Brand", "label.add-active-ingredients": "Add Active Ingredients", "label.add-alternative-name": "Add Alternative Name", @@ -49,6 +51,7 @@ "label.approve-next": "Approve & Next", "label.alt-name": "Alternative Name", "label.alt-names": "Alternative Names", + "label.barcodes": "Barcodes", "label.brand": "Brand", "label.brands": "Brands", "label.browse": "Browse", @@ -68,16 +71,19 @@ "label.forms": "Forms", "label.immediate-packaging": "Immediate Packaging", "label.invite-user": "Invite User", + "label.lookup": "Look up", "label.new-consumable": "New Consumable", "label.new-drug": "New Drug", "label.new-user": "New User", "label.new-vaccine": "New Vaccine", "label.pack-size": "Pack Size", + "label.pack-size-code": "Universal Code for Pack Size", "label.pack-sizes": "Pack Sizes", "label.presentations": "Presentations (Size, Type or Strength)", "label.presentation": "Presentation", "label.properties": "Properties", "label.reject": "Reject", + "label.related-barcodes": "{{ count }} Related Barcodes", "label.route": "Route", "label.routes": "Routes", "label.unit": "Unit", diff --git a/frontend/common/src/types/schema.ts b/frontend/common/src/types/schema.ts index 2de09f44b..1b3294b7f 100644 --- a/frontend/common/src/types/schema.ts +++ b/frontend/common/src/types/schema.ts @@ -27,6 +27,12 @@ export type AccessDenied = LogoutErrorInterface & { fullError: Scalars['String']['output']; }; +export type AddBarcodeInput = { + entityCode: Scalars['String']['input']; + gtin: Scalars['String']['input']; + manufacturer: Scalars['String']['input']; +}; + export type AddConfigurationItemInput = { name: Scalars['String']['input']; type: ConfigurationItemTypeInput; @@ -61,6 +67,31 @@ export type AuthTokenErrorInterface = { export type AuthTokenResponse = AuthToken | AuthTokenError; +export type BarcodeCollectionConnector = { + __typename: 'BarcodeCollectionConnector'; + data: Array; + totalCount: Scalars['Int']['output']; +}; + +export type BarcodeCollectionResponse = BarcodeCollectionConnector; + +export type BarcodeNode = { + __typename: 'BarcodeNode'; + entity: EntityType; + gtin: Scalars['String']['output']; + id: Scalars['String']['output']; + manufacturer: Scalars['String']['output']; +}; + +export type BarcodeResponse = BarcodeNode; + +export type BarcodeType = { + __typename: 'BarcodeType'; + gtin: Scalars['String']['output']; + id: Scalars['String']['output']; + manufacturer: Scalars['String']['output']; +}; + export enum ChangeStatusNode { Approved = 'APPROVED', Pending = 'PENDING', @@ -167,6 +198,7 @@ export type EntitySortInput = { export type EntityType = { __typename: 'EntityType'; alternativeNames: Array; + barcodes: Array; children: Array; code: Scalars['String']['output']; description: Scalars['String']['output']; @@ -195,9 +227,11 @@ export type FullMutation = { __typename: 'FullMutation'; /** Updates user account based on a token and their information (Response to initiate_user_invite) */ acceptUserInvite: InviteUserResponse; + addBarcode: BarcodeResponse; addConfigurationItem: Scalars['Int']['output']; approvePendingChange: UpsertEntityResponse; createUserAccount: CreateUserAccountResponse; + deleteBarcode: Scalars['Int']['output']; deleteConfigurationItem: Scalars['Int']['output']; deleteDrugInteractionGroup: Scalars['Int']['output']; deleteUserAccount: DeleteUserAccountResponse; @@ -227,6 +261,11 @@ export type FullMutationAcceptUserInviteArgs = { }; +export type FullMutationAddBarcodeArgs = { + input: AddBarcodeInput; +}; + + export type FullMutationAddConfigurationItemArgs = { input: AddConfigurationItemInput; }; @@ -243,6 +282,11 @@ export type FullMutationCreateUserAccountArgs = { }; +export type FullMutationDeleteBarcodeArgs = { + gtin: Scalars['String']['input']; +}; + + export type FullMutationDeleteConfigurationItemArgs = { code: Scalars['String']['input']; }; @@ -319,6 +363,8 @@ export type FullQuery = { * The refresh token is returned as a cookie */ authToken: AuthTokenResponse; + /** Get all barcodes */ + barcodes: BarcodeCollectionResponse; /** Get the configuration items for a given type. */ configurationItems: ConfigurationItemsResponse; entities: EntityCollectionType; @@ -348,6 +394,12 @@ export type FullQueryAuthTokenArgs = { }; +export type FullQueryBarcodesArgs = { + first?: InputMaybe; + offset?: InputMaybe; +}; + + export type FullQueryConfigurationItemsArgs = { type: ConfigurationItemTypeInput; }; @@ -452,10 +504,12 @@ export type LogNode = { }; export enum LogNodeType { + BarcodeCreated = 'BARCODE_CREATED', + BarcodeDeleted = 'BARCODE_DELETED', ConfigurationItemCreated = 'CONFIGURATION_ITEM_CREATED', ConfigurationItemDeleted = 'CONFIGURATION_ITEM_DELETED', - InteractionGroupCreated = 'INTERACTION_GROUP_CREATED', InteractionGroupDeleted = 'INTERACTION_GROUP_DELETED', + InteractionGroupUpserted = 'INTERACTION_GROUP_UPSERTED', PropertyConfigurationItemUpserted = 'PROPERTY_CONFIGURATION_ITEM_UPSERTED', UniversalCodeChangeApproved = 'UNIVERSAL_CODE_CHANGE_APPROVED', UniversalCodeChangeRejected = 'UNIVERSAL_CODE_CHANGE_REJECTED', diff --git a/frontend/common/src/ui/components/inputs/Autocomplete/AutocompleteList.tsx b/frontend/common/src/ui/components/inputs/Autocomplete/AutocompleteList.tsx index a87a371b2..dd70f3c5a 100644 --- a/frontend/common/src/ui/components/inputs/Autocomplete/AutocompleteList.tsx +++ b/frontend/common/src/ui/components/inputs/Autocomplete/AutocompleteList.tsx @@ -34,6 +34,7 @@ export type AutocompleteListProps = { disableClearable?: boolean; getOptionDisabled?: (option: T) => boolean; open?: boolean; + openOnFocus?: boolean; }; export const AutocompleteList = ({ @@ -59,6 +60,7 @@ export const AutocompleteList = ({ disableClearable, getOptionDisabled, open = true, + openOnFocus, }: AutocompleteListProps): JSX.Element => { const createdFilterOptions = createFilterOptions(filterOptionConfig); const optionRenderer = optionKey @@ -73,6 +75,8 @@ export const AutocompleteList = ({ mappedOptions = options; } + const openProp = !openOnFocus ? open : undefined; + return ( ({ renderInput || (props => ) } filterOptions={filterOptions ?? createdFilterOptions} - open={open} + open={openProp} forcePopupIcon={false} options={mappedOptions} renderOption={optionRenderer} @@ -116,6 +120,7 @@ export const AutocompleteList = ({ clearText={clearText} value={value} getOptionDisabled={getOptionDisabled} + openOnFocus={openOnFocus} /> ); }; diff --git a/frontend/common/src/ui/components/toolbars/DeleteLinesDropdownItem.tsx b/frontend/common/src/ui/components/toolbars/DeleteLinesDropdownItem.tsx new file mode 100644 index 000000000..6efa1f1fd --- /dev/null +++ b/frontend/common/src/ui/components/toolbars/DeleteLinesDropdownItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useNotification } from '@common/hooks'; +import { useTranslation } from '@common/intl'; +import { DeleteIcon, DropdownMenuItem } from '@common/ui'; +import { + LocalStorage, + RecordWithId, + useConfirmationModal, +} from 'frontend/common/src'; + +export const DeleteLinesDropdownItem = ({ + selectedRows, + deleteItem, +}: { + selectedRows: (T | undefined)[]; + deleteItem: (item: T) => Promise; +}) => { + const t = useTranslation(); + const { success, info, error } = useNotification(); + + const deleteAction = () => { + if (selectedRows.length) { + let errMessage = ''; + Promise.all( + selectedRows.map(async row => { + if (!row) return; + await deleteItem(row).catch(err => { + if (!errMessage) errMessage = err.message; + }); + }) + ).then(() => { + // Separate check for authorisation error, as this is handled globally i.e. not caught above + // Not using useLocalStorage here, as hook result only updates on re-render (after this function finishes running!) + const authError = LocalStorage.getItem('/auth/error'); + if (!errMessage && !authError) { + const deletedMessage = t('messages.deleted-generic', { + count: selectedRows.length, + }); + success(deletedMessage)(); + } else { + error(errMessage ?? 'Unknown/Auth Error')(); + } + }); + } else { + info(t('messages.select-rows-to-delete'))(); + } + }; + + const showDeleteConfirmation = useConfirmationModal({ + onConfirm: deleteAction, + message: t('messages.confirm-delete-generic', { + count: selectedRows.length, + }), + title: t('heading.are-you-sure'), + }); + + return ( + showDeleteConfirmation()} + > + {t('button.delete-lines')} + + ); +}; diff --git a/frontend/common/src/ui/components/toolbars/index.ts b/frontend/common/src/ui/components/toolbars/index.ts index 971905bba..a55ded3c4 100644 --- a/frontend/common/src/ui/components/toolbars/index.ts +++ b/frontend/common/src/ui/components/toolbars/index.ts @@ -1,2 +1,3 @@ export * from './SearchAndDeleteToolbar'; export * from './SearchToolbar'; +export * from './DeleteLinesDropdownItem'; diff --git a/frontend/common/src/ui/icons/Barcode.tsx b/frontend/common/src/ui/icons/Barcode.tsx new file mode 100644 index 000000000..04a03c236 --- /dev/null +++ b/frontend/common/src/ui/icons/Barcode.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; + +export const BarcodeIcon = (props: SvgIconProps): JSX.Element => { + return ( + + + + ); +}; diff --git a/frontend/common/src/ui/icons/index.ts b/frontend/common/src/ui/icons/index.ts index a53f4b243..56c9948ac 100644 --- a/frontend/common/src/ui/icons/index.ts +++ b/frontend/common/src/ui/icons/index.ts @@ -3,6 +3,7 @@ export { AngleCircleRightIcon } from './AngleCircleRight'; export { ArrowLeftIcon } from './ArrowLeft'; export { ArrowRightIcon } from './ArrowRight'; export { BarChartIcon } from './BarChart'; +export { BarcodeIcon } from './Barcode'; export { BookIcon } from './Book'; export { CartIcon } from './Cart'; export { CableIcon } from './Cable'; diff --git a/frontend/config/src/routes.ts b/frontend/config/src/routes.ts index 19e5c4388..83ab547fd 100644 --- a/frontend/config/src/routes.ts +++ b/frontend/config/src/routes.ts @@ -14,6 +14,7 @@ export enum AppRoute { Interactions = 'interactions', Edit = 'edit', PendingChanges = 'pending-changes', + Barcodes = 'barcodes', Settings = 'settings', Logout = 'logout', diff --git a/frontend/host/src/components/AppBar/AppBar.tsx b/frontend/host/src/components/AppBar/AppBar.tsx index b33b5f055..9b72ccc2b 100644 --- a/frontend/host/src/components/AppBar/AppBar.tsx +++ b/frontend/host/src/components/AppBar/AppBar.tsx @@ -81,6 +81,15 @@ const NavLinks = () => { {t('label.browse')} | + + {t('label.barcodes')} + + | { icon={} text={t('pending-changes')} /> + } + text={t('barcodes')} + /> { ) .map(immPack => ({ ...getDetails(immPack), - packSizes: [], // to bring in later + packSizes: + immPack.children + ?.filter( + packSize => + packSize.type === EntityType.PackSize + ) + .map(packSize => getDetails(packSize)) || + [], })) || [], })) || [], })) || [], @@ -531,6 +538,7 @@ export const buildEntityDetailsFromPendingChangeBody = ( name: input.name || '', type: input.type || '', alternativeNames: input.alternativeNames || [], + barcodes: [], properties: input.properties?.map(p => ({ code: p.code, diff --git a/frontend/system/src/Entities/BarcodeLink.tsx b/frontend/system/src/Entities/BarcodeLink.tsx new file mode 100644 index 000000000..080228f73 --- /dev/null +++ b/frontend/system/src/Entities/BarcodeLink.tsx @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from '@common/intl'; +import { EntityType } from '../constants'; +import { RouteBuilder } from '@common/utils'; +import { AppRoute } from 'frontend/config/src'; +import { EntityData } from './EntityData'; +import { Link } from 'frontend/common/src'; + +const SHOW_BARCODE_LINK_TYPES = [ + EntityType.Unit, + EntityType.ImmediatePackaging, + EntityType.PackSize, +]; + +export const BarcodeLink = ({ entity }: { entity: EntityData }) => { + const t = useTranslation('system'); + + if (!SHOW_BARCODE_LINK_TYPES.includes(entity.type as EntityType)) + return <>; + + const barcodeCount = useMemo(() => getBarcodeCount(entity), [entity]); + + return ( + <> + {!!barcodeCount && ( + + {t('label.related-barcodes', { count: barcodeCount })} + + )} + + ); +}; + +const getBarcodeCount = (entity: EntityData) => { + let count = 0; + + count += entity.barcodes.length; + + const countChildBarcodes = (e: EntityData) => { + e.children?.forEach(c => { + count += c.barcodes.length; + countChildBarcodes(c); + }); + }; + countChildBarcodes(entity); + + return count; +}; diff --git a/frontend/system/src/Entities/Barcodes/BarcodeEditModal.tsx b/frontend/system/src/Entities/Barcodes/BarcodeEditModal.tsx new file mode 100644 index 000000000..d862cbb0e --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/BarcodeEditModal.tsx @@ -0,0 +1,173 @@ +import { + AutocompleteList, + AutocompleteOptionRenderer, + BasicTextInput, + DialogButton, + InlineSpinner, + LoadingButton, +} from '@common/components'; +import { useDialog } from '@common/hooks'; +import { CheckIcon } from '@common/icons'; +import { useTranslation } from '@common/intl'; +import { AddBarcodeInput } from '@common/types'; +import { Grid, Typography } from '@common/ui'; +import { RegexUtils } from '@common/utils'; +import { Alert, AlertTitle } from '@mui/material'; +import React, { useState } from 'react'; +import { useEntities } from '../../Entities/api'; +import { EntityRowFragment } from '../../Entities/api/operations.generated'; +import { useAddBarcode } from './api'; +import { getParentDescription } from './helpers'; + +type BarcodeEditModalProps = { + isOpen: boolean; + onClose: () => void; + entityCodes?: string[]; +}; + +export const BarcodeEditModal = ({ + isOpen, + onClose, + entityCodes, +}: BarcodeEditModalProps) => { + const t = useTranslation('system'); + + const [errorMessage, setErrorMessage] = useState(null); + const [draft, setDraft] = useState({ + entityCode: '', + gtin: '', + manufacturer: '', + }); + + const { Modal } = useDialog({ isOpen, onClose }); + + const { data: packSizeEntities } = useEntities({ + first: 10000, + filter: { type: 'PackSize', orderBy: { field: 'description' } }, + offset: 0, + }); + + const { mutateAsync: addBarcode, isLoading } = useAddBarcode(); + + const onSubmit = async () => { + try { + await addBarcode({ + input: { + ...draft, + }, + }); + onClose(); + } catch (err) { + if (err instanceof Error) setErrorMessage(err.message); + else setErrorMessage(t('messages.unknown-error')); + } + }; + + const packSizeOptions: EntityRowFragment[] = packSizeEntities?.data ?? []; + + const isInvalid = !draft.gtin || !draft.manufacturer || !draft.entityCode; + const modalWidth = Math.min(window.innerWidth - 200, 800); + + return ( + } + variant="contained" + > + {t('button.ok')} + + } + cancelButton={} + title={t('label.add-barcode')} + > + {isLoading ? ( + + ) : ( + + setDraft({ ...draft, gtin: e.target.value })} + /> + setDraft({ ...draft, manufacturer: e.target.value })} + /> + `${option.id}`} + width={modalWidth - 50} + openOnFocus + renderInput={props => ( + + )} + filterOptions={(options, state) => + options.filter( + option => + // if entityCodes are defined, filter out options that are not in the list + (entityCodes?.includes(option.code) ?? true) && + RegexUtils.matchObjectProperties(state.inputValue, option, [ + 'description', + 'code', + ]) + ) + } + onChange={(e, value) => + setDraft({ + ...draft, + entityCode: (value as unknown as EntityRowFragment)?.code ?? '', + }) + } + /> + {errorMessage ? ( + + { + setErrorMessage(''); + }} + > + {t('error')} + {errorMessage} + + + ) : null} + + )} + + ); +}; + +const renderOption: AutocompleteOptionRenderer = ( + props, + option +): JSX.Element => ( +
  • + + {option.code} + + + {getParentDescription(option)}{' '} + + {option.name} + + +
  • +); diff --git a/frontend/system/src/Entities/Barcodes/BarcodeList.tsx b/frontend/system/src/Entities/Barcodes/BarcodeList.tsx new file mode 100644 index 000000000..9eb500068 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/BarcodeList.tsx @@ -0,0 +1,149 @@ +import { useAuthContext } from '@common/authentication'; +import { useEditModal } from '@common/hooks'; +import { useTranslation } from '@common/intl'; +import { PermissionNode } from '@common/types'; +import { + AppBarButtonsPortal, + AppBarContentPortal, + ColumnDescription, + createTableStore, + DataTable, + DeleteLinesDropdownItem, + DropdownMenu, + LoadingButton, + NothingHere, + PlusCircleIcon, + TableProps, + TableProvider, + Tooltip, + Typography, + useColumns, + useTableStore, +} from '@common/ui'; +import React from 'react'; +import { useDeleteBarcode } from './api'; +import { BarcodeFragment } from './api/operations.generated'; +import { BarcodeEditModal } from './BarcodeEditModal'; +import { getParentDescription } from './helpers'; + +interface BarcodeListProps { + barcodes: Omit[]; + isError: boolean; + isLoading: boolean; + entityCodes?: string[]; + pagination?: TableProps['pagination']; + updatePaginationQuery?: (page: number) => void; +} + +const BarcodeListComponent = ({ + barcodes, + isError, + isLoading, + entityCodes, + pagination, + updatePaginationQuery, +}: BarcodeListProps) => { + const t = useTranslation('system'); + const { hasPermission } = useAuthContext(); + const isAdmin = hasPermission(PermissionNode.ServerAdmin); + + const { onOpen, onClose, isOpen } = useEditModal(); + + const { mutateAsync: deleteGS1 } = useDeleteBarcode(); + + const selectedRows = useTableStore(state => + Object.keys(state.rowState) + .filter(id => state.rowState[id]?.isSelected) + .map(selectedId => barcodes.find(({ id }) => selectedId === id)) + .filter(Boolean) + ); + + const columnDefs: ColumnDescription[] = [ + { + key: 'entity', + label: 'label.product', + Cell: ({ rowData }) => { + const description = getParentDescription(rowData.entity); + return ( + + + {description.length > 50 + ? description.substring(0, 50) + '...' + : description} + + + ); + }, + }, + { + key: 'entity2', // also on entity, but we need to use different key to avoid error + label: 'label.pack-size', + Cell: ({ rowData }) => ( + <> + {rowData.entity.name} ({rowData.entity.code}) + + ), + }, + { key: 'manufacturer', label: 'label.manufacturer' }, + { key: 'id', label: 'label.gtin' }, + ]; + + const columns = useColumns( + isAdmin ? [...columnDefs, 'selection'] : columnDefs + ); + + return ( + <> + {isAdmin && ( + <> + {isOpen && ( + + )} + + + onOpen()} + isLoading={false} + startIcon={} + > + {t('label.add-barcode')} + + + + + + { + await deleteGS1({ gtin: item.gtin }); + }} + /> + + + + )} + + } + pagination={pagination} + onChangePage={updatePaginationQuery} + /> + + ); +}; + +export const BarcodeList = (props: BarcodeListProps) => { + return ( + + + + ); +}; diff --git a/frontend/system/src/Entities/Barcodes/BarcodeListForEntityView.tsx b/frontend/system/src/Entities/Barcodes/BarcodeListForEntityView.tsx new file mode 100644 index 000000000..803df9991 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/BarcodeListForEntityView.tsx @@ -0,0 +1,45 @@ +import React, { useEffect } from 'react'; +import { useBreadcrumbs } from '@common/hooks'; +import { useParams } from 'react-router'; +import { BarcodeList } from './BarcodeList'; +import { useEntityWithBarcodes } from './api'; +import { AppBarContentPortal, Typography } from '@common/components'; +import { useTranslation } from '@common/intl'; +import { getBarcodes, getPackSizeCodes } from './helpers'; + +export const BarcodeListForEntityView = () => { + const t = useTranslation('system'); + const { code } = useParams(); + const { setSuffix } = useBreadcrumbs(); + + const { + data: entity, + isError, + isLoading, + } = useEntityWithBarcodes(code ?? ''); + + useEffect(() => { + setSuffix(t('label.details')); + }, []); + + if (!entity) return null; + + const barcodes = getBarcodes(entity); + const entityCodes = getPackSizeCodes(entity); + + return ( + <> + + + {entity.description} + + + + + ); +}; diff --git a/frontend/system/src/Entities/Barcodes/BarcodeListView.tsx b/frontend/system/src/Entities/Barcodes/BarcodeListView.tsx new file mode 100644 index 000000000..86f3d1b74 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/BarcodeListView.tsx @@ -0,0 +1,30 @@ +import { useQueryParamsState } from '@common/hooks'; +import React from 'react'; +import { useBarcodes } from './api'; +import { BarcodeList } from './BarcodeList'; + +export const BarcodeListView = () => { + const { queryParams, updatePaginationQuery } = useQueryParamsState(); + + const { data, isError, isLoading } = useBarcodes(queryParams); + + const barcodes = data?.data ?? []; + + const { page, first, offset } = queryParams; + const pagination = { + page, + offset, + first, + total: data?.totalCount, + }; + + return ( + + ); +}; diff --git a/frontend/system/src/Entities/Barcodes/api/hooks/index.ts b/frontend/system/src/Entities/Barcodes/api/hooks/index.ts new file mode 100644 index 000000000..72e177259 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useBarcodes'; +export * from './useAddBarcode'; +export * from './useDeleteBarcode'; +export * from './useEntityWithBarcode'; diff --git a/frontend/system/src/Entities/Barcodes/api/hooks/useAddBarcode.ts b/frontend/system/src/Entities/Barcodes/api/hooks/useAddBarcode.ts new file mode 100644 index 000000000..b1c0ee4f3 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/hooks/useAddBarcode.ts @@ -0,0 +1,21 @@ +import { useQueryClient, useGql, useMutation } from '@uc-frontend/common'; +import { + ENTITY_WITH_BARCODES_KEY, + BARCODES_KEY, +} from 'frontend/system/src/queryKeys'; +import { getSdk } from '../operations.generated'; + +export const useAddBarcode = () => { + const { client } = useGql(); + const sdk = getSdk(client); + const queryClient = useQueryClient(); + + const invalidateQueries = () => { + queryClient.invalidateQueries([BARCODES_KEY]); + queryClient.invalidateQueries([ENTITY_WITH_BARCODES_KEY]); + }; + + return useMutation(sdk.AddBarcode, { + onSettled: invalidateQueries, + }); +}; diff --git a/frontend/system/src/Entities/Barcodes/api/hooks/useBarcodes.ts b/frontend/system/src/Entities/Barcodes/api/hooks/useBarcodes.ts new file mode 100644 index 000000000..594c92a23 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/hooks/useBarcodes.ts @@ -0,0 +1,20 @@ +import { useGql, useQuery } from 'frontend/common/src'; +import { BARCODES_KEY } from '../../../../queryKeys'; +import { getSdk } from '../operations.generated'; + +export const useBarcodes = ({ + first, + offset, +}: { + first: number; + offset: number; +}) => { + const { client } = useGql(); + const sdk = getSdk(client); + const cacheKeys = [BARCODES_KEY, first, offset]; + + return useQuery(cacheKeys, async () => { + const response = await sdk.Barcodes({ first, offset }); + return response?.barcodes; + }); +}; diff --git a/frontend/system/src/Entities/Barcodes/api/hooks/useDeleteBarcode.ts b/frontend/system/src/Entities/Barcodes/api/hooks/useDeleteBarcode.ts new file mode 100644 index 000000000..400b12302 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/hooks/useDeleteBarcode.ts @@ -0,0 +1,19 @@ +import { useGql, useMutation, useQueryClient } from '@uc-frontend/common'; +import { + BARCODES_KEY, + ENTITY_WITH_BARCODES_KEY, +} from 'frontend/system/src/queryKeys'; +import { getSdk } from '../operations.generated'; + +export const useDeleteBarcode = () => { + const { client } = useGql(); + const sdk = getSdk(client); + const queryClient = useQueryClient(); + + return useMutation(sdk.DeleteBarcode, { + onSettled: () => { + queryClient.invalidateQueries(BARCODES_KEY); + queryClient.invalidateQueries(ENTITY_WITH_BARCODES_KEY); + }, + }); +}; diff --git a/frontend/system/src/Entities/Barcodes/api/hooks/useEntityWithBarcode.ts b/frontend/system/src/Entities/Barcodes/api/hooks/useEntityWithBarcode.ts new file mode 100644 index 000000000..be20c3ef8 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/hooks/useEntityWithBarcode.ts @@ -0,0 +1,15 @@ +import { useGql, useQuery } from '@uc-frontend/common'; +import { ENTITY_WITH_BARCODES_KEY } from 'frontend/system/src/queryKeys'; +import { getSdk } from '../operations.generated'; + +export const useEntityWithBarcodes = (code: string) => { + const { client } = useGql(); + const sdk = getSdk(client); + + const cacheKeys = [ENTITY_WITH_BARCODES_KEY, code]; + + return useQuery(cacheKeys, async () => { + const response = await sdk.entityWithBarcodes({ code }); + return response?.entity; + }); +}; diff --git a/frontend/system/src/Entities/Barcodes/api/index.ts b/frontend/system/src/Entities/Barcodes/api/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/frontend/system/src/Entities/Barcodes/api/operations.generated.ts b/frontend/system/src/Entities/Barcodes/api/operations.generated.ts new file mode 100644 index 000000000..fc2c73640 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/operations.generated.ts @@ -0,0 +1,123 @@ +import * as Types from '@uc-frontend/common'; + +import { GraphQLClient } from 'graphql-request'; +import * as Dom from 'graphql-request/dist/types.dom'; +import gql from 'graphql-tag'; +export type BarcodeFragment = { __typename?: 'BarcodeNode', id: string, gtin: string, manufacturer: string, entity: { __typename?: 'EntityType', code: string, name: string, description: string } }; + +export type BarcodesQueryVariables = Types.Exact<{ + first?: Types.InputMaybe; + offset?: Types.InputMaybe; +}>; + + +export type BarcodesQuery = { __typename?: 'FullQuery', barcodes: { __typename?: 'BarcodeCollectionConnector', totalCount: number, data: Array<{ __typename?: 'BarcodeNode', id: string, gtin: string, manufacturer: string, entity: { __typename?: 'EntityType', code: string, name: string, description: string } }> } }; + +export type AddBarcodeMutationVariables = Types.Exact<{ + input: Types.AddBarcodeInput; +}>; + + +export type AddBarcodeMutation = { __typename?: 'FullMutation', addBarcode: { __typename?: 'BarcodeNode', id: string, gtin: string, manufacturer: string, entity: { __typename?: 'EntityType', code: string, name: string, description: string } } }; + +export type DeleteBarcodeMutationVariables = Types.Exact<{ + gtin: Types.Scalars['String']['input']; +}>; + + +export type DeleteBarcodeMutation = { __typename?: 'FullMutation', deleteBarcode: number }; + +export type EntityWithBarcodesFragment = { __typename?: 'EntityType', code: string, name: string, description: string, type: string, barcodes: Array<{ __typename?: 'BarcodeType', id: string, gtin: string, manufacturer: string }> }; + +export type EntityWithBarcodesQueryVariables = Types.Exact<{ + code: Types.Scalars['String']['input']; +}>; + + +export type EntityWithBarcodesQuery = { __typename?: 'FullQuery', entity?: { __typename?: 'EntityType', code: string, name: string, description: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, description: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, description: string, type: string, barcodes: Array<{ __typename?: 'BarcodeType', id: string, gtin: string, manufacturer: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string, gtin: string, manufacturer: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string, gtin: string, manufacturer: string }> } | null }; + +export const BarcodeFragmentDoc = gql` + fragment Barcode on BarcodeNode { + id + gtin + manufacturer + entity { + code + name + description + } +} + `; +export const EntityWithBarcodesFragmentDoc = gql` + fragment EntityWithBarcodes on EntityType { + code + name + description + type + barcodes { + id + gtin + manufacturer + } +} + `; +export const BarcodesDocument = gql` + query Barcodes($first: Int, $offset: Int) { + barcodes(first: $first, offset: $offset) { + ... on BarcodeCollectionConnector { + data { + ...Barcode + } + totalCount + } + } +} + ${BarcodeFragmentDoc}`; +export const AddBarcodeDocument = gql` + mutation AddBarcode($input: AddBarcodeInput!) { + addBarcode(input: $input) { + ...Barcode + } +} + ${BarcodeFragmentDoc}`; +export const DeleteBarcodeDocument = gql` + mutation DeleteBarcode($gtin: String!) { + deleteBarcode(gtin: $gtin) +} + `; +export const EntityWithBarcodesDocument = gql` + query entityWithBarcodes($code: String!) { + entity(code: $code) { + ...EntityWithBarcodes + children { + ...EntityWithBarcodes + children { + ...EntityWithBarcodes + } + } + } +} + ${EntityWithBarcodesFragmentDoc}`; + +export type SdkFunctionWrapper = (action: (requestHeaders?:Record) => Promise, operationName: string, operationType?: string) => Promise; + + +const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationType) => action(); + +export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { + return { + Barcodes(variables?: BarcodesQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(BarcodesDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'Barcodes', 'query'); + }, + AddBarcode(variables: AddBarcodeMutationVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(AddBarcodeDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'AddBarcode', 'mutation'); + }, + DeleteBarcode(variables: DeleteBarcodeMutationVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(DeleteBarcodeDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'DeleteBarcode', 'mutation'); + }, + entityWithBarcodes(variables: EntityWithBarcodesQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(EntityWithBarcodesDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'entityWithBarcodes', 'query'); + } + }; +} +export type Sdk = ReturnType; \ No newline at end of file diff --git a/frontend/system/src/Entities/Barcodes/api/operations.graphql b/frontend/system/src/Entities/Barcodes/api/operations.graphql new file mode 100644 index 000000000..ee06b0a44 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/api/operations.graphql @@ -0,0 +1,55 @@ +fragment Barcode on BarcodeNode { + id + gtin + manufacturer + entity { + code + name + description + } +} + +query Barcodes($first: Int, $offset: Int) { + barcodes(first: $first, offset: $offset) { + ... on BarcodeCollectionConnector { + data { + ...Barcode + } + totalCount + } + } +} + +mutation AddBarcode($input: AddBarcodeInput!) { + addBarcode(input: $input) { + ...Barcode + } +} + +mutation DeleteBarcode($gtin: String!) { + deleteBarcode(gtin: $gtin) +} + +fragment EntityWithBarcodes on EntityType { + code + name + description + type + barcodes { + id + gtin + manufacturer + } +} + +query entityWithBarcodes($code: String!) { + entity(code: $code) { + ...EntityWithBarcodes + children { + ...EntityWithBarcodes + children { + ...EntityWithBarcodes + } + } + } +} diff --git a/frontend/system/src/Entities/Barcodes/helpers.test.ts b/frontend/system/src/Entities/Barcodes/helpers.test.ts new file mode 100644 index 000000000..952cd165d --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/helpers.test.ts @@ -0,0 +1,196 @@ +import { EntityType } from '../../constants'; +import { + getParentDescription, + getPackSizeCodes, + EntityWithBarcodes, + getBarcodes, +} from './helpers'; + +describe('getParentDescription', () => { + it('returns full description when name doesnt match', () => { + expect( + getParentDescription({ description: 'some description', name: 'name' }) + ).toEqual('some description'); + }); + + it('returns description with name removed', () => { + expect( + getParentDescription({ + description: 'some description name', + name: 'name', + }) + ).toEqual('some description'); + }); + + it('returns description correctly when name is also in the middle', () => { + expect( + getParentDescription({ + description: 'description name bla name', + name: 'name', + }) + ).toEqual('description name bla'); + }); +}); + +describe('getPackSizeCodes', () => { + it('returns an empty array when entity type is not PackSize', () => { + const entity: EntityWithBarcodes = { + type: EntityType.ActiveIngredients, + code: '123', + name: '123', + description: '123', + barcodes: [], + children: [ + { + type: EntityType.Brand, + code: '456', + name: '456', + description: '456', + barcodes: [], + children: [ + { + type: EntityType.Strength, + code: '789', + name: '789', + description: '789', + barcodes: [], + }, + ], + }, + ], + }; + + expect(getPackSizeCodes(entity)).toEqual([]); + }); + + it('returns an array with the entity code which entity type is PackSize', () => { + const entity: EntityWithBarcodes = { + type: EntityType.PackSize, + code: '123', + name: '123', + description: '123', + barcodes: [], + }; + + expect(getPackSizeCodes(entity)).toEqual(['123']); + }); + + it('returns an array with the child entity codes where entity type is PackSize', () => { + const entity: EntityWithBarcodes = { + type: EntityType.ImmediatePackaging, + code: '123', + name: '123', + description: '123', + barcodes: [], + children: [ + { + type: EntityType.PackSize, + code: '456', + name: '456', + description: '456', + barcodes: [], + }, + { + type: EntityType.PackSize, + code: '789', + name: '789', + description: '789', + barcodes: [], + }, + ], + }; + + expect(getPackSizeCodes(entity)).toEqual(['456', '789']); + }); +}); + +describe('getBarcodes', () => { + it('returns an empty array when entity has no barcodes', () => { + const entity: EntityWithBarcodes = { + type: EntityType.Unit, + code: '123', + name: '123', + description: '123', + barcodes: [], + }; + + expect(getBarcodes(entity)).toEqual([]); + }); + + it('returns an array with the entity barcodes', () => { + const entity: EntityWithBarcodes = { + type: EntityType.PackSize, + code: '123', + name: '123', + description: '123', + barcodes: [ + { id: '1234567890', gtin: '1234567890', manufacturer: 'X' }, + { id: '0987654321', gtin: '0987654321', manufacturer: 'Y' }, + ], + }; + + expect(getBarcodes(entity)).toEqual([ + { id: '1234567890', gtin: '1234567890', manufacturer: 'X', entity }, + { id: '0987654321', gtin: '0987654321', manufacturer: 'Y', entity }, + ]); + }); + + it('returns an array with the entity and child barcodes', () => { + const entity = { + type: EntityType.ImmediatePackaging, + code: '123', + name: '123', + description: '123', + barcodes: [], + children: [ + { + type: EntityType.PackSize, + code: '456', + name: '456', + description: '456', + barcodes: [ + { id: '1234567890', gtin: '1234567890', manufacturer: 'X' }, + { id: '0987654321', gtin: '0987654321', manufacturer: 'Y' }, + ], + }, + { + type: EntityType.PackSize, + code: '789', + name: '789', + description: '789', + barcodes: [ + { id: '1111111111', gtin: '1111111111', manufacturer: 'X' }, + { id: '2222222222', gtin: '2222222222', manufacturer: 'Y' }, + ], + }, + ], + }; + + expect(getBarcodes(entity)).toEqual([ + { + id: '1234567890', + gtin: '1234567890', + manufacturer: 'X', + entity: entity.children[0], + }, + { + id: '0987654321', + gtin: '0987654321', + manufacturer: 'Y', + entity: entity.children[0], + }, + { + id: '1111111111', + gtin: '1111111111', + manufacturer: 'X', + entity: entity.children[1], + }, + { + id: '2222222222', + gtin: '2222222222', + manufacturer: 'Y', + entity: entity.children[1], + }, + ]); + }); +}); diff --git a/frontend/system/src/Entities/Barcodes/helpers.ts b/frontend/system/src/Entities/Barcodes/helpers.ts new file mode 100644 index 000000000..824e03bea --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/helpers.ts @@ -0,0 +1,64 @@ +import { EntityType } from '../../constants'; +import { + BarcodeFragment, + EntityWithBarcodesFragment, +} from './api/operations.generated'; + +export type EntityWithBarcodes = EntityWithBarcodesFragment & { + children?: EntityWithBarcodes[]; +}; + +export const getParentDescription = ({ + description, + name, +}: { + description: string; + name: string; +}) => { + const nameIndex = description.lastIndexOf(name); + if (nameIndex === -1) return description; + return description.substring(0, nameIndex).trim(); +}; + +export const getPackSizeCodes = (entity?: EntityWithBarcodes | null) => { + const packSizeCodes: string[] = []; + + if (!entity) return packSizeCodes; + + if (entity.type === EntityType.PackSize) { + packSizeCodes.push(entity.code); + } + + const addChildCodes = (e: EntityWithBarcodes) => { + e.children?.forEach(c => { + if (c.type === EntityType.PackSize) { + packSizeCodes.push(c.code); + } + addChildCodes(c); + }); + }; + + addChildCodes(entity); + return packSizeCodes; +}; + +// Barcodes for entity and all its children (though there should only be barcodes on PackSize entities, the lowest level...) +export const getBarcodes = (entity: EntityWithBarcodes) => { + const barcodes: Omit[] = []; + + const addBarcodes = (e: EntityWithBarcodes) => + e.barcodes.forEach(b => barcodes.push({ ...b, entity: e })); + + addBarcodes(entity); + + const addChildBarcodes = (e: EntityWithBarcodes) => { + e.children?.forEach(c => { + addBarcodes(c); + addChildBarcodes(c); + }); + }; + + addChildBarcodes(entity); + + return barcodes; +}; diff --git a/frontend/system/src/Entities/Barcodes/index.ts b/frontend/system/src/Entities/Barcodes/index.ts new file mode 100644 index 000000000..b96ae04d1 --- /dev/null +++ b/frontend/system/src/Entities/Barcodes/index.ts @@ -0,0 +1,2 @@ +export * from './BarcodeListView'; +export * from './BarcodeListForEntityView'; diff --git a/frontend/system/src/Entities/EntityData.tsx b/frontend/system/src/Entities/EntityData.tsx new file mode 100644 index 000000000..791f99bcf --- /dev/null +++ b/frontend/system/src/Entities/EntityData.tsx @@ -0,0 +1,5 @@ +import { EntityDetailsFragment } from './api/operations.generated'; + +export type EntityData = EntityDetailsFragment & { + children?: EntityData[] | null; +}; diff --git a/frontend/system/src/Entities/EntityDetails.tsx b/frontend/system/src/Entities/EntityDetails.tsx index 36f82ba8a..c486ec74e 100644 --- a/frontend/system/src/Entities/EntityDetails.tsx +++ b/frontend/system/src/Entities/EntityDetails.tsx @@ -13,11 +13,19 @@ import { useProduct } from './api'; import { FormControlLabel, Typography } from '@mui/material'; import { TreeView } from '@mui/lab'; import { useNavigate, useParams } from 'react-router-dom'; -import { EntityTreeItem, EntityData } from './EntityTreeItem'; +import { EntityTreeItem } from './EntityTreeItem'; +import { EntityData } from './EntityData'; import { RouteBuilder } from '@common/utils'; import { AppRoute } from 'frontend/config/src'; import { useAuthContext } from '@common/authentication'; import { PermissionNode } from '@common/types'; +import { EntityType } from '../constants'; + +const TYPES_TO_COLLAPSE = [ + EntityType.Unit, + EntityType.ImmediatePackaging, + EntityType.PackSize, +]; export const EntityDetails = () => { const t = useTranslation('system'); @@ -39,7 +47,7 @@ export const EntityDetails = () => { const expandedIds: string[] = []; const addToExpandedIds = (ent?: EntityData | null) => { - if (ent) { + if (ent && !TYPES_TO_COLLAPSE.includes(ent.type as EntityType)) { expandedIds.push(ent.code); ent.children?.forEach(addToExpandedIds); } diff --git a/frontend/system/src/Entities/EntityTreeItem.tsx b/frontend/system/src/Entities/EntityTreeItem.tsx index 8f5338623..98dde094f 100644 --- a/frontend/system/src/Entities/EntityTreeItem.tsx +++ b/frontend/system/src/Entities/EntityTreeItem.tsx @@ -4,12 +4,10 @@ import { CopyIcon, FlatButton } from '@common/ui'; import { useNotification } from '@common/hooks'; import { Box, Link, Typography } from '@mui/material'; import { TreeItem } from '@mui/lab'; -import { EntityDetailsFragment } from './api/operations.generated'; import { usePropertyConfigurationItems } from '../Admin/Configuration/api/hooks/usePropertyConfigurationItems'; - -export type EntityData = EntityDetailsFragment & { - children?: EntityData[] | null; -}; +import { EntityType } from '../constants'; +import { EntityData } from './EntityData'; +import { BarcodeLink } from './BarcodeLink'; export const EntityTreeItem = ({ entity, @@ -40,7 +38,13 @@ export const EntityTreeItem = ({ }; const isLeaf = !entity.children?.length; - const showCode = showAllCodes || isLeaf || highlightCode === entity.code; + const showCode = + showAllCodes || + isLeaf || + highlightCode === entity.code || + // mSupply users will usually want these codes: + entity.type === EntityType.Strength || + entity.type === EntityType.Unit; // use default chevron icons, unless we're looking at a leaf node with no properties const customIcons = @@ -86,6 +90,8 @@ export const EntityTreeItem = ({ )} + + } > diff --git a/frontend/system/src/Entities/Service.tsx b/frontend/system/src/Entities/Service.tsx index 3125dd3af..c31aec8c6 100644 --- a/frontend/system/src/Entities/Service.tsx +++ b/frontend/system/src/Entities/Service.tsx @@ -2,11 +2,18 @@ import React from 'react'; import { Routes, Route } from '@uc-frontend/common'; import { ListView } from './ListView'; import { EntityDetails } from './EntityDetails'; +import { BarcodeListView, BarcodeListForEntityView } from './Barcodes'; +import { AppRoute } from 'frontend/config/src'; const EntitiesService = () => { return ( } /> + } /> + } + /> } /> ); diff --git a/frontend/system/src/Entities/api/operations.generated.ts b/frontend/system/src/Entities/api/operations.generated.ts index bf756c1f0..059cecc04 100644 --- a/frontend/system/src/Entities/api/operations.generated.ts +++ b/frontend/system/src/Entities/api/operations.generated.ts @@ -3,9 +3,9 @@ import * as Types from '@uc-frontend/common'; import { GraphQLClient } from 'graphql-request'; import * as Dom from 'graphql-request/dist/types.dom'; import gql from 'graphql-tag'; -export type EntityRowFragment = { __typename?: 'EntityType', type: string, description: string, code: string, id: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', name: string }> }; +export type EntityRowFragment = { __typename?: 'EntityType', type: string, description: string, code: string, name: string, id: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', name: string }> }; -export type EntityDetailsFragment = { __typename?: 'EntityType', code: string, name: string, type: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }; +export type EntityDetailsFragment = { __typename?: 'EntityType', code: string, name: string, type: string, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }; export type EntitiesQueryVariables = Types.Exact<{ filter: Types.EntitySearchInput; @@ -14,21 +14,21 @@ export type EntitiesQueryVariables = Types.Exact<{ }>; -export type EntitiesQuery = { __typename?: 'FullQuery', entities: { __typename?: 'EntityCollectionType', totalLength: number, data: Array<{ __typename?: 'EntityType', type: string, description: string, code: string, id: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', name: string }> }> } }; +export type EntitiesQuery = { __typename?: 'FullQuery', entities: { __typename?: 'EntityCollectionType', totalLength: number, data: Array<{ __typename?: 'EntityType', type: string, description: string, code: string, name: string, id: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', name: string }> }> } }; export type EntityQueryVariables = Types.Exact<{ code: Types.Scalars['String']['input']; }>; -export type EntityQuery = { __typename?: 'FullQuery', entity?: { __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> } | null }; +export type EntityQuery = { __typename?: 'FullQuery', entity?: { __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> } | null }; export type ProductQueryVariables = Types.Exact<{ code: Types.Scalars['String']['input']; }>; -export type ProductQuery = { __typename?: 'FullQuery', product?: { __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> } | null }; +export type ProductQuery = { __typename?: 'FullQuery', product?: { __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, children: Array<{ __typename?: 'EntityType', code: string, name: string, type: string, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> }>, barcodes: Array<{ __typename?: 'BarcodeType', id: string }>, alternativeNames: Array<{ __typename?: 'AlternativeNameType', code: string, name: string }>, properties: Array<{ __typename?: 'PropertiesType', code: string, type: string, value: string }> } | null }; export const EntityRowFragmentDoc = gql` fragment EntityRow on EntityType { @@ -36,6 +36,7 @@ export const EntityRowFragmentDoc = gql` type description code + name alternativeNames { name } @@ -46,6 +47,9 @@ export const EntityDetailsFragmentDoc = gql` code name type + barcodes { + id + } alternativeNames { code name diff --git a/frontend/system/src/Entities/api/operations.graphql b/frontend/system/src/Entities/api/operations.graphql index 75405bc07..f9715ead0 100644 --- a/frontend/system/src/Entities/api/operations.graphql +++ b/frontend/system/src/Entities/api/operations.graphql @@ -3,6 +3,7 @@ fragment EntityRow on EntityType { type description code + name alternativeNames { name } @@ -12,6 +13,9 @@ fragment EntityDetails on EntityType { code name type + barcodes { + id + } alternativeNames { code name diff --git a/frontend/system/src/queryKeys.ts b/frontend/system/src/queryKeys.ts index bfccb11fb..8a7bf42e4 100644 --- a/frontend/system/src/queryKeys.ts +++ b/frontend/system/src/queryKeys.ts @@ -3,3 +3,5 @@ export const ENTITIES_KEY = 'ENTITIES'; export const PENDING_CHANGE_KEY = 'PENDING_CHANGE'; export const PENDING_CHANGES_KEY = 'PENDING_CHANGES'; export const PROPERTY_CONFIG_ITEMS_KEY = 'PROPERTY_CONFIG_ITEMS'; +export const BARCODES_KEY = 'BARCODES'; +export const ENTITY_WITH_BARCODES_KEY = 'ENTITY_WITH_BARCODES';