diff --git a/resources/queries/targetedms/InstrumentBilling.sql b/resources/queries/targetedms/InstrumentBilling.sql new file mode 100644 index 000000000..debe1ba72 --- /dev/null +++ b/resources/queries/targetedms/InstrumentBilling.sql @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +SELECT + Project.Id AS ProjectID, + Project.LabDirector AS LabDirector, + InstrumentOperator.DisplayName AS Researcher, + i.Instrument.Name AS Instrument, + Name AS RequestedBy, + i.Id AS UsageBlockID, + StartTime AS StartDate, + EndTime AS EndDate, + TIMESTAMPDIFF('SQL_TSI_HOUR', StartTime, EndTime) AS Hours, + ir.Fee, + ir.rateType.setupFee AS Setup_Cost, + TIMESTAMPDIFF('SQL_TSI_HOUR', StartTime, EndTime) * ir.Fee + ir.rateType.setupFee AS TotalCost, + iup.PaymentMethod.UWBudgetNumber AS Payment_Method, + iup.PaymentMethod.Name AS Payment_Method_Name, + iup.PercentPayment + +FROM targetedms.InstrumentSchedule i +LEFT OUTER JOIN targetedms.InstrumentRate ir ON i.Instrument = ir.Instrument +LEFT OUTER JOIN targetedms.InstrumentUsagePayment iup ON i.Id = iup.InstrumentScheduleId \ No newline at end of file diff --git a/resources/queries/targetedms/InstrumentBillingByMonth.sql b/resources/queries/targetedms/InstrumentBillingByMonth.sql new file mode 100644 index 000000000..07021015c --- /dev/null +++ b/resources/queries/targetedms/InstrumentBillingByMonth.sql @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +PARAMETERS(StartBillDate TIMESTAMP) +SELECT + ProjectID, + LabDirector, + Researcher, + Instrument, + RequestedBy, + UsageBlockID, + StartDate, + EndDate, + Hours, + HoursInRange, + Fee, + Setup_Cost, + TotalCost, + Payment_Method, + Payment_Method_Name, + PercentPayment, + ((HoursInRange * Fee + Setup_Cost) * PercentPayment / 100) AS AmountBilled + +FROM + (SELECT + *, + TIMESTAMPDIFF('SQL_TSI_HOUR', + CASE WHEN StartDate <= StartBillDate THEN StartBillDate ELSE StartDate END, + CASE WHEN EndDate >= TIMESTAMPADD('SQL_TSI_MONTH', 1, StartBillDate) THEN TIMESTAMPADD('SQL_TSI_MONTH', 1, StartBillDate) ELSE EndDate END + ) AS HoursInRange + + FROM targetedms.InstrumentBilling i + WHERE (StartDate >= StartBillDate AND StartDate <= TIMESTAMPADD('SQL_TSI_MONTH', 1, StartBillDate)) OR + (EndDate >= StartBillDate AND EndDate <= TIMESTAMPADD('SQL_TSI_MONTH', 1, StartBillDate)) OR + (StartDate <= StartBillDate AND EndDate >= TIMESTAMPADD('SQL_TSI_MONTH', 1, StartBillDate)) + ) X \ No newline at end of file diff --git a/resources/queries/targetedms/instrumentBilling.query.xml b/resources/queries/targetedms/instrumentBilling.query.xml new file mode 100644 index 000000000..736fbee64 --- /dev/null +++ b/resources/queries/targetedms/instrumentBilling.query.xml @@ -0,0 +1,16 @@ + + + + + + + 0.0 + + + $#,##0.00 + + +
+
+
+
\ No newline at end of file diff --git a/resources/queries/targetedms/instrumentBilling/Project View.qview.xml b/resources/queries/targetedms/instrumentBilling/Project View.qview.xml new file mode 100644 index 000000000..020cce962 --- /dev/null +++ b/resources/queries/targetedms/instrumentBilling/Project View.qview.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/queries/targetedms/instrumentBillingByMonth.query.xml b/resources/queries/targetedms/instrumentBillingByMonth.query.xml new file mode 100644 index 000000000..78f428b89 --- /dev/null +++ b/resources/queries/targetedms/instrumentBillingByMonth.query.xml @@ -0,0 +1,16 @@ + + + + + + + 0.0 + + + $#,##0.00 + + +
+
+
+
\ No newline at end of file diff --git a/resources/queries/targetedms/instrumentSchedule.query.xml b/resources/queries/targetedms/instrumentSchedule.query.xml new file mode 100644 index 000000000..a58fe1be7 --- /dev/null +++ b/resources/queries/targetedms/instrumentSchedule.query.xml @@ -0,0 +1,18 @@ + + + + + + + + core + Users + UserId + DisplayName + + + +
+
+
+
\ No newline at end of file diff --git a/resources/queries/targetedms/msProject.query.xml b/resources/queries/targetedms/msProject.query.xml index 87c48239f..27dfab12f 100644 --- a/resources/queries/targetedms/msProject.query.xml +++ b/resources/queries/targetedms/msProject.query.xml @@ -6,6 +6,9 @@ /targetedms-msProjectDetails.view?project=${Id} + + /targetedms-msProjectDetails.view?project=${Id} + diff --git a/resources/queries/targetedms/paymentMethodCostSummary.query.xml b/resources/queries/targetedms/paymentMethodCostSummary.query.xml deleted file mode 100644 index a64f1bf9d..000000000 --- a/resources/queries/targetedms/paymentMethodCostSummary.query.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - $0.00 - - - $0.00 - - - $0.00 - - -
-
-
-
\ No newline at end of file diff --git a/resources/queries/targetedms/paymentMethodCostSummary.sql b/resources/queries/targetedms/paymentMethodCostSummary.sql deleted file mode 100644 index 037aa498b..000000000 --- a/resources/queries/targetedms/paymentMethodCostSummary.sql +++ /dev/null @@ -1,17 +0,0 @@ -SELECT - p.Id as project @hidden, - pm.UWBudgetNumber, - pm.name, - pm.isCurrent, - pm.budgetExpirationDate, - (SUM(iup.percentPayment * rt.setupFee) / 100) AS SetupFee, - timestampdiff('SQL_TSI_HOUR', isc.startTime, isc.endTime) * (SUM(iup.percentPayment * ir.fee) / 100) AS InstrumentCost, - timestampdiff('SQL_TSI_HOUR', isc.startTime, isc.endTime) * (SUM(iup.percentPayment * ir.fee) / 100) + (SUM(iup.percentPayment * rt.setupFee) / 100) AS TotalCost -FROM paymentmethod pm -INNER JOIN projectPaymentMethod ppm ON ppm.paymentMethod = pm.id -INNER JOIN msProject p ON ppm.project = p.id -LEFT JOIN instrumentUsagePayment iup ON iup.paymentMethod = pm.id -LEFT JOIN instrumentSchedule isc ON iup.instrumentScheduleId = isc.id -LEFT JOIN instrumentRate ir ON isc.instrument = ir.instrument -LEFT JOIN rateType rt ON ir.rateType = rt.id -GROUP BY p.title, pm.UWBudgetNumber, pm.name, pm.isCurrent, pm.budgetExpirationDate, rt.setupFee,ir.fee, isc.startTime, isc.endTime, p.Id \ No newline at end of file diff --git a/resources/queries/targetedms/userProjects.query.xml b/resources/queries/targetedms/userProjects.query.xml index b8286a724..04a9b3ec4 100644 --- a/resources/queries/targetedms/userProjects.query.xml +++ b/resources/queries/targetedms/userProjects.query.xml @@ -3,10 +3,6 @@ - - - /targetedms-msProjectDetails.view?project=${Id} -
diff --git a/resources/queries/targetedms/userProjects.sql b/resources/queries/targetedms/userProjects.sql index b6e1989e4..3eacfeda2 100644 --- a/resources/queries/targetedms/userProjects.sql +++ b/resources/queries/targetedms/userProjects.sql @@ -1,9 +1,6 @@ SELECT - '[View]' AS ViewLink, p.id AS Id, p.title AS Title, p.submitDate AS SubmitDate, p.collaborationStatus AS CollaborationStatus -FROM projectResearcher pr -LEFT JOIN msProject p ON pr.project = p.id -GROUP BY p.id, p.title, p.submitDate, p.collaborationStatus \ No newline at end of file +FROM msProject p WHERE p.id IN (SELECT project FROM projectresearcher pr WHERE pr.researcher = USERID()) diff --git a/resources/schemas/targetedms.xml b/resources/schemas/targetedms.xml index dc2deebbd..fac2bdef5 100644 --- a/resources/schemas/targetedms.xml +++ b/resources/schemas/targetedms.xml @@ -1764,6 +1764,7 @@ + A project used for scheduling and billing instruments @@ -1786,7 +1787,9 @@ - + + true + @@ -1795,12 +1798,15 @@
+ Instruments that can be scheduled and whose usage can be billed - + + HTML/CSS color description. Can be RGB like #00ee00 or a named color like "red" + @@ -1810,9 +1816,13 @@
- +
+ + true + text + @@ -1830,7 +1840,6 @@ - @@ -1858,6 +1867,7 @@
+ Scheduled usage of a particular instrument for particular project @@ -1879,7 +1889,9 @@ - + + $#,##0.00 + @@ -1892,7 +1904,9 @@ - + + $#,##0.00 + diff --git a/resources/views/instrumentSchedulingAdmin.html b/resources/views/instrumentSchedulingAdmin.html new file mode 100644 index 000000000..af6892462 --- /dev/null +++ b/resources/views/instrumentSchedulingAdmin.html @@ -0,0 +1,17 @@ + + + +
 
+ + + + +
 
+ +
Edit instrument list (resources that can be scheduled and billed for)
+
Edit rate types (setup fee)
+
Edit rate list (assigns a setup fee and an hourly rate to instruments)
+ +
Edit project/researcher mapping (attaches users to projects)
+ + \ No newline at end of file diff --git a/resources/views/instrumentSchedulingAdmin.view.xml b/resources/views/instrumentSchedulingAdmin.view.xml new file mode 100644 index 000000000..214a75e92 --- /dev/null +++ b/resources/views/instrumentSchedulingAdmin.view.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/views/instrumentSchedulingAdmin.webpart.xml b/resources/views/instrumentSchedulingAdmin.webpart.xml new file mode 100644 index 000000000..43e1775ce --- /dev/null +++ b/resources/views/instrumentSchedulingAdmin.webpart.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/resources/views/msProject.html b/resources/views/msProject.html index 7605e1564..b89ba8ae7 100644 --- a/resources/views/msProject.html +++ b/resources/views/msProject.html @@ -1,19 +1,20 @@
+
+
\ No newline at end of file diff --git a/resources/views/msProject.view.xml b/resources/views/msProject.view.xml index e9268618f..ac0db6c81 100644 --- a/resources/views/msProject.view.xml +++ b/resources/views/msProject.view.xml @@ -1,4 +1,4 @@ - + diff --git a/resources/views/msProjectDetails.html b/resources/views/msProjectDetails.html index c0ee20966..401305d45 100644 --- a/resources/views/msProjectDetails.html +++ b/resources/views/msProjectDetails.html @@ -1,10 +1,11 @@
-
+
-
+ + + + + + + + + + + + + + + + + + + + +
[Add Payment Method]
@@ -33,26 +44,26 @@
-
+
-
to
+
- +
- +
- +
- +
- +
@@ -92,21 +103,21 @@
- +
- +
- +
- +
- +
- +
@@ -145,11 +156,9 @@

Time Scheduled


-

Click here to view all the time scheduled for this project.

-

View the billing FAQ and instructions for scheduling instruction time.


-

View the current rates for instruments.

+

@@ -161,9 +170,12 @@

Time Scheduled

let project = LABKEY.ActionURL.getParameter('project'); let instrument = LABKEY.ActionURL.getParameter('instrument'); + // Parse as integers, defaulting to null if empty/invalid + project = project ? parseInt(project) || null : null; + instrument = instrument ? parseInt(instrument) || null : null; if (!project) { - alert('No project selected'); + document.getElementById('calendar').innerText = 'Invalid or no project specified'; return; } @@ -174,21 +186,24 @@

Time Scheduled

LABKEY.Query.selectRows({ schemaName: 'targetedms', queryName: 'msProject', - sort: 'title', - columns: 'Id,title', + sort: 'Title', + columns: 'Id,Title', scope: this, success: function (result) { let rows = result.rows; - let projectDropDown = ''; - projectDropDown += ''; - - jQuery('#projectDropDown').html(projectDropDown); - } }); @@ -203,14 +218,21 @@

Time Scheduled

scope: this, success: function (result) { let rows = result.rows; - let instrumentDropDown = ''; - instrumentDropDown += ''; - jQuery('#instrumentDropDown').html(instrumentDropDown); + loadInstrumentSchedule(function(instrumentSchedule) { var calendarEl = document.getElementById('calendar'); @@ -228,11 +250,15 @@

Time Scheduled

eventContent: function(e) { let content = ''; - let bgColor = (e.event.extendedProps.project == project ? e.backgroundColor : 'gray'); - let textColor = getContrastTextColor(stringToColor(bgColor)); - let dateStr = DateFormat.format.date(e.event.start, LABKEY.container.formats.timeFormat) + ' - ' + DateFormat.format.date(e.event.end, LABKEY.container.formats.timeFormat); - let style = 'background-color: ' + bgColor + '; color: ' + textColor + ';' + 'width: 100%'; - content += '
' + let bgColor = e.event.extendedProps.project === project ? e.backgroundColor : 'gray'; + const cl = e.event.extendedProps.project === project ? 'activeProjectEvent' : 'otherProjectEvent'; + let textColor = ScheduleUtils.getContrastTextColor(ScheduleUtils.stringToColor(bgColor)); + let timeFormatString = LABKEY.container.formats.timeFormat; + // Strip seconds and milliseconds + timeFormatString = timeFormatString.replace(':ss', '').replace('.SSS', ''); + let dateStr = DateFormat.format.date(e.event.start, timeFormatString) + ' - ' + DateFormat.format.date(e.event.end, timeFormatString); + let style = 'background-color: ' + LABKEY.Utils.encodeHtml(bgColor) + '; color: ' + LABKEY.Utils.encodeHtml(textColor) + ';' + 'width: 100%'; + content += '
' + '
' + LABKEY.Utils.encodeHtml(dateStr) + '
' + '
' + 'Project :  ' + LABKEY.Utils.encodeHtml(e.event.extendedProps.project) + '
' + '
'; @@ -240,7 +266,7 @@

Time Scheduled

}, eventMouseEnter: function (mouseEnterInfo) { removeEventLog(); - let instrumentID = instrument ? instrument : $('#instrumentDropDown select[name="instrumentDropDown"]').val(); + let instrumentID = instrument ? instrument : $('#instrumentDropDown').val(); fetchInstrumentCosts(instrumentID, mouseEnterInfo.event.start, mouseEnterInfo.event.end, false); let content = ''; let dateStr = DateFormat.format.date(mouseEnterInfo.event.start, LABKEY.container.formats.dateTimeFormat) + ' - ' + DateFormat.format.date(mouseEnterInfo.event.end, LABKEY.container.formats.dateTimeFormat); @@ -248,10 +274,6 @@

Time Scheduled

+ '
' + 'Project Id :  ' + LABKEY.Utils.encodeHtml(mouseEnterInfo.event.extendedProps.project) + '
' + '
' + 'Title :  ' + LABKEY.Utils.encodeHtml(mouseEnterInfo.event.title) + '
' + '
' + LABKEY.Utils.encodeHtml(dateStr) + '
' - - + '[Delete]
' - + '[Edit]
' - + '[Edit Project]
' + '
'; @@ -324,9 +346,7 @@

Time Scheduled

success: function (result) { let rows = result.rows; paymentMethodsData = rows; - let paymentMethodDropDown = '

Select Payment Method(s):

'+ - ''; - paymentMethodDropDown += ''; for (let i = 0; i < rows.length; i++) { paymentMethodDropDown += ''; } @@ -344,57 +364,40 @@

Time Scheduled

schemaName: 'targetedms', queryName: 'projectResearcher', sort: 'name', - columns: 'Id,researcher/displayName, project', + columns: 'Id,researcher/displayName,researcher/UserId,project', filterArray: [ - LABKEY.Filter.create('project', project, LABKEY.Filter.Types.EQUAL) + LABKEY.Filter.create('project', project, LABKEY.Filter.Types.EQUAL), + LABKEY.Filter.create('researcher/UserId', project, LABKEY.Filter.Types.NONBLANK) ], scope: this, success: function (result) { let rows = result.rows; - let instrumentOperatorDropDown = ''; - instrumentOperatorDropDown += ''; - jQuery('#instrumentOperatorDropDown').html(instrumentOperatorDropDown); } }); - function stringToColor(color) { - // Create a temporary DOM element - const tempElement = document.createElement('div'); - tempElement.style.color = color; - document.body.appendChild(tempElement); - - // Get the computed color value - const computedColor = window.getComputedStyle(tempElement).color; - document.body.removeChild(tempElement); - - // Convert RGB to HEX - const rgbValues = computedColor.match(/\d+/g).map(Number); - if (rgbValues.length === 3) { - const [r, g, b] = rgbValues; - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; - } - - return null; // Return null if conversion fails - } - - function getContrastTextColor(hexcolor){ - var r = parseInt(hexcolor.substring(1,3),16); - var g = parseInt(hexcolor.substring(3,5),16); - var b = parseInt(hexcolor.substring(5,7),16); - var yiq = ((r*299)+(g*587)+(b*114))/1000; - return (yiq >= 128) ? 'black' : 'white'; - } function editEvent(event) { let startDate = event.startDate; let endDate = event.endDate; - if (!event.id) { - // for new events subtract 1 minute from the end date to show the end date in the date picker - endDate = new Date(endDate.getTime() - 60000); + + if (!event.id && startDate.getHours() === 0 && startDate.getMinutes() === 0 && endDate.getHours() === 0 && endDate.getMinutes() === 0) { + // Default to starting at 8AM and ending at 5PM + startDate.setHours(8); + endDate.setHours(17); + endDate.setDate(endDate.getDate() - 1); } let startDateFormatted = DateFormat.format.date(startDate, LABKEY.container.formats.dateTimeFormat); @@ -409,7 +412,7 @@

Time Scheduled

$('#event-modal input[name="event-name"]').val(event ? event.name : ''); $('#event-modal input[name="event-notes"]').val(event ? event.notes : ''); $('#delete-event').toggle(!!event.id); - $('#add-event').text(event.id ? 'Edit' : 'Add'); + $('#add-event').text('Save'); $('#event-modal').modal(); } @@ -424,10 +427,10 @@

Time Scheduled

eventToSave.startTime = $('#event-modal input[name="event-start-date"]').val(); eventToSave.endTime = $('#event-modal input[name="event-end-date"]').val(); eventToSave.project = project; - eventToSave.instrument = instrument ? instrument : $('#instrumentDropDown select[name="instrumentDropDown"]').val(); + eventToSave.instrument = instrument ? instrument : $('#instrumentDropDown').val(); eventToSave.name = $('#event-modal input[name="event-name"]').val(); eventToSave.notes = $('#event-modal input[name="event-notes"]').val(); - eventToSave.instrumentoperator = $('#instrumentOperatorDropDown select[name="instrumentOperatorDropDown"]').val(); + eventToSave.instrumentoperator = $('#instrumentOperatorDropDown').val(); if (eventToSave.endTime < eventToSave.startTime) { alert('End date must be after start date'); @@ -437,7 +440,7 @@

Time Scheduled

const events = calendar.getEvents(); // validate that the events time do not overlap with existing events for (let i = 0; i < events.length; i++) { - if (eventToSave.id != events[i].id) { + if (eventToSave.id !== events[i].id) { let newEvent = { start: new Date(eventToSave.startTime), end: new Date(eventToSave.endTime) @@ -685,12 +688,13 @@

Time Scheduled

function loadInstrumentSchedule(callback) { let instrumentSchedule = []; + const currentInstrument = instrument ? instrument : parseInt($('#instrumentDropDown').val()); LABKEY.Query.selectRows({ schemaName: 'targetedms', queryName: 'instrumentSchedule', - columns: 'Id,startTime,endTime,name,notes,instrument/color,project/Id, project/title', + columns: 'Id,startTime,endTime,name,notes,instrument/color,instrument/Id,project/Id, project/Title', filterArray: [ - LABKEY.Filter.create('instrument', instrument ? instrument : $('#instrumentDropDown select[name="instrumentDropDown"]').val()), + LABKEY.Filter.create('instrument', instrument ? instrument : $('#instrumentDropDown').val()), ], success: function (data) { let rows = data.rows; @@ -704,7 +708,7 @@

Time Scheduled

notes: row.notes, color: row['instrument/color'], project: row['project/Id'], - title: row['project/title'] + title: row['project/Title'] }); } callback(instrumentSchedule); @@ -728,10 +732,6 @@

Time Scheduled

deleteEvent(); }); - $('#event-delete').click(function () { - deleteEvent(); - }); - $('#close-schedule').click(function () { $('#schedule-save-error').text(''); $('#event-modal').modal('hide'); @@ -745,8 +745,7 @@

Time Scheduled

addPaymentMethodEl.addEventListener('click', function () { paymentMethodCount++; let id = 'paymentMethodDropDown' + paymentMethodCount; - let paymentMethodDropDown = ''; - paymentMethodDropDown += ''; for (let i = 0; i < paymentMethodsData.length; i++) { paymentMethodDropDown += ''; } @@ -772,17 +771,16 @@

Time Scheduled

}); // refresh the page on project change - document.getElementById('projectDropDown').addEventListener('change', function (event) { - document.location.href = LABKEY.ActionURL.buildURL('targetedms', 'scheduleInstrument', null, {project: event.target.value}); - + document.getElementById('projectDropDown').addEventListener('change', function () { + document.location.href = LABKEY.ActionURL.buildURL('targetedms', 'scheduleInstrument', null, {project: document.getElementById('projectDropDown').value}); }); // refresh the page on instrument change - document.getElementById('instrumentDropDown').addEventListener('change', function (event) { - document.location.href = LABKEY.ActionURL.buildURL('targetedms', 'scheduleInstrument', null, {project: LABKEY.ActionURL.getParameter('project'), instrument: event.target.value}); + document.getElementById('instrumentDropDown').addEventListener('change', function () { + document.location.href = LABKEY.ActionURL.buildURL('targetedms', 'scheduleInstrument', null, {project: LABKEY.ActionURL.getParameter('project'), instrument: document.getElementById('instrumentDropDown').value}); }); - document.getElementById('instrument-rates').setAttribute('href', LABKEY.ActionURL.buildURL('query', 'executeQuery', LABKEY.ActionURL.getContainer(), {schemaName: 'targetedms', 'query.queryName': 'InstrumentRate'})); + document.getElementById('instrument-rates').innerHTML = LABKEY.Utils.textLink({text: 'View the current rates for instruments', href: LABKEY.ActionURL.buildURL('query', 'executeQuery', LABKEY.ActionURL.getContainer(), {schemaName: 'targetedms', 'query.queryName': 'InstrumentRate'})}); }); diff --git a/resources/views/scheduleInstrument.view.xml b/resources/views/scheduleInstrument.view.xml index c45623167..66bbcc043 100644 --- a/resources/views/scheduleInstrument.view.xml +++ b/resources/views/scheduleInstrument.view.xml @@ -3,5 +3,6 @@ + \ No newline at end of file diff --git a/src/org/labkey/targetedms/view/CrossLinkedPeptideInfo.java b/src/org/labkey/targetedms/view/CrossLinkedPeptideInfo.java index 2293437be..c8774a0c4 100644 --- a/src/org/labkey/targetedms/view/CrossLinkedPeptideInfo.java +++ b/src/org/labkey/targetedms/view/CrossLinkedPeptideInfo.java @@ -189,12 +189,13 @@ public List findMatches(List proteins) do { index = proteinSequence.indexOf(getUnmodified(), index + 1); - if (index >= 0) + if (index != -1 && + (index == 0 || proteinSequence.charAt(index - 1) == 'K' || proteinSequence.charAt(index - 1) == 'R')) { result.add(new Match(protein, index)); } } - while (index > 0); + while (index >= 0); } } return result; @@ -255,6 +256,37 @@ public void testOmittedIndices() Assert.assertEquals("Link location", Set.of(9), i.getExtraSequences().get(0).getLinkIndices()); Assert.assertEquals("Link location", Set.of(4, 7, 0, 1), i.getExtraSequences().get(4).getLinkIndices()); } + + @Test + public void testTrypticMatches() + { + CrossLinkedPeptideInfo info = new CrossLinkedPeptideInfo("PEPTIDE"); + PeptideSequence seq = info.getBaseSequence(); + + // Single protein containing multiple occurrences of PEPTIDE: + // - at start (allowed) + // - preceded by A (disallowed) + // - preceded by K (allowed) + // - preceded by R (allowed) + Protein protein = new Protein(); + String proteinSeq = "PEPTIDE" + "APEPTIDE" + "KPEPTIDE" + "RPEPTIDE"; + protein.setSequence(proteinSeq); + + List proteins = Arrays.asList(protein); + List matches = seq.findMatches(proteins); + + // Expect three valid matches: indices 0 (start), 16 (after K), 24 (after R) + Assert.assertEquals("Should find three valid matches within a single protein sequence", 3, matches.size()); + + Assert.assertSame("First match should be at start (index 0)", protein, matches.get(0).protein()); + Assert.assertEquals(0, matches.get(0).index()); + + Assert.assertSame("Second match should be after K at index 16", protein, matches.get(1).protein()); + Assert.assertEquals(16, matches.get(1).index()); + + Assert.assertSame("Third match should be after R at index 24", protein, matches.get(2).protein()); + Assert.assertEquals(24, matches.get(2).index()); + } } public class Linker diff --git a/test/sampledata/TargetedMS/CrosslinkPeptideMapTest.sky.zip b/test/sampledata/TargetedMS/CrosslinkPeptideMapTest.sky.zip index 37bf5fc5b..287b2780f 100644 Binary files a/test/sampledata/TargetedMS/CrosslinkPeptideMapTest.sky.zip and b/test/sampledata/TargetedMS/CrosslinkPeptideMapTest.sky.zip differ diff --git a/test/src/org/labkey/test/tests/targetedms/InstrumentSchedulingTest.java b/test/src/org/labkey/test/tests/targetedms/InstrumentSchedulingTest.java new file mode 100644 index 000000000..f173a348d --- /dev/null +++ b/test/src/org/labkey/test/tests/targetedms/InstrumentSchedulingTest.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2016-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.test.tests.targetedms; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.InsertRowsCommand; +import org.labkey.remoteapi.security.WhoAmICommand; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.Locator; +import org.labkey.test.TestTimeoutException; +import org.labkey.test.util.APIContainerHelper; +import org.labkey.test.util.ApiPermissionsHelper; +import org.labkey.test.util.PermissionsHelper; +import org.labkey.test.util.PortalHelper; +import org.labkey.test.util.PostgresOnlyTest; +import org.openqa.selenium.support.ui.ExpectedConditions; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Calendar; +import java.util.List; +import java.util.Map; + +@Category({}) +public class InstrumentSchedulingTest extends TargetedMSTest implements PostgresOnlyTest +{ + protected static final String SCHEDULER_USER_1 = "scheduler1@targetedms.test"; + protected static final String SCHEDULER_USER_2 = "scheduler2@targetedms.test"; + + public static final String INSTRUMENT_1 = "Instrument1"; + public static final String INSTRUMENT_2 = "Instrument2"; + public static final String INACTIVE_INSTRUMENT = "InactiveInstrument"; + public static final String PROJECT_1 = "Project1"; + public static final String PROJECT_2 = "Project2"; + public static final Locator.IdLocator EVENT_NAME_FIELD = Locator.id("event-name"); + public static final Locator.IdLocator EVENT_NOTE_FIELD = Locator.id("event-notes"); + + @BeforeClass + public static void initProject() throws IOException, CommandException + { + InstrumentSchedulingTest init = getCurrentTest(); + init.doInit(); + } + + private void doInit() throws IOException, CommandException + { + setupFolder(FolderType.Experiment); + new PortalHelper(this).addWebPart("Instrument Scheduling Admin"); + + int schedulerUser1Id = _userHelper.createUser(SCHEDULER_USER_1).getUserId(); + int schedulerUser2Id = _userHelper.createUser(SCHEDULER_USER_2).getUserId(); + ApiPermissionsHelper apiPermissionsHelper = new ApiPermissionsHelper(this); + apiPermissionsHelper.addMemberToRole(SCHEDULER_USER_1, "Editor", PermissionsHelper.MemberType.user); + apiPermissionsHelper.addMemberToRole(SCHEDULER_USER_2, "Editor", PermissionsHelper.MemberType.user); + + InsertRowsCommand instrumentInsert = new InsertRowsCommand("targetedms", "msInstrument"); + instrumentInsert.setRows(Arrays.asList( + Map.of("Name", INSTRUMENT_1, "Active", true, "Color", "#ee0000"), + Map.of("Name", INSTRUMENT_2, "Active", true, "Color", "#00ee00"), + Map.of("Name", INACTIVE_INSTRUMENT, "Active", false, "Color", "#0000ee") + )); + List> instruments = instrumentInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + InsertRowsCommand projectInsert = new InsertRowsCommand("targetedms", "msProject"); + projectInsert.setRows(Arrays.asList( + Map.of("Affiliation", "LabKey", "Title", PROJECT_1, "SubmitDate", "1/1/2025", "CollaborationWith", "Mike", "ScientificQuestion", "Why do I have to enter this?", "abstract", "b"), + Map.of("Affiliation", "UW", "Title", PROJECT_2, "SubmitDate", "2/2/2025", "CollaborationWith", "Josh", "ScientificQuestion", "Why is the sky blue?", "abstract", "a") + )); + List> projects = projectInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + InsertRowsCommand rateTypeInsert = new InsertRowsCommand("targetedms", "rateType"); + rateTypeInsert.setRows(Arrays.asList( + Map.of("Name", "DefaultRate", "SetupFee", 50), + Map.of("Name", "BigSpenderRate", "SetupFee", 50) + )); + List> rateTypes = rateTypeInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + InsertRowsCommand paymentMethodInsert = new InsertRowsCommand("targetedms", "paymentMethod"); + paymentMethodInsert.setRows(Arrays.asList( + Map.of("UWBudgetNumber", "1111", "Name", "PaymentMethod1"), + Map.of("UWBudgetNumber", "2222", "Name", "PaymentMethod2") + )); + List> paymentMethods = paymentMethodInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + InsertRowsCommand projectPaymentMethodInsert = new InsertRowsCommand("targetedms", "projectPaymentMethod"); + projectPaymentMethodInsert.setRows(Arrays.asList( + Map.of("PaymentMethod", paymentMethods.get(0).get("Id"), "Project", projects.get(0).get("Id")), + Map.of("PaymentMethod", paymentMethods.get(1).get("Id"), "Project", projects.get(1).get("Id")) + )); + List> projectPaymentMethods = projectPaymentMethodInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + int currentUserId = new WhoAmICommand().execute(createDefaultConnection(), getProjectName()).getUserId().intValue(); + + InsertRowsCommand projectResearcherInsert = new InsertRowsCommand("targetedms", "projectResearcher"); + projectResearcherInsert.setRows(Arrays.asList( + Map.of("Project", projects.get(0).get("Id"), "Researcher", schedulerUser1Id), + Map.of("Project", projects.get(1).get("Id"), "Researcher", schedulerUser1Id), + Map.of("Project", projects.get(1).get("Id"), "Researcher", schedulerUser2Id), + Map.of("Project", projects.get(0).get("Id"), "Researcher", currentUserId), + Map.of("Project", projects.get(1).get("Id"), "Researcher", currentUserId) + )); + List> projectResearchers = projectResearcherInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + + InsertRowsCommand instrumentRateInsert = new InsertRowsCommand("targetedms", "instrumentRate"); + instrumentRateInsert.setRows(Arrays.asList( + Map.of("Instrument", instruments.get(0).get("Id"), "rateType", rateTypes.get(0).get("Id"), "fee", 100), + Map.of("Instrument", instruments.get(1).get("Id"), "rateType", rateTypes.get(1).get("Id"), "fee", 110) + )); + List> instrumentRates = instrumentRateInsert.execute(createDefaultConnection(), getProjectName()).getRows(); + } + + @Test + public void testSchedule() + { + goToProjectHome(); + clickAndWait(Locator.linkWithText("Your project list")); + waitForText(PROJECT_1, PROJECT_2); + clickAndWait(Locator.linkWithText(PROJECT_1)); + waitAndClickAndWait(Locator.linkWithText("Schedule instrument time")); + + String yearMonth = Calendar.getInstance().get(Calendar.YEAR) + "-"; + int month = (Calendar.getInstance().get(Calendar.MONTH) + 1); + if (month < 10) + { + yearMonth += yearMonth; + } + yearMonth += month; + + scheduleInstrument(yearMonth + "-02"); + scheduleInstrument(yearMonth + "-03"); + scheduleInstrument(yearMonth + "-03", true); + scheduleInstrument(yearMonth + "-03"); + + assertProjectEventCounts(2, 0); + + sleep(1000); + doAndWaitForPageToLoad(() -> selectOptionByText(Locator.id("projectDropDown"), PROJECT_2)); + + scheduleInstrument(yearMonth + "-04"); + assertProjectEventCounts(1, 2); + + scheduleInstrument(yearMonth + "-05"); + assertProjectEventCounts(2, 2); + + sleep(1000); + doAndWaitForPageToLoad(() -> selectOptionByText(Locator.id("instrumentDropDown"), INSTRUMENT_2)); + scheduleInstrument(yearMonth + "-06"); + assertProjectEventCounts(1, 0); + + goToDashboard(); + waitAndClickAndWait(Locator.linkWithText("All instrument calendar view")); + assertTextPresent(INSTRUMENT_1, INSTRUMENT_2, INACTIVE_INSTRUMENT); + + assertProjectEventCounts(5, 0); + + selectOptionByText(Locator.id("projectFilter"), PROJECT_2); + assertProjectEventCounts(3, 2); + + goToDashboard(); + waitAndClickAndWait(Locator.linkWithText("Instrument billing report")); + assertTextPresent("$950.00", 4); + assertTextPresent("$1,040.00", 1); + + // Future test cases: + // Split payment across multiple methods + // Schedule for hours within a day instead of 24-hour periods + // Check billing for individual months, including reservations that span month boundaries with start/end dates + // Ensure that overlapping reservations are rejected + // Ensure that reservations cannot be made for inactive instruments + } + + private void assertProjectEventCounts(int expectedActiveCount, int expectedOtherCount) + { + Locator activeLocator = Locator.byClass("activeProjectEvent"); + Locator otherLocator = Locator.byClass("otherProjectEvent"); + if (expectedActiveCount > 0) + { + waitForElementToBeVisible(activeLocator); + } + if (expectedOtherCount > 0) + { + waitForElementToBeVisible(otherLocator); + } + assertElementPresent(activeLocator, expectedActiveCount); + assertElementPresent(otherLocator, expectedOtherCount); + } + + private void scheduleInstrument(String yearMonthDay) + { + scheduleInstrument(yearMonthDay, false); + } + + private void scheduleInstrument(String yearMonthDay, boolean delete) + { + waitAndClick(Locator.tagWithAttribute("td", "data-date", yearMonthDay)); + waitForText("Add Instrument Time"); + if (delete) + { + waitAndClick(Locator.button("Delete")); + } + else + { + waitForElementToBeVisible(EVENT_NAME_FIELD); + setFormElement(EVENT_NAME_FIELD.findElement(getDriver()), "A name!"); + setFormElement(EVENT_NOTE_FIELD.findElement(getDriver()), "A note!"); + waitAndClick(Locator.button("Save")); + waitAndClick(Locator.button("Yes")); + } + } + + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + // these tests use the UIContainerHelper for project creation, but we can use the APIContainerHelper for deletion + APIContainerHelper apiContainerHelper = new APIContainerHelper(this); + apiContainerHelper.deleteProject(getProjectName(), afterTest); + + _userHelper.deleteUsers(false, SCHEDULER_USER_1); + _userHelper.deleteUsers(false, SCHEDULER_USER_2); + } +} diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java index 01f5c0a4b..c7f393179 100644 --- a/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java +++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSMAMTest.java @@ -19,6 +19,8 @@ import org.junit.Test; import org.junit.experimental.categories.Category; import org.labkey.test.Locator; +import org.labkey.test.util.DataRegion; +import org.labkey.test.util.DataRegionTable; import static org.junit.Assert.assertTrue; @@ -56,10 +58,13 @@ public void testSteps() assertTextPresent("Chromatograms"); clickAndWait(Locator.linkContainingText("Peptide Map")); + DataRegionTable table = new DataRegionTable("PeptideIds", getDriver()); + table.setPageSize(250); assertTextPresentInThisOrder("11.3", "14.1", "14.8"); assertTextPresentInThisOrder("1501.75", "1078.50", "1547.71"); - assertTextPresentInThisOrder("NU205", "NU205", "1433Z", "UCRI; RL35"); - assertTextPresentInThisOrder("70-84", "325-333", "28-41", "190-196; 26-32"); + assertTextPresentInThisOrder("NU205", "1433Z", "RL35", "HSP72; HSP7C"); + assertTextNotPresent("UCRI; RL35"); // Ensure we don't have non-tryptic matches anymore + assertTextPresentInThisOrder("70-84", "325-333", "28-41", "305-314; 302-311"); assertTextPresentInThisOrder("(K)ASTEGVAIQGQQGTR(L)", "(K)AQYEDIANR(S)", "(K)SVTEQGAELSNEER(N)"); assertTextPresentInThisOrder("Carbamidomethyl Cysteine @ C157", "Carbamidomethyl Cysteine @ C245", "Carbamidomethyl Cysteine @ C94"); @@ -75,15 +80,15 @@ public void testCrossLinkedPeptideMap() clickAndWait(Locator.linkContainingText("Panorama Dashboard")); clickAndWait(Locator.linkContainingText(CROSS_LINKED_SKY_FILE)); - verifyRunSummaryCountsPep(2,3,0, 3,3, 1, 0, 0); + verifyRunSummaryCountsPep(2,2,0, 2,2, 1, 0, 0); clickAndWait(Locator.linkContainingText("Peptide Map")); - assertTextPresentInThisOrder("364-366", "367-369", "364-367"); + assertTextPresentInThisOrder("121-124", "342-345", "142-145"); // Disulfide bonds - assertTextPresentInThisOrder("Q364-T369-D364/\nN366-T369-D364", "V121-S345-Q142/\nQ124-S345-Q142"); - assertTextPresentInThisOrder("(A)LKPLALV(D)", "(G)AVVQDPA(Y)", "(F)YGEATSR(E)"); + assertTextPresentInThisOrder("V121-S345-Q142/\nQ124-S345-Q142", "L11-A137-Y271/\nL11-A137-Y271/\nV17-A137-Y271/"); + assertTextPresentInThisOrder("(K)LKPLALV(D)", "(K)AVVQDPA(Y)", "(R)YGEATSR(E)"); // Ensure that the highlighting is as expected for both crosslinking and modification - assertTrue(getHtmlSource().contains("(Y)QMN(D)")); + assertTrue(getHtmlSource().contains("(R)VSSQ(Q)")); } } diff --git a/webapp/TargetedMS/js/scheduleUtils.js b/webapp/TargetedMS/js/scheduleUtils.js new file mode 100644 index 000000000..c1f92538c --- /dev/null +++ b/webapp/TargetedMS/js/scheduleUtils.js @@ -0,0 +1,58 @@ +// Shared utilities for TargetedMS scheduling pages +// Expose on a namespaced object to avoid globals +(function(window) { + const utils = {}; + + // Convert a CSS color string (named, rgb, hex) to standard 6-digit HEX color (#RRGGBB) + utils.stringToColor = function(color) { + if (!color) return '#888888'; + try { + const tempElement = document.createElement('div'); + tempElement.style.color = color; + document.body.appendChild(tempElement); + const computedColor = window.getComputedStyle(tempElement).color; + document.body.removeChild(tempElement); + const rgbValues = computedColor.match(/\d+/g); + if (rgbValues && rgbValues.length >= 3) { + const r = parseInt(rgbValues[0], 10); + const g = parseInt(rgbValues[1], 10); + const b = parseInt(rgbValues[2], 10); + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b) + .toString(16) + .slice(1) + .toUpperCase(); + } + } catch (e) { + // fall through to default return + } + return '#888888'; + }; + + // Pick black or white text for best contrast over a given hex background color + utils.getContrastTextColor = function(hexColor) { + if (!hexColor) return '#000000'; + const c = hexColor.replace('#', ''); + if (c.length !== 6) return '#000000'; + const r = parseInt(c.substring(0, 2), 16); + const g = parseInt(c.substring(2, 4), 16); + const b = parseInt(c.substring(4, 6), 16); + const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + return yiq >= 128 ? '#000000' : '#FFFFFF'; + }; + + // Return a time-only format string without seconds/millis based on the container setting + utils.getTimeOnlyFormat = function() { + if (window.LABKEY && LABKEY.container?.formats?.timeFormat) { + return LABKEY.container.formats.timeFormat.replace(':ss', '').replace('.SSS', ''); + } + return 'HH:mm'; + }; + + // Format a time range using DateFormat and time-only format + utils.formatTimeRange = function(start, end) { + const fmt = utils.getTimeOnlyFormat(); + return DateFormat.format.date(start, fmt) + ' - ' + DateFormat.format.date(end, fmt); + }; + + window.ScheduleUtils = utils; +})(window);