Commit ee297f3a authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '384-print-labels-with-template-selection' into 'main'

Print labels with template selection

Closes #384

See merge request grin-global/grin-global-ui!374
parents 5ae5ef72 4705b5eb
......@@ -34,6 +34,7 @@
"printLabel": "Print label",
"printLabel_plural": "Print labels",
"generatePdf": "Generate PDF document",
"sendToPrinter": "Send to printer",
"openActionList": "Open action list",
"previousPeriod": "< Previous period",
"nextPeriod": "Next period >",
......
......@@ -4,7 +4,7 @@ import propertySet from 'lodash/set';
import { IPageRequest, Page } from '@gringlobal-ce/client/model/page';
import _isArray from 'lodash/isArray';
import _isObject from 'lodash/isObject';
import { ApplicationService, ReportService } from '@gringlobal-ce/client/service';
import { ReportService } from '@gringlobal-ce/client/service';
import Mustache from 'mustache';
// import { isObject } from 'util';
......@@ -422,33 +422,31 @@ export async function loadAllPages<T>(that, method: (...args) => Promise<Page<T>
export const YesNoToBoolean = (value: string): boolean => value === 'Y';
export function printLabels(templateName: string, labelData: Iterable<unknown>): Promise<string> {
return ApplicationService.getSetting(templateName, 'LABEL')
.catch((err) => {
console.log('Error fetching label', err);
throw `Error fetching label template '${templateName}'`;
})
.then((labelTemplate) => {
console.log('Got template', labelTemplate);
let toPrint = '';
for (const data of labelData) {
toPrint += Mustache.render(labelTemplate, data) + '\n\n';
}
// console.log('Labels to print', toPrint);
const ua = window?.navigator?.userAgent.toLowerCase();
if (ua && ua.search(/android/) === -1) {
const printWindow = window.open('about:blank', 'LabelPrinter', 'width=500,height=300');
if (printWindow) {
printWindow.document.writeln('<plaintext>'); // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/plaintext
printWindow.document.write(toPrint);
printWindow.document.close();
printWindow.document.title = 'ZPL Labels';
printWindow.focus();
}
export function printLabels(labelData: Generator, options: { template: string, numberOfCopies?: number, isCollated?: boolean }): string {
console.log('Got template', options.template);
let itemsZpl = '';
for (const data of labelData) {
const oneZpl = Mustache.render(options.template, data) + '\n\n';
itemsZpl += oneZpl.repeat(options.isCollated ? 1 : options.numberOfCopies); // Add one copy if collated, or many copies if not collated
}
const toPrint = itemsZpl.repeat(options.isCollated ? options.numberOfCopies : 1); // Add multiple copies of ZPL
// console.log('Labels to print', toPrint);
const ua = window?.navigator?.userAgent.toLowerCase();
if (ua && ua.search(/android/) === -1) {
const printWindow = window.open('about:blank', 'LabelPrinter', 'width=500,height=300');
if (printWindow) {
printWindow.document.writeln('<plaintext>'); // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/plaintext
printWindow.document.write(toPrint);
printWindow.document.close();
printWindow.document.title = 'ZPL Labels';
printWindow.focus();
}
}
return toPrint;
});
return toPrint;
}
export function printPdfReport(entityPath: string, originalFilename: string, itemIds: number[]): Promise<string> {
......
......@@ -171,6 +171,13 @@
},
"public": {
"c": {
"printLabel": {
"noTemplateSelected": "Please select a template",
"selectTemplate": "Select template",
"numberOfCopies": "Number of copies",
"collated": "Collated",
"noTemplateFound": "No template found by name: {{templateName}}"
},
"generateReport": {
"noReportsFound": "No report templates in {{path}}",
"noReportSelected": "Please select a report template",
......
import * as React from 'react';
import { connect } from 'react-redux';
import { WithTranslation, withTranslation } from 'react-i18next';
import { bindActionCreators } from 'redux';
import { Button, withWidth, WithWidth } from '@material-ui/core';
import PrintLabelForm from 'common/PrintLabelForm';
import { printLabels } from "@gringlobal-ce/client/utilities";
import { showSnackbar } from "@gringlobal-ce/client/action/snackbar";
import { ApplicationService } from "@gringlobal-ce/client/service";
import { closeDialog } from "@gringlobal-ce/client/action/dialog";
interface IPrintLabelProps extends React.ClassAttributes<any>, WithTranslation, WithWidth {
labelsGenerator: (...args: any[]) => Generator;
showSnackbar: (message: string) => void;
templateName: string;
buttonTitle?: string;
generatorData?: any[]
dialogKey: string;
dialogKeyOpened: string;
closeDialog: () => void;
}
class PrintLabelDialog extends React.Component<IPrintLabelProps, any> {
public state = {
error: null,
androidIntent: null,
templates: null
}
public componentDidUpdate(prevProps: Readonly<IPrintLabelProps>) {
const { dialogKey, dialogKeyOpened } = this.props;
if (!prevProps.dialogKeyOpened && dialogKeyOpened === dialogKey) {
this.setState({ androidIntent: null })
this.checkMultiple()
}
}
private checkMultiple = () => {
const { templateName, showSnackbar, t } = this.props;
ApplicationService.appSettingsList().then((settings) => {
const labelTemplates = settings.content.filter((setting) => setting.categoryTag === 'LABEL' && setting.name.startsWith(templateName))
if (labelTemplates.length > 0) {
this.setState({ templates: labelTemplates })
return;
}
showSnackbar(t('public.c.printLabel.noTemplateFound', { templateName }))
}).catch((e) => {
this.setState({ error: e.data && e.data.error || e.toString() });
});
}
public handlePrintLabels = (options?) => {
const { showSnackbar, closeDialog, labelsGenerator, generatorData = [], t } = this.props;
const toPrint = printLabels(labelsGenerator(...generatorData), options)
// this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.browser_fallback_url=${encodeURIComponent('https://gitlab.croptrust.org/grin-global/ggce-printer-android')};S.label=${encodeURIComponent(res)};end` });
this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.label=${encodeURIComponent(toPrint)};end` });
showSnackbar(t('inventory.public.p.details.label.labelsReady'));
if (window?.navigator?.userAgent.toLowerCase().search(/android/) === -1) {
closeDialog()
}
}
private resetError = () => {
this.setState({ error: null })
}
public render() {
const { t, width, dialogKeyOpened, templateName, closeDialog } = this.props;
const { error, androidIntent, templates } = this.state;
return (
<PrintLabelForm
isOpen={ !!dialogKeyOpened }
onClose={ closeDialog }
onSubmit={ this.handlePrintLabels }
formId="print-label-form"
title={ t('common:action.printLabel_plural') }
submitButtonLabel={ t('common:action.printLabel_plural') }
size="md"
fullScreen={ width === 'xs' }
error={ error }
resetError={ this.resetError }
initialValues={ { numberOfCopies: 1, isCollated: false } }
templates={ templates }
templateName={ templateName }
additionalActions={
androidIntent && window?.navigator?.userAgent.toLowerCase().search(/android/) !== -1 && <Button variant="outlined" color="default" onClick={ closeDialog } href={ androidIntent }>{ t('common:action.sendToPrinter') }</Button>
}
/>
);
}
}
const mapStateToProps = (state) => ({
dialogKeyOpened: state.dialog.key,
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
showSnackbar,
closeDialog
}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(withTranslation()(withWidth()(PrintLabelDialog)));
import * as React from 'react';
import { connect } from 'react-redux';
import { Form, Field, FormProps, FormRenderProps } from 'react-final-form';
import { withTranslation, WithTranslation } from 'react-i18next';
// UI
import { Grid } from '@material-ui/core';
import { composeValidators, minValue, maxValue, required, validInteger } from '@gringlobal-ce/client/utilities/validators';
import { TextField } from "@gringlobal-ce/client/ui/common/form/TextField";
import { Toggle } from "@gringlobal-ce/client/ui/common/form/Toggle";
import withDialog from "ui/common/withDialog";
import RadioSelection from "@gringlobal-ce/client/ui/common/form/RadioSelection";
import { ApplicationService } from "@gringlobal-ce/client/service";
import { bindActionCreators } from "redux";
import { showSnackbar } from "@gringlobal-ce/client/action/snackbar";
import AppSetting from "@gringlobal-ce/client/model/gringlobal/AppSetting";
import { CodeValueDisplay } from './CodeValue';
const PrintLabelForm = ({ t, onSubmit, initialValues, templateName, templates, showSnackbar, error }:
{ showSnackbar: (msg) => void, onSubmit: (values) => void, templates?: AppSetting[], templateName?: string } & FormProps & WithTranslation) => {
const [ selectedTemplateId, setSelectedTemplateId ] = React.useState(null)
const [ settings, setSettings ] = React.useState(templates)
const [ internalError, setInternalError ] = React.useState(null)
React.useEffect(() => {
if (!settings && !templates && templateName) {
ApplicationService.appSettingsList().then((settings) => {
const labelTemplates = settings.content.filter((setting) => setting.categoryTag === 'LABEL' && setting.name.startsWith(templateName))
if (labelTemplates.length === 0) {
setInternalError(t('public.c.printLabel.noTemplateFound', { templateName }))
}
setSettings(labelTemplates)
}).catch((e) => {
console.log('Error getting templates', e)
showSnackbar(t('common:label.errorHappened'));
});
}
}, [templates, templateName])
React.useEffect(() => {
if (templates && !settings) {
setSettings(templates)
}
}, [templates])
const handleSubmit = (values) => {
if (!settings || settings?.length === 0) {
setInternalError(t('public.c.printLabel.noTemplateFound', { templateName }))
return
}
if (settings?.length > 1 && !selectedTemplateId) {
setInternalError(t('public.c.printLabel.noTemplateSelected'))
return
}
const selectedTemplate = settings?.length === 1 ? settings[0] : settings.find((setting) => +setting.id === +selectedTemplateId)
onSubmit({ ...values, template: selectedTemplate.value })
}
const renderLabel = (option) => {
const selectedSetting = settings?.find((setting) => +setting.id === +option)
return <CodeValueDisplay
codeGroup="BARCODE_LABEL"
description
value={ `${selectedSetting.name}${selectedSetting.sortOrder === undefined ? '' : `_${selectedSetting.sortOrder}`}` }
/>;
}
const selectTemplate = (e, val) => {
setSelectedTemplateId(val)
setInternalError(null)
}
return (
<Form
initialValues={ initialValues }
onSubmit={ handleSubmit }
>
{ (props: FormRenderProps) => (
<form onSubmit={ props.handleSubmit } id="print-label-form">
<Grid container spacing={ 4 }>
{ settings?.length > 1 &&
<Grid item xs={ 12 }>
<Field
name="template"
component={ RadioSelection }
label={ t(`public.c.printLabel.selectTemplate`) }
onChange={ selectTemplate }
options={ settings?.map((setting) => `${ setting.id }`) }
renderOptionLabel={ renderLabel }
/>
</Grid>
}
<Grid item xs={ 12 } sm={ 6 }>
<Field
name="numberOfCopies"
label={ t('public.c.printLabel.numberOfCopies') }
component={ TextField }
type="text"
required
validate={ composeValidators(required, validInteger, minValue(1), maxValue(4)) }
/>
</Grid>
<Grid item xs={ 12 } sm={ 6 }>
<Field
label={ t('public.c.printLabel.collated') }
name="isCollated"
component={ Toggle }
type="checkbox"
labelPlacement="end"
/>
</Grid>
{ (internalError || error) && <Grid item xs={ 12 } sm={ 12 } style={ { color: 'red' } }>{ internalError || error }</Grid> }
</Grid>
</form>
) }
</Form>
)
}
const mapDispatchToProps = (dispatch) => bindActionCreators({
showSnackbar,
}, dispatch);
export default connect(null, mapDispatchToProps)(withDialog(withTranslation()(PrintLabelForm)));
......@@ -70,7 +70,7 @@ class PrintReportDialog extends React.Component<IPrintReportDialogProps, any> {
}
public render() {
const { t, width, modelName, dialogKeyOpened, closeDialog } = this.props;
const { t, width, modelName, entityPath, dialogKeyOpened, closeDialog } = this.props;
const { error, reports } = this.state;
return (
......@@ -87,6 +87,7 @@ class PrintReportDialog extends React.Component<IPrintReportDialogProps, any> {
resetError={ this.resetError }
reportsLoaded={ reports }
modelName={ modelName }
entityPath={ entityPath }
/>
);
}
......
......@@ -25,7 +25,7 @@ const PrintReportForm = ({ t, onSubmit, initialValues, entityPath, reportsLoaded
if (!reports && !reportsLoaded && entityPath) {
ReportService.getReportList(entityPath).then((data) => {
if (data.length === 0) {
setInternalError({ error: t('public.c.generateReport.noReportsFound', { path: `/reports/${modelName}` }) })
setInternalError(t('public.c.generateReport.noReportsFound', { path: `/reports/${modelName}` }))
}
setReports(data)
}).catch((e) => {
......@@ -36,6 +36,10 @@ const PrintReportForm = ({ t, onSubmit, initialValues, entityPath, reportsLoaded
}, [entityPath, reportsLoaded])
const handleSubmit = () => {
if (!reports || reports?.length === 0) {
setInternalError(t('public.c.generateReport.noReportsFound', { path: `/reports/${modelName}` }))
return
}
if (reports?.length > 1 && !selectedReportId) {
setInternalError(t('public.c.generateReport.noReportSelected'))
return
......
......@@ -31,10 +31,9 @@ import InventoryActionDialog from 'inventory/ui/c/InventoryActionDialog';
import ScheduleOutlinedIcon from '@material-ui/icons/ScheduleOutlined';
import PrintIcon from '@material-ui/icons/Print';
import FABMenu from '@gringlobal-ce/client/ui/common/button/FABMenu';
import { printLabels } from '@gringlobal-ce/client/utilities';
import { showSnackbar } from '@gringlobal-ce/client/action/snackbar';
import { Button, Dialog, DialogActions } from '@material-ui/core';
import DialogTitle from '@material-ui/core/DialogTitle';
import PrintLabelDialog from "common/PrintLabelDialog";
import { showDialog } from "@gringlobal-ce/client/action/dialog";
export const InventoryTableDefaultConfig = {
defaultColumns: [
......@@ -92,9 +91,11 @@ export const InventoryTableDefaultConfig = {
const InventoryTableConfig = new TableConfiguration(InventoryTableDefaultConfig);
const templateName = "INVENTORYITEM";
const printDialogKey = "inventory-print-label-dialog"
class BrowsePage extends React.Component<PropsFromRedux & WithTranslation & WithBrowsePage> {
public state = {
androidIntent: null,
inventoryDialogIsOpen: false,
inventoryActionDialogIsOpen: false,
error: null,
......@@ -155,45 +156,27 @@ class BrowsePage extends React.Component<PropsFromRedux & WithTranslation & With
this.setState({ selected });
}
public handlePrintLabels = () => {
const { showSnackbar, t } = this.props;
const { selected } = this.state;
const templateName = "INVENTORYITEM";
// Print label for each inventory
/* Sample ZPL template:
^XA
^CF0,30
^FO50,10^FDInventory {{ inventory.id }}#{{ item }}^FS
^FO50,40^FDINV {{ inventory.inventoryNumber }}^FS
^FO50,90^FDACC {{ accession.accessionNumber }}^FS
^FO50,120^FD{{ taxon }}^FS
^FX Third section with bar code.
^FO50,150^BQN,2,6^FD{{ inventory.id }}#{{ item }}^FS
^XZ
*/
function* labelsGenerator() {
for (let item = 0; item < selected.length; item++) {
yield {
inventory: selected[item],
accession: selected[item].accession,
taxon: speciesToText(selected[item].accession.taxonomySpecies),
item: item + 1
};
}
// Print label for each inventory
/* Sample ZPL template:
^XA
^CF0,30
^FO50,10^FDInventory {{ inventory.id }}#{{ item }}^FS
^FO50,40^FDINV {{ inventory.inventoryNumber }}^FS
^FO50,90^FDACC {{ accession.accessionNumber }}^FS
^FO50,120^FD{{ taxon }}^FS
^FX Third section with bar code.
^FO50,150^BQN,2,6^FD{{ inventory.id }}#{{ item }}^FS
^XZ
*/
private static* labelsGenerator(selected) {
for (let item = 0; item < selected.length; item++) {
yield {
inventory: selected[item],
accession: selected[item].accession,
taxon: speciesToText(selected[item].accession.taxonomySpecies),
item: item + 1
};
}
printLabels(templateName, labelsGenerator())
.then((res) => {
// this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.browser_fallback_url=${encodeURIComponent('https://gitlab.croptrust.org/grin-global/ggce-printer-android')};S.label=${encodeURIComponent(res)};end` });
this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.label=${encodeURIComponent(res)};end` });
showSnackbar(t('viability.public.p.details.label.labelsReady'));
})
.catch((err) => {
console.log('Error', err);
showSnackbar(t('viability.public.p.details.label.labelError', { what: err.data?.error || err.toString() }));
});
}
private openInventoryActionDialog = () => {
......@@ -204,13 +187,9 @@ class BrowsePage extends React.Component<PropsFromRedux & WithTranslation & With
this.setState({ inventoryActionDialogIsOpen: false });
};
private closeLabelsPrintDialog = () => {
this.setState({ androidIntent: null });
};
public render() {
const { data, t, onSortChange, applyFilter, loadMore } = this.props;
const { inventoryDialogIsOpen, inventoryActionDialogIsOpen, error, selected, androidIntent } = this.state;
const { data, t, onSortChange, showDialog, applyFilter, loadMore } = this.props;
const { inventoryDialogIsOpen, inventoryActionDialogIsOpen, error, selected } = this.state;
const columns = InventoryTableConfig.getColumns(data && data.content ? data.content[0] : null);
const actions = [
......@@ -221,7 +200,7 @@ class BrowsePage extends React.Component<PropsFromRedux & WithTranslation & With
},
{
title: 'common:action.printLabel',
action: this.handlePrintLabels,
action: () => showDialog(printDialogKey),
icon: <PrintIcon/>,
},
]
......@@ -274,29 +253,19 @@ class BrowsePage extends React.Component<PropsFromRedux & WithTranslation & With
inventories={ selected }
tableConfig={ InventoryTableConfig }
/>
<PrintLabelDialog
templateName={ templateName }
labelsGenerator={ BrowsePage.labelsGenerator }
generatorData={[selected]}
buttonTitle={ t('common:action.printLabel_plural') }
dialogKey={ printDialogKey }
/>
{ selected.length > 0 &&
<FABMenu
title="common:label.openActionList"
actions={ actions }
/>
}
{ androidIntent && window?.navigator?.userAgent.toLowerCase().search(/android/) !== -1 &&
<Dialog
open={ androidIntent }
onClose={ this.closeLabelsPrintDialog }
fullWidth
maxWidth={ 'sm' }
disableEnforceFocus
>
<DialogTitle>{ t('common:action.printLabel') }</DialogTitle>
<DialogActions>
<Button variant="outlined" color="default" href={ androidIntent }>
{ t('common:action.printLabel') }
</Button>
<Button variant="text" color="primary" onClick={ this.closeLabelsPrintDialog }>{ t('common:action.close') }</Button>
</DialogActions>
</Dialog>
}
</>
);
}
......@@ -314,6 +283,7 @@ const mapDispatch = {
navigateTo,
receiveInventorySuccessAction,
showSnackbar,
showDialog
}
type PropsFromRedux = ReturnType<typeof mapStateToProps> & typeof mapDispatch;
......
......@@ -59,8 +59,9 @@ import CancelOutlinedIcon from '@material-ui/icons/CancelOutlined';
import EditIcon from '@material-ui/icons/Edit';
import FABMenu from '@gringlobal-ce/client/ui/common/button/FABMenu';
import { InventoryGroupTableDefaultConfig } from 'inventorygroup/ui/GroupBrowsePage';
import { printLabels } from '@gringlobal-ce/client/utilities';
import InventoryQualityStatusForm from 'inventory/ui/c/InventoryQualityStatusForm';
import PrintLabelDialog from "common/PrintLabelDialog";
import { showDialog } from "@gringlobal-ce/client/action/dialog";
const styles = (theme) => ({
systemHeader: {
......@@ -132,6 +133,9 @@ enum InventoryDetailsTabs {
const InventoryTableConfig = new TableConfiguration(InventoryTableDefaultConfig);
const templateName = "INVENTORYITEM";
const printDialogKey = "inventory-print-label-dialog"
class InventoryDetailsPage extends React.Component<PropsFromRedux & IWithTabs & WithTranslation & WithStyles> {
protected static needs = [
......@@ -149,7 +153,6 @@ class InventoryDetailsPage extends React.Component<PropsFromRedux & IWithTabs &
actions: [],
selectedAction: null,
createNew: true,
androidIntent: null,
qualityStatus: [],
selectedQuality: [],
qualityDialogIsOpen: false,
......@@ -397,23 +400,6 @@ class InventoryDetailsPage extends React.Component<PropsFromRedux & IWithTabs &
})
}
public handlePrintLabel = () => {
const { showSnackbar, inventoryCall, t } = this.props;
const { data: inventory } = inventoryCall;
const templateName = "INVENTORYITEM";
printLabels(templateName, [{ inventory, accession: inventory.accession, taxon: speciesToText(inventory.accession.taxonomySpecies) }])
.then((res) => {
// this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.browser_fallback_url=${encodeURIComponent('https://gitlab.croptrust.org/grin-global/ggce-printer-android')};S.label=${encodeURIComponent(res)};end` });
this.setState({ androidIntent: `intent://print/#Intent;scheme=ggce;S.label=${encodeURIComponent(res)};end` });
showSnackbar(t('inventory.public.p.details.label.labelsReady'));
})
.catch((err) => {
console.log('Error', err);
showSnackbar(t('inventory.public.p.details.label.labelError', { what: err.data?.error || err.toString() }));
});
}
private resetError = () => {
return this.setState({ error: null });
}
......@@ -427,13 +413,13 @@ class InventoryDetailsPage extends React.Component<PropsFromRedux & IWithTabs &
};
public render(): React.ReactNode {
const { inventoryCall, t, currentTab, onTabChange, classes, removeInventoryAttachmentsAction, removeInventoryAttachmentAction, id } = this.props;
const { inventoryCall, t, currentTab, showDialog, onTabChange, classes, removeInventoryAttachmentsAction, removeInventoryAttachmentAction, id } = this.props;
if (!inventoryCall) {