diff --git a/specifyweb/frontend/js_src/lib/components/FormCommands/ShowTransactions.tsx b/specifyweb/frontend/js_src/lib/components/FormCommands/ShowTransactions.tsx index 242a47bd9f6..b7808b2df2e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCommands/ShowTransactions.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCommands/ShowTransactions.tsx @@ -1,71 +1,35 @@ import React from 'react'; -import { useAsyncState } from '../../hooks/useAsyncState'; +import { useMultipleAsyncState } from '../../hooks/useAsyncState'; +import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; -import { f } from '../../utils/functools'; -import type { RA } from '../../utils/types'; -import { sortFunction } from '../../utils/utils'; -import { H3, Ul } from '../Atoms'; +import type { RA, RR } from '../../utils/types'; +import { H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; -import { Link } from '../Atoms/Link'; -import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; -import type { AnySchema } from '../DataModel/helperTypes'; +import { formatNumber } from '../Atoms/Internationalization'; +import type { CollectionFetchFilters } from '../DataModel/collection'; +import { fetchCollection } from '../DataModel/collection'; +import { backendFilter, formatRelationshipPath } from '../DataModel/helpers'; +import type { + AnyInteractionPreparation, + SerializedResource, +} from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { deserializeResource } from '../DataModel/serializers'; +import type { SpecifyTable } from '../DataModel/specifyTable'; import { tables } from '../DataModel/tables'; -import type { Preparation } from '../DataModel/types'; +import type { Preparation, Tables } from '../DataModel/types'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import type { InteractionWithPreps } from '../Interactions/helpers'; +import { + interactionPrepTables, + interactionsWithPrepTables, +} from '../Interactions/helpers'; import { Dialog } from '../Molecules/Dialog'; -import { ResourceLink } from '../Molecules/ResourceLink'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; -function List({ - resources, - fieldName, - displayFieldName, -}: { - readonly resources: RA>; - readonly fieldName: string; - readonly displayFieldName: string; -}): JSX.Element { - const [entries] = useAsyncState( - React.useCallback(async () => { - const interactions: RA> = await Promise.all( - resources.map(async (resource) => resource.rgetPromise(fieldName)) - ); - return interactions - .map((resource) => ({ - label: resource.get(displayFieldName), - resource, - })) - .sort(sortFunction(({ label }) => label)); - }, [resources, fieldName, displayFieldName]), - false - ); - - return resources.length === 0 ? ( - <>{commonText.noResults()} - ) : Array.isArray(entries) ? ( - - ) : ( - <>{commonText.loading()} - ); -} - export function ShowLoansCommand({ preparation, onClose: handleClose, @@ -73,101 +37,155 @@ export function ShowLoansCommand({ readonly preparation: SpecifyResource; readonly onClose: () => void; }): JSX.Element | null { - const [data] = useAsyncState( - React.useCallback( - async () => - f.all({ - openLoans: hasTablePermission('LoanPreparation', 'read') - ? fetchCollection('LoanPreparation', { - isResolved: false, - limit: DEFAULT_FETCH_LIMIT, - preparation: preparation.get('id'), - domainFilter: false, - }).then(({ records }) => records.map(deserializeResource)) - : undefined, - resolvedLoans: hasTablePermission('LoanPreparation', 'read') - ? fetchCollection('LoanPreparation', { - isResolved: true, - limit: DEFAULT_FETCH_LIMIT, - preparation: preparation.get('id'), - domainFilter: false, - }).then(({ records }) => records.map(deserializeResource)) - : undefined, - gifts: hasTablePermission('GiftPreparation', 'read') - ? fetchCollection('GiftPreparation', { - limit: DEFAULT_FETCH_LIMIT, - preparation: preparation.get('id'), - domainFilter: false, - }).then(({ records }) => records.map(deserializeResource)) - : undefined, - exchanges: hasTablePermission('ExchangeOutPrep', 'read') - ? fetchCollection('ExchangeOutPrep', { - limit: DEFAULT_FETCH_LIMIT, - preparation: preparation.get('id'), - domainFilter: false, - }).then(({ records }) => records.map(deserializeResource)) - : undefined, - }), - [preparation] + const accessibleInteractionTables = React.useMemo( + () => + interactionsWithPrepTables.filter((interactionTable) => + hasTablePermission(interactionTable, 'read') + ), + [] + ); + + const [relatedInteractions] = useMultipleAsyncState< + RR> + >( + React.useMemo( + () => + Object.fromEntries( + accessibleInteractionTables.map((interactionTable) => [ + interactionTable, + async () => + fetchRelatedInterations(preparation, interactionTable).then( + (records) => records.map(({ id }) => id) + ), + ]) + ), + [preparation, accessibleInteractionTables] ), - true + false ); - return typeof data === 'object' ? ( + return ( -

- - {interactionsText.openLoans({ - loanTable: tables.Loan.label, - })} -

- -

- - {interactionsText.resolvedLoans({ - loanTable: tables.Loan.label, - })} -

- -

- - {interactionsText.gifts({ - giftTable: tables.Gift.label, - })} -

- - {Array.isArray(data.exchanges) && data.exchanges.length > 0 && ( + {relatedInteractions === undefined + ? commonText.loading() + : accessibleInteractionTables.length === + Object.keys(relatedInteractions).length && + Object.values(relatedInteractions).every( + (relatedIds) => + Array.isArray(relatedIds) && relatedIds.length === 0 + ) + ? interactionsText.noInteractions() + : accessibleInteractionTables + .map( + (interactionTable) => + [ + interactionTable, + relatedInteractions[interactionTable], + ] as const + ) + .map(([interactionTable, relatedIds], index) => ( + + ))} +
+ ); +} +function InterationWithPreps({ + tableName, + relatedInteractionIds, +}: { + readonly tableName: InteractionWithPreps['tableName']; + readonly relatedInteractionIds: RA | undefined; +}): JSX.Element | null { + const [isOpen, handleOpen, handleClose, _] = useBooleanState(false); + + return ( + <> + {relatedInteractionIds === undefined ? ( <>

- {interactionsText.exchanges({ - exhangeInTable: tables.ExchangeIn.label, - exhangeOutTable: tables.ExchangeOut.label, - })} +
+ + + {interactionsText.tableLabelRecords({ + tableLabel: tables[tableName].label, + })} +

- + {commonText.loading()} + ) : relatedInteractionIds.length === 0 ? null : ( + +
+ + + {`${interactionsText.tableLabelRecords({ + tableLabel: tables[tableName].label, + })} (${formatNumber(relatedInteractionIds.length)})`} +
+
)} - - ) : null; + {isOpen && Array.isArray(relatedInteractionIds) ? ( + undefined} + onSlide={undefined} + /> + ) : null} + + ); +} + +async function fetchRelatedInterations< + INTERACTION_TABLE extends InteractionWithPreps['tableName'], +>( + preparation: SpecifyResource, + interactionTable: INTERACTION_TABLE +): Promise>> { + const preparationField = tables[interactionTable].relationships.find( + (relationship) => + interactionPrepTables.includes( + relationship.relatedTable.name as AnyInteractionPreparation['tableName'] + ) + ); + + return fetchCollection(interactionTable, { + ...backendFilter( + formatRelationshipPath(preparationField!.name, 'preparation') + ).equals(preparation.get('id')), + domainFilter: false, + limit: 0, + } as CollectionFetchFilters).then( + ({ records }) => { + /** + * If there are multiple InteractionPreparations in an Interaction that + * reference the same Preparation, remove the duplicated Interaction + * records from response + */ + const recordIds: Record = {}; + return records.filter(({ id }) => { + if (recordIds[id]) return false; + recordIds[id] = true; + return true; + }); + } + ); } diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index c08a1e144b9..55db88594a8 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -575,19 +575,37 @@ export function InteractionDialog({
{state.missing.length > 0 && (
-

{interactionsText.preparationsNotFoundFor()}

+

+ {interactionsText.preparationsNotFoundFor()} +

{state.missing.map((problem, index) => (

{problem}

))}
)} - {state.unavailableBis.length > 0 && ( -
-

{interactionsText.preparationsNotAvailableFor()}

- {state.unavailableBis.map((problem, index) => ( -

{problem}

- ))} -
+ {state.type === 'MissingState' && ( + <> + {state.missing.length > 0 && ( + <> +

+ {interactionsText.preparationsNotFoundFor()} +

+ {state.missing.map((problem, index) => ( +

{problem}

+ ))} + + )} + {state.unavailableBis.length > 0 && ( + <> +

+ {interactionsText.preparationsNotAvailableFor()} +

+ {state.unavailableBis.map((problem, index) => ( +

{problem}

+ ))} + + )} + )}
)} diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx index df0ec7b2e49..f1a6d031b68 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/LoanReturn.tsx @@ -11,7 +11,6 @@ import { replaceItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Form, Input, Label } from '../Atoms/Form'; import { Submit } from '../Atoms/Submit'; -import { getField } from '../DataModel/helpers'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { tables } from '../DataModel/tables'; import type { Loan, LoanPreparation } from '../DataModel/types'; @@ -20,6 +19,7 @@ import { autoGenerateViewDefinition } from '../Forms/generateFormDefinition'; import { SpecifyForm } from '../Forms/SpecifyForm'; import { userInformation } from '../InitialContext/userInformation'; import { Dialog } from '../Molecules/Dialog'; +import { getField } from '../DataModel/helpers'; import { PrepReturnRow, updateResolvedChanged, diff --git a/specifyweb/frontend/js_src/lib/localization/interactions.ts b/specifyweb/frontend/js_src/lib/localization/interactions.ts index 851319a9fdd..02433d9a851 100644 --- a/specifyweb/frontend/js_src/lib/localization/interactions.ts +++ b/specifyweb/frontend/js_src/lib/localization/interactions.ts @@ -36,13 +36,27 @@ export const interactionsText = createDictionary({ 'de-ch': '{table:string} Rückkehr', 'pt-br': '{table:string} Retornar', }, + noInteractions: { + comment: 'Example: There are no interactions linked to this {preparation}', + 'en-us': 'There are no interactions linked to this preparation.', + }, + tableLabelRecords: { + comment: 'Example: Loan records', + 'en-us': '{tableLabel:string} records', + 'ru-ru': '{tableLabel:string} записи', + 'es-es': '{tableLabel:string} registros', + 'fr-fr': '{tableLabel:string} enregistrements', + 'uk-ua': '{tableLabel:string} записи', + 'de-ch': '{tableLabel:string} Datensätze', + 'pt-br': '{tableLabel:string} registros', + }, preparationsNotFoundFor: { 'en-us': 'No preparations were found for the following records:', 'de-ch': 'Für die folgenden Datensätze wurden keine Vorbereitungen gefunden:', 'es-es': 'No se encontraron preparaciones para los siguientes registros:', 'fr-fr': - "Aucune préparation n'a été trouvée pour les enregistrements suivants :", + "Aucune préparation n'a été trouvée pour les enregistrements suivants :", 'ru-ru': 'Не было обнаружено никаких подготовительных работ для следующих записей:', 'uk-ua': 'Для наступних записів не знайдено жодних підготовчих матеріалів:', @@ -54,9 +68,9 @@ export const interactionsText = createDictionary({ 'de-ch': 'Für mindestens eine Zubereitungsart sind in den folgenden Datensätzen keine Präparate verfügbar:', 'es-es': - 'No hay preparaciones disponibles para al menos un tipo de preparación en los siguientes registros:', + 'No hay preparaciones disponibles para al menos un tipo de preparación en los folgenden registros:', 'fr-fr': - "Aucune préparation n'est disponible pour au moins un type de préparation dans les enregistrements suivants :", + "Aucune préparation n'est disponible pour au moins un type de préparation dans les enregistrements suivants :", 'ru-ru': 'В следующих записях отсутствуют данные как минимум об одном из видов препаратов:', 'uk-ua': @@ -68,7 +82,7 @@ export const interactionsText = createDictionary({ 'en-us': 'There are problems with the entry:', 'ru-ru': 'В записи обнаружены ошибки:', 'es-es': 'Hay problemas con la entrada:', - 'fr-fr': 'Il y a des problèmes avec la saisie :', + 'fr-fr': 'Il y a des problèmes avec la saisie :', 'uk-ua': 'Є проблеми зі вступом:', 'de-ch': 'Es gibt Probleme mit dem Eintrag:', 'pt-br': 'Existem problemas com a entrada:', @@ -247,7 +261,7 @@ export const interactionsText = createDictionary({ 'uk-ua': 'Повернена сума', 'de-ch': 'Zurückgegebene Anzahl', 'pt-br': 'Valor devolvido', - }, + }, resolvedAmount: { 'en-us': 'Resolved Amount', 'ru-ru': 'Решенная сумма', @@ -262,52 +276,11 @@ export const interactionsText = createDictionary({ 'en-us': '{tableName:string}: {resource:string}', 'ru-ru': '{tableName:string}: {resource:string}', 'es-es': '{tableName:string}: {resource:string}', - 'fr-fr': '{tableName:string} : {resource:string}', + 'fr-fr': '{tableName:string} : {resource:string}', 'uk-ua': "{tableName:string}': {resource:string}", 'de-ch': '{tableName:string}: {resource:string}', 'pt-br': '{tableName:string}: {resource:string}', }, - resolvedLoans: { - comment: 'Example: Resolved Loan records', - 'en-us': 'Resolved {loanTable:string} records', - 'es-es': 'Registros {loanTable:string} resueltos', - 'fr-fr': 'Enregistrements {loanTable:string} résolus', - 'ru-ru': 'Разрешены записи {loanTable:string}', - 'uk-ua': 'Вирішено записів {loanTable:string}', - 'de-ch': 'Aufgelöste {loanTable:string}-Datensätze', - 'pt-br': 'Registros {loanTable:string} resolvidos', - }, - openLoans: { - comment: 'Example: Open Loan records', - 'en-us': 'Open {loanTable:string} records', - 'es-es': 'Abrir {loanTable:string} registros', - 'fr-fr': 'Ouvrir les enregistrements {loanTable:string}', - 'ru-ru': 'Открыть записи {loanTable:string}', - 'uk-ua': 'Відкрити записи {loanTable:string}', - 'de-ch': '{loanTable:string}-Datensätze öffnen', - 'pt-br': 'Abrir registros {loanTable:string}', - }, - gifts: { - comment: 'Example: Gift records', - 'en-us': '{giftTable:string} records', - 'es-es': '{giftTable:string} registros', - 'fr-fr': '{giftTable:string} enregistrements', - 'ru-ru': '{giftTable:string} записи', - 'uk-ua': '{giftTable:string} записи', - 'de-ch': '{giftTable:string}-Datensätze', - 'pt-br': '{giftTable:string} registros', - }, - exchanges: { - comment: 'Example: Exchange In / Exchnage Out records', - 'en-us': '{exhangeInTable:string} / {exhangeOutTable:string} records', - 'es-es': '{exhangeInTable:string} / {exhangeOutTable:string} registros', - 'fr-fr': - '{exhangeInTable:string} / {exhangeOutTable:string} enregistrements', - 'ru-ru': '{exhangeInTable:string} / {exhangeOutTable:string} записи', - 'uk-ua': 'Записи {exhangeInTable:string} / {exhangeOutTable:string}', - 'de-ch': '{exhangeInTable:string} / {exhangeOutTable:string} Datensätze', - 'pt-br': 'Registros {exhangeInTable:string} / {exhangeOutTable:string}', - }, unCataloged: { 'en-us': 'uncataloged', 'ru-ru': 'некаталогизированный', @@ -398,7 +371,7 @@ export const interactionsText = createDictionary({ 'Keiner dieser Datensätze enthält Vorbereitungen. Möchten Sie fortfahren?', 'es-es': 'Ninguno de estos discos tiene preparativos. ¿Quieres continuar?', 'fr-fr': - "Aucun de ces enregistrements n'est préparé. Souhaitez-vous continuer ?", + "Aucun de ces enregistrements n'est préparé. Souhaitez-vous continuer ?", 'pt-br': 'Nenhum desses registros possui preparativos. Deseja continuar?', 'ru-ru': 'Ни одна из этих записей не была подготовлена. Хотите продолжить?', 'uk-ua': 'Жоден із цих записів не має підготовки. Бажаєте продовжити?',