diff --git a/README.md b/README.md index d0164f719..a92e24c0c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ This file should be copied to create a `client/src/js/config.json` file and edit | site_name | Site Name to display in footer | | site_link | URL to site home page | | data_catalogue | Object that includes name and url property for a link to a data catalogue - displayed on the landing page | +| csv_profile | The csv profile for importing shipments, currently diamond or imca, as client/src/js/csv/.js | +| csv_message | A help message displayed at the top of the CSV importer page | Site Image can be customised via the tailwind.config.js header-site-logo and footer-site-logo values. diff --git a/api/src/Page/Sample.php b/api/src/Page/Sample.php index b602f47e5..443063b12 100644 --- a/api/src/Page/Sample.php +++ b/api/src/Page/Sample.php @@ -52,6 +52,7 @@ class Sample extends Page 'CRYSTALID' => '\d+', 'CONTAINERID' => '\d+', 'LOCATION' => '\d+', + 'SUBLOCATION' => '\d+', 'CODE' => '(\w|\s|\-)+|^$', // Change validation to work for dashes as well as numbers 'ACRONYM' => '([\w\-])+', 'SEQUENCE' => '[\s\w\(\)\.>\|;\n]+', @@ -102,7 +103,7 @@ class Sample extends Page 'EXPERIMENTKIND' => '[\w|\s]+', 'CENTRINGMETHOD' => '\w+', - 'RADIATIONSENSITIVITY' => '\w+', + 'RADIATIONSENSITIVITY' => '\d+(.\d+)?', 'USERPATH' => '(?=.{0,40}$)(\w|-)+\/?(\w|-)+', // Up to two folders as a path, 40 characters maximum 'EXPOSURETIME' => '\d+(.\d+)?', 'PREFERREDBEAMSIZEX' => '\d+(.\d+)?', @@ -138,6 +139,7 @@ class Sample extends Page 'TYPE' => '\w+', 'BLSAMPLEGROUPSAMPLEID' => '\d+-\d+', 'PLANORDER' => '\d', + 'SHIPPINGID' => '\d+', 'SAMPLEGROUPID' => '\d+', 'SCREENINGMETHOD' => '\w+', @@ -146,6 +148,7 @@ class Sample extends Page 'INITIALSAMPLEGROUP' => '\d+', 'STRATEGYOPTION' => '', 'MINIMUMRESOLUTION' => '\d+(.\d+)?', + 'OBSERVEDRESOLUTION' => '\d+(.\d+)?', 'groupSamplesType' => '.*' // query parameter to query sample groups by sample types. Should be comma separated values like so: groupSamplesType=container,capillary ); @@ -1053,6 +1056,12 @@ function _samples() array_push($args, $this->arg('BLSAMPLEGROUPID')); } + # For a specific shipment + if ($this->has_arg('SHIPPINGID')) { + $where .= ' AND d.shippingid=:'.(sizeof($args)+1); + array_push($args, $this->arg('SHIPPINGID')); + } + # For a specific container if ($this->has_arg('cid')) { $where .= ' AND c.containerid=:' . (sizeof($args) + 1); @@ -1455,6 +1464,7 @@ function _prepare_sample_args($s = null) 'COLOR', 'THEORETICALDENSITY', 'LOOPTYPE', + 'SUBLOCATION', 'ENERGY', 'USERPATH', 'SCREENINGMETHOD', @@ -1462,6 +1472,7 @@ function _prepare_sample_args($s = null) 'SAMPLEGROUP', 'STRATEGYOPTION', 'MINIMUMRESOLUTION', + 'OBSERVEDRESOLUTION', 'INITIALSAMPLEGROUP' ) as $f) { if ($s) @@ -1479,8 +1490,8 @@ function _do_add_sample($s) $a = $this->_prepare_strategy_option_for_sample($s); $this->db->pq( - "INSERT INTO diffractionplan (diffractionplanid, requiredresolution, anomalousscatterer, centringmethod, experimentkind, radiationsensitivity, energy, userpath, strategyoption, minimalresolution) VALUES (s_diffractionplan.nextval, :1, :2, :3, :4, :5, :6, :7, :8, :9) RETURNING diffractionplanid INTO :id", - array($a['REQUIREDRESOLUTION'], $a['ANOMALOUSSCATTERER'], $a['CENTRINGMETHOD'], $a['EXPERIMENTKIND'], $a['RADIATIONSENSITIVITY'], $a['ENERGY'], $a['USERPATH'], $a['STRATEGYOPTION'], $a['MINIMUMRESOLUTION']) + "INSERT INTO diffractionplan (diffractionplanid, requiredresolution, anomalousscatterer, centringmethod, experimentkind, radiationsensitivity, energy, userpath, strategyoption, minimalresolution, observedresolution) VALUES (s_diffractionplan.nextval, :1, :2, :3, :4, :5, :6, :7, :8, :9, :10) RETURNING diffractionplanid INTO :id", + array($a['REQUIREDRESOLUTION'], $a['ANOMALOUSSCATTERER'], $a['CENTRINGMETHOD'], $a['EXPERIMENTKIND'], $a['RADIATIONSENSITIVITY'], $a['ENERGY'], $a['USERPATH'], $a['STRATEGYOPTION'], $a['MINIMUMRESOLUTION'], $a['OBSERVEDRESOLUTION']) ); $did = $this->db->id(); @@ -1513,8 +1524,8 @@ function _do_add_sample($s) } $this->db->pq( - "INSERT INTO blsample (blsampleid,crystalid,diffractionplanid,containerid,location,comments,name,code,blsubsampleid,screencomponentgroupid,volume,packingfraction,dimension1,dimension2,dimension3,shape,looptype) VALUES (s_blsample.nextval,:1,:2,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,:15,:16) RETURNING blsampleid INTO :id", - array($crysid, $did, $a['CONTAINERID'], $a['LOCATION'], $a['COMMENTS'], $a['NAME'], $a['CODE'], $a['BLSUBSAMPLEID'], $a['SCREENCOMPONENTGROUPID'], $a['VOLUME'], $a['PACKINGFRACTION'], $a['DIMENSION1'], $a['DIMENSION2'], $a['DIMENSION3'], $a['SHAPE'], $a['LOOPTYPE']) + "INSERT INTO blsample (blsampleid,crystalid,diffractionplanid,containerid,location,comments,name,code,blsubsampleid,screencomponentgroupid,volume,packingfraction,dimension1,dimension2,dimension3,shape,looptype,sublocation) VALUES (s_blsample.nextval,:1,:2,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,:15,:16,:17) RETURNING blsampleid INTO :id", + array($crysid, $did, $a['CONTAINERID'], $a['LOCATION'], $a['COMMENTS'], $a['NAME'], $a['CODE'], $a['BLSUBSAMPLEID'], $a['SCREENCOMPONENTGROUPID'], $a['VOLUME'], $a['PACKINGFRACTION'], $a['DIMENSION1'], $a['DIMENSION2'], $a['DIMENSION3'], $a['SHAPE'], $a['LOOPTYPE'], $a['SUBLOCATION']) ); $sid = $this->db->id(); diff --git a/api/src/Page/Shipment.php b/api/src/Page/Shipment.php index 88d45adc0..ce89b4f9c 100644 --- a/api/src/Page/Shipment.php +++ b/api/src/Page/Shipment.php @@ -126,6 +126,7 @@ class Shipment extends Page 'PUCK' => '\d', 'PROCESSINGPIPELINEID' => '\d+', 'OWNERID' => '\d+', + 'SOURCE' => '[\w\-]+', 'CONTAINERREGISTRYID' => '\d+', 'PROPOSALID' => '\d+', @@ -2024,11 +2025,12 @@ function _add_container() $crid = $this->has_arg('CONTAINERREGISTRYID') ? $this->arg('CONTAINERREGISTRYID') : null; $pipeline = $this->has_arg('PROCESSINGPIPELINEID') ? $this->arg('PROCESSINGPIPELINEID') : null; + $source = $this->has_arg('SOURCE') ? $this->arg('SOURCE') : null; $this->db->pq( - "INSERT INTO container (containerid,dewarid,code,bltimestamp,capacity,containertype,scheduleid,screenid,ownerid,requestedimagerid,comments,barcode,experimenttype,storagetemperature,containerregistryid,prioritypipelineid) - VALUES (s_container.nextval,:1,:2,CURRENT_TIMESTAMP,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14) RETURNING containerid INTO :id", - array($this->arg('DEWARID'), $this->arg('NAME'), $cap, $this->arg('CONTAINERTYPE'), $sch, $scr, $own, $rid, $com, $bar, $ext, $tem, $crid, $pipeline) + "INSERT INTO container (containerid,dewarid,code,bltimestamp,capacity,containertype,scheduleid,screenid,ownerid,requestedimagerid,comments,barcode,experimenttype,storagetemperature,containerregistryid,prioritypipelineid,source) + VALUES (s_container.nextval,:1,:2,CURRENT_TIMESTAMP,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,ifnull(:15,current_user)) RETURNING containerid INTO :id", + array($this->arg('DEWARID'), $this->arg('NAME'), $cap, $this->arg('CONTAINERTYPE'), $sch, $scr, $own, $rid, $com, $bar, $ext, $tem, $crid, $pipeline, $source) ); $cid = $this->db->id(); @@ -2247,7 +2249,7 @@ function _container_registry() } $rows = $this->db->paginate("SELECT r.containerregistryid, r.barcode, GROUP_CONCAT(distinct CONCAT(p.proposalcode,p.proposalnumber) SEPARATOR ', ') as proposals, count(distinct c.containerid) as instances, TO_CHAR(r.recordtimestamp, 'DD-MM-YYYY') as recordtimestamp, - TO_CHAR(max(c.bltimestamp),'DD-MM-YYYY') as lastuse, max(CONCAT(p.proposalcode,p.proposalnumber)) as prop, r.comments, COUNT(distinct cr.containerreportid) as reports + TO_CHAR(max(c.bltimestamp),'DD-MM-YYYY') as lastuse, max(CONCAT(p.proposalcode,p.proposalnumber)) as prop, r.comments, COUNT(distinct cr.containerreportid) as reports, c.code as lastname FROM containerregistry r LEFT OUTER JOIN containerregistry_has_proposal rhp on rhp.containerregistryid = r.containerregistryid LEFT OUTER JOIN proposal p ON p.proposalid = rhp.proposalid diff --git a/client/src/css/partials/_content.scss b/client/src/css/partials/_content.scss index 99767d875..38e1d344e 100644 --- a/client/src/css/partials/_content.scss +++ b/client/src/css/partials/_content.scss @@ -2090,5 +2090,20 @@ ul.messages { background: color(#00ff00 tint(80%)); } } +} + +.dropimage { + color: $content-search-background; + padding: 20px; + border: 2px dashed $content-search-background; + margin: 2% 0; + text-align: center; + border-radius: 5px; + + &.active { + color: $content-header-color; + background: $content-dark-background; + text-decoration: italic; + } } diff --git a/client/src/js/config_sample.json b/client/src/js/config_sample.json index cc71a60f8..c01b2c5dd 100644 --- a/client/src/js/config_sample.json +++ b/client/src/js/config_sample.json @@ -20,6 +20,9 @@ "maintenance_message": "This is the maintenance message", "maintenance": false, + "csv_profile": "diamond", + "csv_message": "This CSV uploader is in Beta mode, use at your own risk.", + "ga_ident": "", "_data_catalogue_comment": " Remove the data_catalogue object if you don't want a link on the landing page", diff --git a/client/src/js/csv/diamond.js b/client/src/js/csv/diamond.js new file mode 100644 index 000000000..fba619cd3 --- /dev/null +++ b/client/src/js/csv/diamond.js @@ -0,0 +1,60 @@ +define([], function() { + + return { + + // The csv column names + headers: ['Proposal Code', 'Proposal Number', 'Visit Number', 'Shipping Name', 'Dewar Code', 'Puck', 'preObsResolution', 'minimalResolution', 'Oscillation Range', 'Protein Acronym', 'Protein Name', 'Space Group', + 'Barcode', 'Sample Name', 'Location', 'Comments', 'Cell A', 'Cell B', 'Cell C', 'Cell Alpha', 'Cell Beta', 'Cell Gamma', 'Sublocation', 'Loop Type', 'Required Resolution', 'Centring Method', 'Experiment Kind', + 'Radiation Sensitivity', 'Energy', 'User Path', 'Screen and Collect Recipe', 'S&C N value', 'Sample Group'], + + // ... and their ISPyB table mapping + mapping: ['PROPOSALCODE', 'PROPOSALNUMBER', 'VISITNUMBER', 'SHIPPINGNAME', 'FACILITYCODE', 'CONTAINER', 'OBSERVEDRESOLUTION', 'MINIMUMRESOLUTION', 'AXISRANGE', 'ACRONYM', 'PROTEINNAME', 'SPACEGROUP', + 'CODE', 'NAME', 'LOCATION', 'COMMENTS', 'CELL_A', 'CELL_B', 'CELL_C', 'CELL_ALPHA', 'CELL_BETA', 'CELL_GAMMA', 'SUBLOCATION', 'LOOPTYPE', 'REQUIREDRESOLUTION', 'CENTRINGMETHOD', 'EXPERIMENTKIND', + 'RADIATIONSENSITIVITY', 'ENERGY', 'USERPATH', 'SCREENINGMETHOD', 'SCREENINGCOLLECTVALUE', 'SAMPLEGROUPNAME'], + + // Columns to show on the import page + columns: { + LOCATION: 'Location', + ACRONYM: 'Protein Acronym', + NAME: 'Name', + SAMPLEGROUPNAME: 'Sample Group', + CODE: 'Barcode', + COMMENTS: 'Comment', + USERPATH: 'User Path', + SPACEGROUP: 'Spacegroup', + CELL: 'Cell', + CENTRINGMETHOD: 'Centring Method', + EXPERIMENTKIND: 'Experiment Kind', + ENERGY: 'Energy (eV)', + SCREENINGMETHOD: 'Screening Method', + REQUIREDRESOLUTION: 'Required Res', + MINIMUMRESOLUTION: 'Minimum Res', + SCREENINGCOLLECTVALUE: 'Number to collect', + }, + + // Import transforms + transforms: { + SPACEGROUP: function(v, m) { + m.SPACEGROUP = v.replace(/[()]/g, '').toUpperCase() + } + }, + + exampleCSV: `cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0221,TestInsulin-x00021,1,Z1992316315,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00022,2,Z1787158625,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00023,3,Z1275599911,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0765,TestInsulin-x00024,4,Z3201466300,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00025,5,Z8187272620,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E1412,TestInsulin-x00026,6,Z1454840342,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00027,7,Z1563512128,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00028,8,Z5567190000,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00029,9,Z1650868495,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0472,TestInsulin-x00030,10,Z1741785925,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0413,TestInsulin-x00031,11,Z2510259379,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0797,TestInsulin-x00032,12,Z2856434779,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00033,13,Z2856434839,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0888,TestInsulin-x00034,14,Z1432018343,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,DF150E0553,TestInsulin-x00035,15,Z4884759400,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry +cm,28170,67,cm28170-53_2021-09-21_15-40-49,cm28170-53_TestInsulin,I03-0001,,,,TestInsulin,TestInsulin,P422,-CANT-FIND,TestInsulin-x00036,16,Z1315161580,57,57,149,90,90,90,1,,1.8,diffraction,XChem Low Symmetry` + } + +}) diff --git a/client/src/js/csv/imca.js b/client/src/js/csv/imca.js new file mode 100644 index 000000000..c05e4e294 --- /dev/null +++ b/client/src/js/csv/imca.js @@ -0,0 +1,92 @@ +define([], function() { + + return { + // The csv column names + headers: ['Puck', 'Pin', 'Project', 'Priority', 'Mode', 'Notes to Staff', 'Collection strategy', 'Contact person', 'Expected space group', 'Expected Cell Dimensions', 'Expected Resolution', 'Minimum Resolution Required to Collect', 'Recipe', 'Exposure time', 'Image Width', 'Phi', 'Attenuation', 'Aperture', 'Detector Distance', 'Prefix for frames', 'Observed Resolution', 'Comments From Staff', 'Status'], + + // ... and their ISPyB table mapping + mapping: ['CONTAINER', 'LOCATION', 'ACRONYM', 'PRIORITY', 'COLLECTIONMODE', 'COMMENTS', 'COMMENTS', 'OWNER', 'SPACEGROUP', 'CELL', 'AIMEDRESOLUTION', 'REQUIREDRESOLUTION', 'RECIPE', 'EXPOSURETIME', 'AXISRANGE', 'AXISROTATION', 'TRANSMISSION', 'PREFERREDBEAMSIZEX', 'DETECTORDISTANCE', 'PREFIX', 'DCRESOLUTION', 'STAFFCOMMENTS', 'STATUS'], + + // Columns to show on the import page + columns: { + LOCATION: 'Location', + PROTEINID: 'Protein', + NAME: 'Sample', + PRIORITY: 'Priority', + COLLECTIONMODE: 'Mode', + COMMENTS: 'Comments', + SPACEGROUP: 'Spacegroup', + CELL: 'Cell', + AIMEDRESOLUTION: 'Aimed Res', + REQUIREDRESOLUTION: 'Required Res', + EXPOSURETIME: 'Exposure (s)', + AXISRANGE: 'Axis Osc', + NUMBEROFIMAGES: 'No. Images', + TRANSMISSION: 'Transmission', + PREFERREDBEAMSIZEX: 'Beamsize', + }, + + // Import transforms + transforms: { + CELL: function(v, m) { + var comps = v.split(/\s+/) + _.each(['CELL_A', 'CELL_B', 'CELL_C', 'CELL_ALPHA', 'CELL_BETA', 'CELL_GAMMA'], function(ax, i) { + if (comps.length > i) m[ax] = comps[i].replace(',', '') + }) + }, + AXISROTATION: function(v, m) { + if (m.AXISRANGE) m.NUMBEROFIMAGES = m.AXISROTATION / m.AXISRANGE + }, + SPACEGROUP: function(v, m) { + m.SPACEGROUP = v.replace(/[()]/g, '') + }, + LOCATION: function(v, m) { + if (!this.xcount) this.xcount = 1 + m.NAME = 'x'+(this.xcount++) + }, + COLLECTIONMODE: function(v, m) { + m.COLLECTIONMODE = v.toLowerCase() + } + }, + + // Export transforms + export: { + CELL: function(m) { + return `${m.CELL_A}, ${m.CELL_B}, ${m.CELL_C}, ${m.CELL_ALPHA}, ${m.CELL_BETA}, ${m.CELL_GAMMA}`.trim() + }, + + STATUS: function(m) { + var status = 'skipped' + if (m.QUEUEDTIMESTAMP) status = 'queued'; + if (m.R > 0) status = 'received' + if (m.DC > 0) status = 'collected' + + return status + }, + + AXISROTATION: function(m) { + return m.AXISRANGE * m.NUMBEROFIMAGES + }, + + COMMENTS: function(m, h) { + var comments = m.COMMENTS.split(' | ') + return comments.length > 1 && h == 'Collection strategy' ? comments[1] : comments[0] + } + }, + + exampleCSV: `Puck,Pin,Project,Priority,Mode,Notes to Staff,Collection strategy,Contact person,Expected space group,Expected Cell Dimensions,Expected Resolution,Minimum Resolution Required to Collect,Recipe,Exposure time,Image Width,Phi,Attenuation,Aperture,Detector Distance,Prefix for frames,Observed Resolution,Comments From Staff,Status +Blue53,1,a,1,Manual,Tricky,Do best you can,Luke,C2,"143.734, 67.095, 76.899, 90, 110.45, 90",1.9-3.5,4,luke-360.rcp,,,,,,,,,, +Blue53,2,a,1,Manual,Very tricky,New crystals,Luke,C2,140 65 75 90 110 90,1.8-2.4,3.5,,0.1,0.25,,95,5,250,image_,,, +Blue53,2,a,1,Manual,Very tricky,New crystals,Luke,C2,140 65 75 90 110 90,1.8-2.4,3.5,,0.1,0.25,,95,5,250,image_,,, +Blue53,3,b,3,Auto,Routine,SeMet,Luke,P2,52.4 39.8 65.0 108.5,1.5,1.7,,0.04,0.25,360,,10,300,,,, +Blue53,4,c,3,Auto,Rods,Native,Luke,P21,39 69.2 60 90 105.3,1.5,1.7,,0.04,0.25,360,95,20,,image_,,, +Blue53,5,d,8,,Plates,,Luke,C222,280 45 112 102 90,1.5,1.7,,0.04,0.25,360,95,50,300,image_,,, +Blue54,1,e,,Auto,,,,P212121,67 82 276,2.1,2.5,,,0.25,180,,10,350,image_,,, +Blue54,2,e,4,,,,Luke,P2(1)2(1)2(1),67 82 276,,1.7,luke-180.rcp,,,,,,,,,, +Blue54,3,f,,Auto,,,,P222,,2.1,,,0.04,,180,95,,350,image_,,, +Blue54,4,g,4,Auto,,,Luke,,,2.1,2.5,,0.04,0.25,180,75,,350,image_,,, +Blue54,5,h,99,Auto,,,Luke,P222,,2.2,2.5,,0.04,0.25,180,95,,400,image_,,, + ` + } + +}) diff --git a/client/src/js/models/sample.js b/client/src/js/models/sample.js index 3abf17909..ff0e39f68 100644 --- a/client/src/js/models/sample.js +++ b/client/src/js/models/sample.js @@ -204,12 +204,26 @@ define(['backbone', 'collections/components', pattern: 'twopath', maxLength: 40, }, + ENERGY: { + required: false, + pattern: 'number', + }, SCREENINGMETHOD: { required: false, - pattern: 'word' + oneOf: ['none', 'all', 'best'] }, SCREENINGCOLLECTVALUE: { - required: false, + required: function() { + return this.get('SCREENINGMETHOD') == 'best' + }, + pattern: 'digits', + min: 1, + max: 5 + }, + MINIMUMRESOLUTION: { + required: function() { + return this.get('SCREENINGMETHOD') == 'all' + }, pattern: 'number' }, SAMPLEGROUP: { @@ -218,7 +232,7 @@ define(['backbone', 'collections/components', }, EXPERIMENTKIND: { required: false, - pattern: 'word' + pattern: 'wwsdash' }, COMPONENTAMOUNTS: function(from_ui, attr, all_values) { diff --git a/client/src/js/modules/shipment/controller.js b/client/src/js/modules/shipment/controller.js index a9ad910d4..08f6614c0 100644 --- a/client/src/js/modules/shipment/controller.js +++ b/client/src/js/modules/shipment/controller.js @@ -7,6 +7,7 @@ define(['backbone', 'modules/shipment/views/shipments', 'modules/shipment/views/shipment', 'modules/shipment/views/shipmentadd', + 'modules/shipment/views/fromcsv', 'models/container', 'collections/containers', @@ -47,7 +48,7 @@ define(['backbone', ], function(Backbone, GetView, Dewar, Shipment, Shipments, - ShipmentsView, ShipmentView, ShipmentAddView, + ShipmentsView, ShipmentView, ShipmentAddView, ImportFromCSV, Container, Containers, ContainerView, ContainerPlateView, /*ContainerAddView,*/ ContainersView, QueueContainerView, ContainerRegistry, ContainersRegistry, ContainerRegistryView, RegisteredContainer, RegisteredDewar, DewarRegistry, DewarRegView, RegDewarView, RegDewarAddView, DewarRegistryView, @@ -105,6 +106,36 @@ define(['backbone', } }, + // Import csv based on selected profile + import_csv: function(sid) { + if (!app.config.csv_profile) { + app.message({ title: 'CSV Import Not Enabled', message: 'Shipment CSV import is not currently enabled'}) + return + } + + var lookup = new ProposalLookup({ field: 'SHIPPINGID', value: sid }) + lookup.find({ + success: function() { + var shipment = new Shipment({ SHIPPINGID: sid }) + shipment.fetch({ + success: function() { + app.bc.reset([bc, { title: shipment.get('SHIPPINGNAME') }, { title: 'Import from CSV' }]) + app.content.show(new ImportFromCSV({ model: shipment, format: 'imca' })) + }, + error: function() { + app.bc.reset([bc]) + app.message({ title: 'No such shipment', message: 'The specified shipment could not be found'}) + }, + }) + }, + + error: function() { + app.bc.reset([bc, { title: 'No such shipment' }]) + app.message({ title: 'No such shipment', message: 'The specified shipment could not be found'}) + } + }) + }, + create_awb: function(sid) { var shipment = new Shipment({ SHIPPINGID: sid }) shipment.fetch({ @@ -489,4 +520,4 @@ define(['backbone', }) return controller -}) \ No newline at end of file +}) diff --git a/client/src/js/modules/shipment/router.js b/client/src/js/modules/shipment/router.js index 99fc5c71c..df64ea1ae 100644 --- a/client/src/js/modules/shipment/router.js +++ b/client/src/js/modules/shipment/router.js @@ -8,6 +8,7 @@ define(['utils/lazyrouter'], function(LazyRouter) { 'shipments(/sid/:sid)': 'view', 'shipments/awb/sid/:sid': 'create_awb', 'shipments/pickup/sid/:sid': 'rebook_pickup', + 'shipments/csv/:sid': 'import_csv', 'containers/cid/:cid(/iid/:iid)(/sid/:sid)': 'view_container', 'containers/queue/:cid': 'queue_container', @@ -46,4 +47,4 @@ define(['utils/lazyrouter'], function(LazyRouter) { // controller: c rjsController: 'modules/shipment/controller', }) -}) \ No newline at end of file +}) diff --git a/client/src/js/modules/shipment/routes.js b/client/src/js/modules/shipment/routes.js index 673cd6531..8d5d26ee3 100644 --- a/client/src/js/modules/shipment/routes.js +++ b/client/src/js/modules/shipment/routes.js @@ -35,6 +35,7 @@ const ManifestView = import(/* webpackChunkName: "shipment" */ 'modules/shipment const DewarStats = import(/* webpackChunkName: "shipment-stats" */ 'modules/shipment/views/dewarstats') const DispatchView = import(/*webpackChunkName: "shipment" */ 'modules/shipment/views/dispatch') const TransferView = import(/*webpackChunkName: "shipment" */ 'modules/shipment/views/transfer') +const ImportFromCSV = import(/*webpackChunkName: "shipment" */ 'modules/shipment/views/fromcsv') // In future may want to move these into wrapper components // Similar approach was used for samples with a samples-map to determine the correct view @@ -297,6 +298,31 @@ const routes = [ breadcrumbs: [bc, { title: 'Dewar Stats' }] } }, + { + path: 'csv/:sid', + name: 'shipment-import-csv', + component: MarionetteView, + props: route => ({ + mview: ImportFromCSV, + breadcrumbs: [bc, { title: 'Import from CSV' }], + breadcrumb_tags: ['SHIPPINGNAME'], // Append shipment model name to the bc + options: { + model: new Shipment({ SHIPPINGID: route.params.sid }) + } + }), + beforeEnter: (to, from, next) => { + // Call the loading state here because we are finding the proposal based on this shipment id + // Prop lookup sets the proposal and type via set application.cookie method which we mapped to the store + store.dispatch('proposal/proposalLookup', { field: 'SHIPPINGID', value: to.params.sid } ) + .then( () => { + console.log("Calling next - Success, shipment model will be prefetched in marionette view") + next() + }, () => { + console.log("Error, no proposal found from the shipment id") + next('/notfound') + }) + } + }, ], }, // @@ -537,4 +563,4 @@ const routes = [ }, ] -export default routes \ No newline at end of file +export default routes diff --git a/client/src/js/modules/shipment/views/fromcsv.js b/client/src/js/modules/shipment/views/fromcsv.js new file mode 100644 index 000000000..bb4185f76 --- /dev/null +++ b/client/src/js/modules/shipment/views/fromcsv.js @@ -0,0 +1,609 @@ +define(['backbone', + 'marionette', + 'papaparse', + 'models/protein', + 'collections/proteins', + 'models/sample', + 'collections/samples', + 'models/container', + 'collections/containers', + 'models/samplegroup', + 'collections/samplegroups', + 'collections/dewars', + 'views/validatedrow', + 'views/form', + 'modules/shipment/collections/containerregistry', + 'collections/users', + 'modules/shipment/collections/distinctproteins', + + 'utils/sgs', + 'utils/experimentkinds', + 'utils/centringmethods', + + 'templates/shipment/fromcsv.html', + 'templates/shipment/fromcsvtable.html', + 'templates/shipment/fromcsvcontainer.html' + ], function( + Backbone, + Marionette, + Papa, + Protein, + Proteins, + Sample, + Samples, + Container, + Containers, + SampleGroup, + SampleGroups, + Dewars, + ValidatedRow, + FormView, + ContainerRegistry, + Users, + DistinctProteins, + SG, + EXP, + CM, + template, table, container) { + + + var GridRow = ValidatedRow.extend({ + template: false, + tagName: 'tr', + + columnTypes: { + LOCATION: function(v) { + return ''+v+'' + }, + + CELL: function(v, model) { + return ` + + + + + + + + ` + }, + SPACEGROUP: function(v, m) { + return '' + }, + CENTRINGMETHOD: function(v, m) { + return '' + }, + EXPERIMENTKIND: function(v, m) { + return '' + }, + ACRONYM: function(v) { + return ''+v+'' + }, + }, + + onRender: function() { + var cts = this.getOption('columnTypes') + var columns = _.map(this.getOption('profile').columns, function(c, k) { + var val = this.model.get(k) || '' + return cts[k] ? cts[k](val, this.model, this) : '' + }, this) + + this.$el.html(columns.join('')) + this.$el.find('select[name=SPACEGROUP]').val(this.model.get('SPACEGROUP')) + if (this.model.get('EXPERIMENTKIND') in EXP.obj()) { + this.$el.find('select[name=EXPERIMENTKIND]').val(this.model.get('EXPERIMENTKIND')) + } else if (this.model.get('EXPERIMENTKIND')) { + app.alert({ message: 'Experiment kind '+this.model.get('EXPERIMENTKIND')+' not valid' }) + this.model.set({EXPERIMENTKIND: ''}) + } + if (this.model.get('CENTRINGMETHOD') in CM.obj()) { + this.$el.find('select[name=CENTRINGMETHOD]').val(this.model.get('CENTRINGMETHOD')) + } else if (this.model.get('CENTRINGMETHOD')) { + app.alert({ message: 'Centring method '+this.model.get('CENTRINGMETHOD')+' not valid' }) + this.model.set({CENTRINGMETHOD: ''}) + } + this.model.validate() + }, + }) + + var TableView = Marionette.CompositeView.extend({ + tagName: "table", + className: 'samples reflow', + template: table, + childView: GridRow, + childViewOptions: function() { + return { + profile: this.getOption('profile'), + } + }, + + ui: { + tr: 'thead tr', + }, + + onRender: function() { + var headers = _.map(this.getOption('profile').columns, function(c, k) { + return ''+c+'' + }) + + this.ui.tr.html(headers.join('')) + }, + + }) + + var ContainerView = Marionette.LayoutView.extend({ + template: _.template('

<%-NAME%>

'), + + regions: { + rsamples: '.rsamples', + rcontainer: '.rcontainer', + }, + + initialize: function(options) { + this.samples = new Samples() + this.listenTo(options.samples, 'sync reset', this.generateSamples) + this.generateSamples() + }, + + generateSamples: function() { + this.samples.reset(this.getOption('samples').where({ CONTAINER: this.model.get('NAME') })) + }, + + onRender: function() { + this.rsamples.show(new TableView({ + collection: this.samples, + profile: this.getOption('profile'), + })) + this.rcontainer.show(new ModifyContainerView({ + model: this.model, + users: this.getOption('users'), + containerregistry: this.getOption('containerregistry'), + })) + } + }) + + var ModifyContainerView = FormView.extend({ + template: container, + ui: { + registry: '[name=CONTAINERREGISTRYID]', + person: '[name=PERSONID]', + }, + + events: { + 'change select': 'updateModel', + 'change input': 'updateModel', + }, + + createModel: function() { + + }, + + updateModel: function(e) { + var attr = $(e.target).attr('name') + console.log('updateModel', attr, e.target.value) + this.model.set({ [attr]: e.target.value }) + }, + + onRender: function() { + this.ui.registry.html(''+this.getOption('containerregistry').opts({ empty: true })) + this.ui.person.html(this.getOption('users').opts()).val(this.model.get('OWNERID') || app.personid) + + if (!this.model.get('CONTAINERTYPE')) { + this.model.set({ CONTAINERTYPE: 'Puck', CAPACITY: 16 }) + } + + if (!this.model.get('CONTAINERREGISTRYID')) { + var reg = this.getOption('containerregistry') + var nearest = reg.findWhere({ LASTNAME: this.model.get('NAME') }) + this.model.set({ CONTAINERREGISTRYID: nearest ? nearest.get('CONTAINERREGISTRYID') : '!' }) + } + this.ui.registry.val(this.model.get('CONTAINERREGISTRYID')) + + this.model.set({ SOURCE: 'SynchWeb-CSV-Upload' }) + + this.model.isValid(true) + } + }) + + var ContainersView = Marionette.CollectionView.extend({ + childView: ContainerView, + childViewOptions: function() { + return { + samples: this.getOption('samples'), + profile: this.getOption('profile'), + containerregistry: this.getOption('containerregistry'), + users: this.getOption('users'), + proteins: this.getOption('proteins'), + } + } + }) + + + var MessageView = Marionette.ItemView.extend({ + tagName: 'li', + template: _.template('<%-message%>') + }) + + var MessagesView = Marionette.CollectionView.extend({ + tagName: 'ul', + childView: MessageView + }) + + var Message = Backbone.Model.extend({ + + }) + + return Marionette.LayoutView.extend({ + template: template, + className: 'content', + + regions: { + rcontainers: '.rcontainers', + rmessages: '.rmessages', + }, + + ui: { + drop: '.dropimage', + pnew: '.pnew', + browse: '#browse', + csvmessage: '#csvmessage', + }, + + events: { + 'dragover @ui.drop': 'dragHover', + 'dragleave @ui.drop': 'dragHover', + 'drop @ui.drop': 'dropFile', + 'click .submit': 'import', + 'change @ui.browse': 'fileselected', + }, + + fileselected: function(e) { + console.log('file selected') + console.log(e) + //console.log(e.originalEvent.dataTransfer.files) + console.log(e.target.files[0]) + f = e.target.files[0] + this.uploadFile(f) + }, + + addMessage: function(options) { + this.messages.add(new Message({ message: options.message })) + }, + + import: function(e) { + e.preventDefault() + this.messages.reset() + + if (!this.containers.length && !this.samples.length) { + app.alert({ message: 'No containers and samples found' }) + return + } + + var valid = true + this.containers.each(function(c) { + var cValid = c.isValid(true) + console.log(c.get('CODE'), c) + if (!cValid) { + valid = false + this.addMessage({ message: `Container ${c.get('NAME')} is invalid` }) + } + }, this) + + this.samples.each(function(s) { + var sValid = s.isValid(true) + console.log(s.get('NAME'), s) + if (!sValid) { + valid = false + this.addMessage({ message: `Sample ${s.get('NAME')} is invalid` }) + } + }, this) + + window.scrollTo({ top: 0, behavior: 'smooth' }); + + if (!valid) { + app.alert({ message: 'Shipment is not valid' }) + return + } + + this.messages.reset() + + var self = this + + var gg = [] + this.newGroups.each(function(g) { + gg.push(g.save({}, { + success: function(result) { + g.set({BLSAMPLEGROUPID: result.get('BLSAMPLEGROUPID')}) + self.addMessage({ message: 'Created group '+g.get('NAME')+'id: '+g.get('BLSAMPLEGROUPID')}) + }, + error: function(xhr, status, error) { + self.addMessage({ messages: 'Error creating group: '+error}) + } + })) + }, this) + + $.when.apply($, gg).done(function() { + var cp = [] + self.containers.each(function(c) { + if (c.isNew()) { + cp.push(c.save({}, { + success: function() { + self.addMessage({ message: 'Created container: '+c.get('NAME') }) + }, + error: function(xhr, status, error) { + self.addMessage({ messages: 'Error creating container: '+error}) + } + })) + } + }) + + $.when.apply($, cp).done(function() { + var news = new Samples(self.samples.filter(function(s) { + return s.isNew() + })) + + if (news.length == 0) return + + news.each(function(s) { + if (!s.get('CONTAINERID')) { + var c = self.containers.findWhere({ NAME: s.get('CONTAINER')}) + s.set({ CONTAINERID: c.get('CONTAINERID') }, { silent: true }) + } + if (s.get('SAMPLEGROUPNAME')) { + var g = self.newGroups.findWhere({ NAME: s.get('SAMPLEGROUPNAME')}) + s.set({ SAMPLEGROUP: g.get('BLSAMPLEGROUPID') }) + } + + }) + + news.save({ + success: function() { + window.location.href = '/shipments/sid/'+self.model.escape('SHIPPINGID') + }, + error: function(xhr, status, error) { + self.addMessage({ messages: 'Error creating samples: '+error}) + } + }) + }) + }) + + }, + + initialize: function(options) { + this.messages = new Backbone.Collection() + + this.samples = new Samples(null, { state: { pageSize: 9999 }}) + this.samples.queryParams.SHIPPINGID = this.model.get('SHIPPINGID') + this.samples.fetch() + + this.containers = new Containers() + this.containers.shipmentID = this.model.get('SHIPPINGID') + this.containers.setSorting('BLTIMESTAMP', 0) + this.containers.fetch() + + this.ready = [] + + this.sampleGroups = new SampleGroups(null, { state: { pageSize: 9999 }}) + this.ready.push(this.sampleGroups.fetch()) + this.newGroups = new SampleGroups() + + this.containerregistry = new ContainerRegistry(null, { state: { pageSize: 9999 }}) + this.ready.push(this.containerregistry.fetch()) + + this.users = new Users(null, { state: { pageSize: 9999 }}) + this.users.queryParams.all = 1 + this.ready.push(this.users.fetch()) + + this.proteins = new DistinctProteins() + if (app.valid_samples) { + this.proteins.queryParams.SAFETYLEVEL = 'GREEN'; + } + this.ready.push(this.proteins.fetch()) + + this.dewars = new Dewars() + this.dewars.queryParams.sid = this.model.get('SHIPPINGID') + this.ready.push(this.dewars.fetch()) + + this.csvProfile = require('csv/'+app.config.csv_profile+'.js') + this.valid = true + console.log('initialize', this.csvProfile) + }, + + + dragHover: function(e) { + e.stopPropagation() + e.preventDefault() + if (e.type == 'dragover') this.ui.drop.addClass('active') + else this.ui.drop.removeClass('active') + }, + + dropFile: function(e) { + this.dragHover(e) + var files = e.originalEvent.dataTransfer.files + var f = files[0] + this.uploadFile(f) + }, + + + uploadFile: function(f) { + if (f.name.endsWith('csv')) { + var reader = new FileReader() + var self = this + reader.onload = function(e) { + self.parseCSVContents(e.target.result) + } + reader.readAsText(f) + } else { + app.alert({ message: 'Cannot import file "'+f.name+'" is not a csv file' }) + } + }, + + createObjects: function(raw) { + var parsed = Papa.parse(raw) + + if (parsed.errors.length) { + var errs = [] + _.each(parsed.errors, function(e) { + errs.push({ message: e.code + ': ' + e.message + ' at row ' + e.row }) + }) + this.messages.reset(errs) + app.alert({ message: 'Error parsing csv file, see messages below' }) + return + } + + var objects = parsed.data + var mapping = this.csvProfile.mapping + var headers = this.csvProfile.headers + var transforms = this.csvProfile.transforms + + var newGroups = [] + var populatedObject = [] + _.each(objects, function(item){ + if (!item.length || (item.length == 1 && !item[0].trim())) return + if (["#", ";", "!"].includes(item[0][0])) return + if (item[0] === headers[0]) return + var obj = {} + _.each(item, function(v, i) { + var key = mapping[i] + if (v) obj[key] ? obj[key] += ' | '+v : obj[key] = v + }) + + _.each(obj, function(v, k) { + if (k in transforms) { + transforms[k](v, obj) + } + }, this) + + if (!obj.PROTEINID) { + var protein = this.proteins.findWhere({ ACRONYM: obj.ACRONYM }) + if (protein) { + obj.PROTEINID = protein.get('PROTEINID') + } else { + app.alert({ message: 'Invalid protein: '+obj.ACRONYM }) + this.valid = false + return + } + } + + if (obj.SAMPLEGROUPNAME) { + var newg = _.findWhere(newGroups, { NAME: obj.SAMPLEGROUPNAME }) + if (newg) { + newg.SAMPLES.push(obj.NAME) + } else { + newGroups.push({ + NAME: obj.SAMPLEGROUPNAME, + SAMPLES: [obj.NAME], + }) + } + } + + // must be a number between 1 and 16, allow spaces before and after and zero padding + validLocations = /^ *(0?[1-9]|1[0-6]) *$/ + if (!obj.LOCATION || !validLocations.test(obj.LOCATION)) { + app.alert({ message: obj.NAME+ ': Location must be between 1 and 16' }) + this.valid = false + return + } + + _.each(populatedObject, function(previousSample){ + if (obj.ACRONYM == previousSample.ACRONYM && obj.NAME == previousSample.NAME) { + app.alert({ message: 'Multiple rows exist for protein '+obj.ACRONYM+' and sample name '+obj.NAME }) + this.valid = false + return + } + if (obj.CONTAINER == previousSample.CONTAINER && obj.LOCATION == previousSample.LOCATION) { + app.alert({ message: 'Multiple rows exist for container '+obj.CONTAINER+' and location '+obj.LOCATION }) + this.valid = false + return + } + }, this) + + if (obj.SCREENINGMETHOD) { + validScreeningMethods = ['', 'none', 'best', 'all'] + if (!validScreeningMethods.includes(obj.SCREENINGMETHOD)) { + app.alert({ message: obj.NAME+ ': Screening method must be none, best or all' }) + this.valid = false + return + } + } + + populatedObject.push(obj) + }, this) + + this.newGroups.reset(newGroups) + console.log('newGroups') + console.log(newGroups) + return populatedObject + }, + + parseCSVContents: function(raw) { + this.valid = true + var samples = this.createObjects(raw) + console.log('parseCSVContents', samples) + + var existingContainers = this.containers.pluck('NAME') + _.each(_.unique(_.pluck(samples, 'CONTAINER')), function(name) { + if (existingContainers.indexOf(name) > -1) { + app.alert({ message: 'Container ' + name + ' already exists' }) + this.valid = false + } + }, this) + + var existingSamples = this.samples.pluck('NAME') + _.each(samples, function(sample) { + if (existingSamples.indexOf(sample.NAME) > -1) { + app.alert({ message: 'Sample ' + sample.NAME + ' already exists' }) + this.valid = false + } + }, this) + + var existingSampleGroups = this.sampleGroups.pluck('NAME') + _.each(samples, function(sample) { + if (existingSampleGroups.indexOf(sample.SAMPLEGROUPNAME) > -1) { + app.alert({ message: 'Sample group ' + sample.SAMPLEGROUPNAME + ' already exists' }) + this.valid = false + } + }, this) + + if (!this.valid) { + return + } + + this.samples.reset(samples) + + this.containers.reset(_.map(_.unique(_.pluck(samples, 'CONTAINER')), function(name) { + var sample = this.samples.findWhere({ CONTAINER: name }) + + var ownerid = null + if (sample) { + var oid = this.users.findWhere({ FULLNAME: sample.get('OWNER') }) + if (oid) ownerid = oid.get('PERSONID') + } + + return { NAME: name, DEWARID: this.dewars.at(0).get('DEWARID'), OWNERID: ownerid } + }, this)) + + }, + + onRender: function() { + $.when.apply($, this.ready).done(this.doOnRender.bind(this)) + this.rmessages.show(new MessagesView({ collection: this.messages })) + }, + + doOnRender: function() { + // this.parseCSVContents(this.csvProfile.exampleCSV) + this.ui.csvmessage.html(app.config.csv_message) + + this.rcontainers.show(new ContainersView({ + collection: this.containers, + samples: this.samples, + profile: this.csvProfile, + containerregistry: this.containerregistry, + users: this.users, + proteins: this.proteins, + })) + }, + + }) + +}) diff --git a/client/src/js/templates/shipment/fromcsv.html b/client/src/js/templates/shipment/fromcsv.html new file mode 100644 index 000000000..ebd920efc --- /dev/null +++ b/client/src/js/templates/shipment/fromcsv.html @@ -0,0 +1,23 @@ +

<%-SHIPPINGNAME%>: Import from CSV

+ + + + + + +

Messages

+
+
+ +
+ +
+ +
diff --git a/client/src/js/templates/shipment/fromcsvcontainer.html b/client/src/js/templates/shipment/fromcsvcontainer.html new file mode 100644 index 000000000..61fad84fa --- /dev/null +++ b/client/src/js/templates/shipment/fromcsvcontainer.html @@ -0,0 +1,4 @@ +
+ Registered Container: + Owner: +
diff --git a/client/src/js/templates/shipment/fromcsvtable.html b/client/src/js/templates/shipment/fromcsvtable.html new file mode 100644 index 000000000..696e046e5 --- /dev/null +++ b/client/src/js/templates/shipment/fromcsvtable.html @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/js/templates/shipment/shipment.html b/client/src/js/templates/shipment/shipment.html index 20c0e4f83..b06c8ef33 100644 --- a/client/src/js/templates/shipment/shipment.html +++ b/client/src/js/templates/shipment/shipment.html @@ -36,6 +36,10 @@

Shipment: <%-SHIPPI Print Shipment Labels Print Contents + + <% if (app.config.csv_profile) { %> + Import CSV + <% } %> <% } %>