diff --git a/.gitignore b/.gitignore index cd1cd29659..92d1ed7adf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ logfile*.txt MANIFEST* ./cgi-bin/secretom MYMETA.json +db/delete_nd_experiment_ids/ tags .prove .Rhistory diff --git a/db/00200/AddPopulationToWizard.pm b/db/00200/AddPopulationToWizard.pm new file mode 100644 index 0000000000..19f14a6fe3 --- /dev/null +++ b/db/00200/AddPopulationToWizard.pm @@ -0,0 +1,259 @@ +#!/usr/bin/env perl + + +=head1 NAME + +AddPopulationToWizard.pm + + +=head1 SYNOPSIS + +mx-run AddPopulationUser [options] -H hostname -D dbname -u username [-F] + +this is a subclass of L +see the perldoc of parent class for more details. + +=head1 DESCRIPTION + +This patch: + - Add populations to materilized view + +=head1 AUTHOR + +Chris Simoes + +=head1 COPYRIGHT & LICENSE + +Copyright 2010 Boyce Thompson Institute for Plant Research + +This program is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +package AddPopulationToWizard; + +use Moose; +extends 'CXGN::Metadata::Dbpatch'; + + +has '+description' => ( default => <<'' ); +This patch updates the materialized_phenoview by adding tissue sample to the view + + +sub patch { + my $self=shift; + + print STDOUT "Executing the patch:\n " . $self->name . ".\n\nDescription:\n ". $self->description . ".\n\nExecuted by:\n " . $self->username . " ."; + + print STDOUT "\nChecking if this db_patch was executed before or if previous db_patches have been executed.\n"; + + print STDOUT "\nExecuting the SQL commands.\n"; + + $self->dbh->do(< +see the perldoc of parent class for more details. + +=head1 DESCRIPTION + +This patch adds: +- the cv 'experiment_meeting' +- the cvterm 'meeting_project' in cv 'experiment_meeting' +- the cvterm 'meeting_json' in cv 'project_property' + +This subclass uses L. The parent class uses L + +=head1 AUTHOR + +Chris Simoes + +=head1 COPYRIGHT & LICENSE + +Copyright 2010 Boyce Thompson Institute for Plant Research + +This program is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +package AddExperimentMeetingCvterms; + +use Moose; +use Bio::Chado::Schema; +use Try::Tiny; +use SGN::Model::Cvterm; + +extends 'CXGN::Metadata::Dbpatch'; + +has '+description' => ( default => <<'' ); +This patch adds the cv 'experiment_meeting', the cvterm 'meeting_project' in cv 'experiment_meeting', and the cvterm 'meeting_json' in cv 'project_property' + +has '+prereq' => ( + default => sub { + [], + }, +); + +sub patch { + my $self = shift; + + print STDOUT "Executing the patch:\n " . $self->name . ".\n\nDescription:\n " . $self->description . ".\n\nExecuted by:\n " . $self->username . " ."; + + print STDOUT "\nChecking if this db_patch was executed before or if previous db_patches have been executed.\n"; + print STDOUT "\nExecuting the SQL commands.\n"; + + my $schema = Bio::Chado::Schema->connect( sub { $self->dbh->clone } ); + + print STDERR "INSERTING CVS AND CV TERMS...\n"; + + my $cv_name = 'experiment_meeting'; + + my $cv = $schema->resultset('Cv::Cv')->find({ name => $cv_name }); + if (!$cv) { + $cv = $schema->resultset('Cv::Cv')->create({ name => $cv_name }); + print STDERR "Created cv '$cv_name'\n"; + } + else { + print STDERR "cv '$cv_name' already exists\n"; + } + + my $meeting_project = $schema->resultset('Cv::Cvterm')->find({ + name => 'meeting_project', + cv_id => $cv->cv_id + }); + + if (!$meeting_project) { + $schema->resultset('Cv::Cvterm')->create_with({ + name => 'meeting_project', + cv => $cv_name + }); + print STDERR "Created cvterm 'meeting_project' in cv '$cv_name'\n"; + } + else { + print STDERR "cvterm 'meeting_project' already exists in cv '$cv_name'\n"; + } + + my $project_property_cv = $schema->resultset('Cv::Cv')->find({ name => 'project_property' }); + die "cv 'project_property' does not exist\n" unless $project_property_cv; + + my $meeting_json = $schema->resultset('Cv::Cvterm')->find({ + name => 'meeting_json', + cv_id => $project_property_cv->cv_id + }); + + if (!$meeting_json) { + $schema->resultset('Cv::Cvterm')->create_with({ + name => 'meeting_json', + cv => 'project_property' + }); + print STDERR "Created cvterm 'meeting_json' in cv 'project_property'\n"; + } + else { + print STDERR "cvterm 'meeting_json' already exists in cv 'project_property'\n"; + } + + print "You're done!\n"; +} + +#### +1; # +#### \ No newline at end of file diff --git a/js/source/entries/decision_meeting.js b/js/source/entries/decision_meeting.js new file mode 100644 index 0000000000..1fc8573fd8 --- /dev/null +++ b/js/source/entries/decision_meeting.js @@ -0,0 +1,2780 @@ +(function (jQuery) { + 'use strict'; + jQuery(function ($) { + + window.DM_MEETING_CACHE = window.DM_MEETING_CACHE || {}; + + /* ===== Root detection ===== */ + var $root = + $('#decision_meeting_main').length ? $('#decision_meeting_main') : + $('#decision_table').length ? $('#decision_table').closest('.panel, .box, .card, .well, body') : + $('#meeting_table').length ? $('#meeting_table').closest('.panel, .box, .card, .well, body') : + $(); + + var hasDT = $.fn && $.fn.DataTable; + + function adjustVisibleDataTables() { + if (!$.fn || !$.fn.dataTable) return; + + ['#decision_table', '#meeting_table', '#approvers_table'].forEach(function(sel) { + var $tbl = $(sel); + if (!$tbl.length) return; + if (!$.fn.DataTable.isDataTable($tbl[0])) return; + + var dt = $tbl.DataTable(); + + try { dt.columns.adjust(); } catch (e) {} + try { + if (dt.responsive && typeof dt.responsive.recalc === 'function') { + dt.responsive.recalc(); + } + } catch (e) {} + try { + if (dt.fixedHeader) { + dt.fixedHeader.adjust(); + } + } catch (e) {} + + var $wrap = $tbl.closest('.dataTables_wrapper'); + var $scrollHead = $wrap.find('.dataTables_scrollHeadInner'); + var $scrollHeadTable = $wrap.find('.dataTables_scrollHeadInner table'); + var $scrollBodyTable = $wrap.find('.dataTables_scrollBody table'); + + if ($scrollBodyTable.length && $scrollHead.length && $scrollHeadTable.length) { + var bodyWidth = $scrollBodyTable.outerWidth(); + if (bodyWidth) { + $scrollHead.width(bodyWidth); + $scrollHeadTable.width(bodyWidth); + } + } + }); + } + + var dmResizeTimer = null; + $(window) + .off('resize.dmTables orientationchange.dmTables') + .on('resize.dmTables orientationchange.dmTables', function () { + clearTimeout(dmResizeTimer); + dmResizeTimer = setTimeout(function () { + adjustVisibleDataTables(); + }, 120); + }); + + $(document).on('shown.bs.collapse shown.bs.tab shown.bs.modal', function () { + setTimeout(function () { + adjustVisibleDataTables(); + }, 80); + }); + + function findSectionBody($sec){ + var $b = $sec.find('.panel-body, .detail-section-body, .box-body, .card-body, .well').first(); + return $b.length ? $b : $sec; + } + + /* ----------------------- Client state ---------------------- */ + var STATE = { + list_id: '', + rows: [], + baseRows: [], + accessions: [], + programs: [], + decisionsMap: new Map(), + currentBpFilter: '' + }; + + function injectStyles(){ + if (document.getElementById('dm-decision-styles')) return; + var css = ` + .dm-decision-select { min-width: 130px; } + .dm-dec-none { background-color: #fff; } + .dm-dec-drop { background-color: #f8d7da; color:#842029; } + .dm-dec-hold { background-color: #ffe5b4; color:#7a3e00; } + .dm-dec-advance{ background-color: #d4edda; color:#0f5132; } + .dm-dec-jump { background-color: #e6d4ff; color:#2e1065; } + .dm-bp-header-select { max-width: 220px; margin-top:6px; } + .dm-topbar-search { width:260px; display:inline-block; } + .dm-meeting-check { transform: scale(1.1); } + + .dm-meeting-check[disabled] { + cursor: not-allowed; + opacity: 0.45; + } + + .dm-meeting-select-disabled { + color: #999; + font-size: 12px; + font-style: italic; + display: inline-block; + white-space: nowrap; + } + + .dm-new-stage-cell { + font-weight: 600; + color: #1f4d7a; + } + + .dm-stage-dialog::backdrop { + background: rgba(0,0,0,0.35); + } + + .dm-stage-dialog { + border: none; + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + } + + #summary_table_wrapper .dataTables_filter { + float: right !important; + text-align: right !important; + margin-bottom: 8px; + } + + #summary_table_wrapper .dataTables_filter input { + margin-left: 6px; + min-width: 220px; + } + + #summary_table_wrapper .dataTables_length { + display: none !important; + } + + #summary_table { + width: 100% !important; + } + + #decision_table, + #meeting_table, + #approvers_table { + width: 100% !important; + } + + .dataTables_scrollHeadInner table, + .dataTables_scrollBody table { + margin: 0 !important; + } + + #decision_table_wrapper .dataTables_filter { + float: right !important; + text-align: right !important; + margin-bottom: 8px; + } + + #decision_table_wrapper .dataTables_filter input { + margin-left: 6px; + min-width: 220px; + } + + #decision_table_wrapper .dataTables_length { + display: none !important; + } + + #dm-saveall-wrap { + margin-top: 12px; + margin-bottom: 18px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + + #dm_save_all_btn { + min-width: 170px; + font-weight: 600; + } + + #dm_download_template_btn, + #dm_upload_template_btn { + min-width: 170px; + } + + #dm-upload-hint { + margin-top: 6px; + color: #666; + font-size: 12px; + text-align: right; + } + + .dm-save-dialog::backdrop { + background: rgba(0,0,0,0.35); + } + + .dm-save-dialog { + border: none; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + width: min(1200px, 96vw); + max-width: 1200px; + padding: 0; + } + + .dm-save-dialog-header, + .dm-save-dialog-footer { + padding: 14px 18px; + background: #fff; + } + + .dm-save-dialog-body { + padding: 0 18px 18px 18px; + background: #fff; + } + + .dm-save-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: 10px 16px; + margin-bottom: 14px; + } + + .dm-save-meta-box { + border: 1px solid #ddd; + border-radius: 8px; + padding: 10px 12px; + background: #fafafa; + } + + .dm-save-meta-label { + display: block; + font-size: 12px; + color: #666; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: .03em; + } + + .dm-save-meta-value { + font-size: 14px; + font-weight: 600; + word-break: break-word; + } + + #dm-save-meeting-notes { + min-height: 70px; + resize: vertical; + margin-bottom: 14px; + } + + #dm-save-report-table { + width: 100%; + border-collapse: collapse; + } + + #dm-save-report-table th, + #dm-save-report-table td { + border: 1px solid #ddd; + padding: 8px; + vertical-align: top; + } + + #dm-save-report-table th { + background: #f5f5f5; + position: sticky; + top: 0; + z-index: 1; + } + + .dm-save-report-wrap { + max-height: 52vh; + overflow: auto; + border: 1px solid #ddd; + border-radius: 8px; + } + + .dm-save-comment { + min-width: 220px; + min-height: 62px; + resize: vertical; + } + + .dm-save-empty { + color: #777; + font-style: italic; + } + `; + var el = document.createElement('style'); + el.id = 'dm-decision-styles'; + el.type = 'text/css'; + el.appendChild(document.createTextNode(css)); + document.head.appendChild(el); + } + injectStyles(); + + function injectTopbar(){ + if (!$root.length) return; + var $dest = findSectionBody($root); + if ($('#dm-topbar').length) return; + var html = + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
'; + $dest.prepend(html); + } + injectTopbar(); + + function getSelectedMeetingAttendees(){ + var $checked = $('.dm-meeting-check:checked').first(); + if (!$checked.length) return ''; + + var meetingId = $checked.data('meeting-id'); + if (!meetingId) return ''; + + var cached = window.DM_MEETING_CACHE && window.DM_MEETING_CACHE[String(meetingId)]; + if (cached && cached.attendees_text) { + return $.trim(cached.attendees_text || ''); + } + + var attendeesText = ''; + + $('#meeting_table tbody tr').each(function(){ + var $tr = $(this); + var $chk = $tr.find('.dm-meeting-check').first(); + if (String($chk.data('meeting-id') || '') === String(meetingId)) { + attendeesText = $.trim($tr.children('td').eq(5).text() || ''); + return false; + } + }); + + return attendeesText; + } + + function ensureSaveAllUI(){ + if (!$('#decision_table').length) return; + + if (!$('#dm-saveall-wrap').length) { + var $target = $('#decision_table').closest('.dataTables_wrapper'); + if (!$target.length) { + $target = $('#decision_table').closest('.table-responsive, .panel, .box, .card, .well, div').first(); + } + if ($target.length) { + $target.after( + '
' + + '' + + '' + + '' + + '' + + '
' + ); + $target.after( + '
Excel columns: Accession, Breeding Program, Previous Stage, Decision, New Stage, Notes, Comment.
' + ); + } + } + + if (!document.getElementById('dm_save_dialog')) { + var html = '' + + '' + + '
' + + '
' + + '
' + + '
Decision report before saving
' + + '
Review the meeting data and add comments for each accession before sending to the controller.
' + + '
' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
Meeting
' + + '
Date
' + + '
Attendees
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
AccessionBreeding ProgramPrevious StageDecisionNew StageCurrent NotesMeeting Comments
' + + '
' + + '
' + + ' ' + + '
'; + $('body').append(html); + + $(document) + .off('click.dmSaveDialogClose', '#dm_save_dialog_close_x, #dm_save_dialog_cancel') + .on('click.dmSaveDialogClose', '#dm_save_dialog_close_x, #dm_save_dialog_cancel', function(){ + var dlg = document.getElementById('dm_save_dialog'); + if (dlg) dlg.close(''); + }); + } + } + + async function detectApiBase() { + var hinted = document.querySelector('[data-dm-api-base]')?.getAttribute('data-dm-api-base'); + var candidates = [ + hinted, + '/ajax/decisionmeeting', + '/ajax/decision_meeting', + '/ajax/decision' + ].filter(Boolean); + + for (var i = 0; i < candidates.length; i++) { + var base = candidates[i]; + try { + var r = await fetch(base + '/ping', { headers:{ 'Accept':'application/json' } }); + var ct = r.headers.get('content-type') || ''; + if (!r.ok) continue; + if (!ct.includes('application/json')) continue; + var j = await r.json(); + if (j && (j.ok || j.lists || j.datasets)) return base; + } catch (e) {} + } + return null; + } + + function ajaxJSON(path, params){ + var base = window.DM_API_BASE || ''; + var q = params ? ('?' + new URLSearchParams(params)) : ''; + var url = base + path + q; + + return fetch(url, { headers:{ 'Accept':'application/json' } }).then(async function(r){ + var ct = r.headers.get('content-type') || ''; + if (!r.ok) { + var txt = await r.text().catch(function(){ return ''; }); + throw new Error('HTTP ' + r.status + ' ' + r.statusText + ' on ' + url + '\n' + txt.slice(0, 200)); + } + if (!ct.includes('application/json')) { + var txt2 = await r.text().catch(function(){ return ''; }); + throw new Error('Non-JSON response for ' + url + ': ' + txt2.slice(0, 120) + '…'); + } + return r.json(); + }); + } + + function ensureDatasetControl(){ + var $holder = $('#dataset_select'); + + if ($holder.length) { + if (!$holder.find('select').length) { + $holder.html( + '' + ); + } + return $('#dm_dataset_sel'); + } + + var selectors = [ + '#dm_dataset_sel', + 'select[name="dataset_id"]', + '#dataset_id', + '#dataset', + 'select[data-role="dataset-select"]' + ]; + + return $(selectors.join(', ')).first(); + } + + function ensureSummaryTbody(){ + var $table = $('#summary_table'); + + if (!$table.length) { + return $(); + } + + var $tbody = $table.find('tbody'); + if (!$tbody.length) { + $tbody = $(''); + $table.append($tbody); + } + + return $tbody; + } + + function ensureSummaryDataTable(){ + var $table = $('#summary_table'); + if (!hasDT || !$table.length) return null; + + if ($.fn.DataTable.isDataTable($table[0])) { + return $table.DataTable(); + } + + return $table.DataTable({ + dom: 'ftip', + pageLength: 20, + lengthChange: false, + searching: true, + ordering: true, + info: true, + autoWidth: false, + scrollX: true, + order: [], + drawCallback: function () { + var api = this.api(); + setTimeout(function () { + try { api.columns.adjust(); } catch (e) {} + }, 0); + }, + initComplete: function () { + setTimeout(function () { + try { $table.DataTable().columns.adjust(); } catch (e) {} + }, 0); + } + }); + } + + function renderSummaryTable(rows){ + var $table = $('#summary_table'); + var $tbody = ensureSummaryTbody(); + if (!$table.length || !$tbody.length) return; + + if (hasDT) { + var dt = ensureSummaryDataTable(); + if (!dt) return; + + dt.clear(); + + (rows || []).forEach(function(r){ + dt.row.add([ + escapeHtml(r.accession || ''), + escapeHtml(r.trait || ''), + escapeHtml(r.min == null ? '' : r.min), + escapeHtml(r.max == null ? '' : r.max), + escapeHtml(r.average == null ? '' : r.average), + escapeHtml(r.std == null ? '' : r.std) + ]); + }); + + dt.draw(false); + return; + } + + setTimeout(function(){ + var $wrap = $('#summary_table_wrapper'); + var $plots = $('#dm-plots-wrap'); + if ($wrap.length && $plots.length) { + $plots.insertAfter($wrap); + } + }, 0); + + $tbody.empty(); + + if (!(rows || []).length) { + $tbody.append('No data found for this dataset.'); + return; + } + + rows.forEach(function(r){ + $tbody.append( + '' + + '' + escapeHtml(r.accession || '') + '' + + '' + escapeHtml(r.trait || '') + '' + + '' + escapeHtml(r.min == null ? '' : r.min) + '' + + '' + escapeHtml(r.max == null ? '' : r.max) + '' + + '' + escapeHtml(r.average == null ? '' : r.average) + '' + + '' + escapeHtml(r.std == null ? '' : r.std) + '' + + '' + ); + }); + } + + function loadDatasets(){ + return ajaxJSON('/datasets').then(function(resp){ + var $sel = ensureDatasetControl(); + + if (!$sel.length) { + return; + } + + $sel.empty().append(''); + + ((resp && resp.datasets) || []).forEach(function(ds){ + var id = ds.dataset_id || ds.sp_dataset_id || ''; + var name = ds.name || ds.dataset_name || ('Dataset ' + id); + + if (id !== '') { + $sel.append(''); + } + }); + }).catch(function(){}); + } + + function loadDatasetSummary(datasetId){ + var $table = $('#summary_table'); + var $tbody = ensureSummaryTbody(); + if (!$tbody.length) return; + + if (!datasetId) { + if (hasDT && $table.length && $.fn.DataTable.isDataTable($table[0])) { + $table.DataTable().clear().draw(); + } else { + $tbody.empty(); + } + return; + } + + if (hasDT && $table.length) { + var dt = ensureSummaryDataTable(); + if (dt) { + dt.clear().draw(); + } + } else { + $tbody.html( + 'Loading dataset summary...' + ); + } + + return ajaxJSON('/dataset_summary', { dataset_id: datasetId }) + .then(function(resp){ + var rows = (resp && resp.summary) || []; + renderSummaryTable(rows); + }) + .catch(function(){ + if (hasDT && $table.length && $.fn.DataTable.isDataTable($table[0])) { + $table.DataTable().clear().draw(); + } else { + $tbody.html( + 'Failed to load dataset.' + ); + } + }); + } + + /* =========================== + Plot UI + dataset plot data + =========================== */ + + var DM_PLOT_CACHE = {}; + + function getCurrentDatasetId(){ + var $sel = ensureDatasetControl(); + return $sel.length ? ($sel.val() || '') : ''; + } + + function ensurePlotlyLoaded(){ + return new Promise(function(resolve, reject){ + if (window.Plotly) { + resolve(window.Plotly); + return; + } + + var existing = document.getElementById('dm-plotly-loader'); + if (existing) { + existing.addEventListener('load', function(){ resolve(window.Plotly); }); + existing.addEventListener('error', reject); + return; + } + + var s = document.createElement('script'); + s.id = 'dm-plotly-loader'; + s.src = 'https://cdn.plot.ly/plotly-2.35.2.min.js'; + s.onload = function(){ resolve(window.Plotly); }; + s.onerror = function(){ reject(new Error('Could not load Plotly')); }; + document.head.appendChild(s); + }); + } + + function ensurePlotPanels(){ + if ($('#dm-plots-wrap').length) return; + + var $anchor = $('#summary_table_wrapper'); + + if (!$anchor.length) { + $anchor = $('#summary_table').closest('.dataTables_wrapper'); + } + + if (!$anchor.length) { + $anchor = $('#summary_table').closest('.table-responsive, .panel, .box, .card, .well, div').first(); + } + + if (!$anchor.length) { + $anchor = $root.length ? $root : $('body'); + } + + var html = '' + + '
' + + '
' + + '
Boxplot
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
Barplot
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + $anchor.after(html); + } + + function clearPlots(){ + ensurePlotPanels(); + $('#dm_boxplot').html('
Select a dataset and trait.
'); + $('#dm_barplot').html('
Select a dataset and trait.
'); + } + + function populateSelect($sel, items, placeholder){ + if (!$sel || !$sel.length) return; + var isMultiple = $sel.prop('multiple'); + + $sel.empty(); + + if (!isMultiple) { + $sel.append(''); + } + + (items || []).forEach(function(item){ + $sel.append(''); + }); + } + + function getSelectedMultiValues(sel){ + var v = $(sel).val(); + return Array.isArray(v) ? v : (v ? [v] : []); + } + + function getPlotCacheKey(datasetId, trait){ + return String(datasetId || '') + '||' + String(trait || ''); + } + + function fetchDatasetPlotData(datasetId, trait){ + var key = getPlotCacheKey(datasetId, trait); + + if (DM_PLOT_CACHE[key]) { + return Promise.resolve(DM_PLOT_CACHE[key]); + } + + return ajaxJSON('/dataset_plot_data', { + dataset_id: datasetId, + trait: trait + }).then(function(resp){ + DM_PLOT_CACHE[key] = resp || {}; + return DM_PLOT_CACHE[key]; + }); + } + function refreshPlotSelectors(datasetId){ + ensurePlotPanels(); + + if (!datasetId) { + populateSelect($('#dm_box_trait_sel'), [], '(select trait)'); + populateSelect($('#dm_bar_trait_sel'), [], '(select trait)'); + populateSelect($('#dm_box_highlight_sel'), []); + populateSelect($('#dm_bar_highlight_sel'), []); + clearPlots(); + return Promise.resolve(); + } + + return ajaxJSON('/dataset_traits', { dataset_id: datasetId }) + .then(function(resp){ + var traits = (resp && resp.traits) || []; + var accessions = (resp && resp.accessions) || []; + + populateSelect($('#dm_box_trait_sel'), traits, '(select trait)'); + populateSelect($('#dm_bar_trait_sel'), traits, '(select trait)'); + populateSelect($('#dm_box_highlight_sel'), accessions); + populateSelect($('#dm_bar_highlight_sel'), accessions); + + if (traits.length) { + $('#dm_box_trait_sel').val(traits[0]); + $('#dm_bar_trait_sel').val(traits[0]); + } + + return renderAllPlots(); + }) + .catch(function(){ + clearPlots(); + }); + } + + function renderBoxplot(datasetId, trait, highlighted){ + ensurePlotPanels(); + + if (!datasetId || !trait) { + $('#dm_boxplot').html('
Select a dataset and trait.
'); + return Promise.resolve(); + } + + return ensurePlotlyLoaded() + .then(function(Plotly){ + return fetchDatasetPlotData(datasetId, trait).then(function(resp){ + var rows = (resp && resp.rows) || []; + if (!rows.length) { + $('#dm_boxplot').html('
No plot data found.
'); + return; + } + + var grouped = {}; + rows.forEach(function(r){ + var acc = r.accession || ''; + var val = Number(r.value); + if (!acc || isNaN(val)) return; + if (!grouped[acc]) grouped[acc] = []; + grouped[acc].push(val); + }); + + var accessions = Object.keys(grouped).sort(); + + var traces = accessions.map(function(acc){ + var isHi = highlighted.indexOf(acc) !== -1; + return { + type: 'box', + name: acc, + y: grouped[acc], + boxpoints: 'outliers', + jitter: 0.25, + pointpos: 0, + marker: { + size: isHi ? 7 : 5, + color: isHi ? '#d9480f' : '#4c78a8' + }, + line: { + width: isHi ? 3 : 1 + }, + fillcolor: isHi ? 'rgba(217,72,15,0.25)' : 'rgba(76,120,168,0.18)' + }; + }); + + var layout = { + title: trait, + xaxis: { + title: 'Accession', + automargin: true + }, + yaxis: { + title: 'Value', + automargin: true + }, + showlegend: false, + margin: { t: 50, r: 20, b: 120, l: 70 } + }; + + return Plotly.newPlot('dm_boxplot', traces, layout, { responsive: true }); + }); + }) + .catch(function(){ + $('#dm_boxplot').html('
Failed to render boxplot.
'); + }); + } + + function renderBarplot(datasetId, trait, highlighted){ + ensurePlotPanels(); + + if (!datasetId || !trait) { + $('#dm_barplot').html('
Select a dataset and trait.
'); + return Promise.resolve(); + } + + return ensurePlotlyLoaded() + .then(function(Plotly){ + return fetchDatasetPlotData(datasetId, trait).then(function(resp){ + var summary = (resp && resp.accession_summary) || []; + if (!summary.length) { + $('#dm_barplot').html('
No plot data found.
'); + return; + } + + summary = summary.slice().sort(function(a, b){ + return Number(b.mean) - Number(a.mean); + }); + + var x = summary.map(function(r){ return r.accession; }); + var y = summary.map(function(r){ return Number(r.mean); }); + var err = summary.map(function(r){ return Number(r.std); }); + var colors = summary.map(function(r){ + return highlighted.indexOf(r.accession) !== -1 ? '#d9480f' : '#4c78a8'; + }); + + var trace = { + type: 'bar', + x: x, + y: y, + marker: { color: colors }, + error_y: { + type: 'data', + array: err, + visible: true + }, + customdata: summary.map(function(r){ + return [r.n, r.min, r.max, r.std]; + }), + hovertemplate: + '%{x}
' + + 'Mean: %{y:.3f}
' + + 'N: %{customdata[0]}
' + + 'Min: %{customdata[1]}
' + + 'Max: %{customdata[2]}
' + + 'Std: %{customdata[3]}' + }; + + var layout = { + title: trait + ' (mean by accession)', + xaxis: { + title: 'Accession', + automargin: true + }, + yaxis: { + title: 'Mean value', + automargin: true + }, + showlegend: false, + margin: { t: 50, r: 20, b: 120, l: 70 } + }; + + return Plotly.newPlot('dm_barplot', [trace], layout, { responsive: true }); + }); + }) + .catch(function(){ + $('#dm_barplot').html('
Failed to render barplot.
'); + }); + } + + function renderAllPlots(){ + var datasetId = getCurrentDatasetId(); + var boxTrait = $('#dm_box_trait_sel').val() || ''; + var barTrait = $('#dm_bar_trait_sel').val() || ''; + var boxHi = getSelectedMultiValues('#dm_box_highlight_sel'); + var barHi = getSelectedMultiValues('#dm_bar_highlight_sel'); + + return Promise.all([ + renderBoxplot(datasetId, boxTrait, boxHi), + renderBarplot(datasetId, barTrait, barHi) + ]); + } + + function loadLists(){ + return ajaxJSON('/lists', { type:'accessions' }).then(function(resp){ + var $sel = $('#dm_list_sel'); + if (!$sel.length) return; + $sel.empty().append(''); + ((resp && resp.lists) || []).forEach(function(li){ + $sel.append(''); + }); + }); + } + + async function loadBreedingPrograms(){ + var endpoints = ['/programs']; + for (var i = 0; i < endpoints.length; i++) { + try { + var j = await ajaxJSON(endpoints[i]); + if (!j) continue; + + var arr = []; + if (Array.isArray(j)) { arr = j; } + else if (Array.isArray(j.programs)) { arr = j.programs; } + else if (Array.isArray(j.breeding_programs)) { arr = j.breeding_programs; } + else if (Array.isArray(j.rows)) { arr = j.rows; } + + var norm = arr.map(function(x){ + return x.name || x.program_name || x.label || x.value || String(x || ''); + }).filter(Boolean); + + if (norm.length) { + var seen = new Set(); + var out = []; + norm.forEach(function(n){ + if (!seen.has(n)) { + seen.add(n); + out.push(n); + } + }); + return out; + } + } catch (e) {} + } + return []; + } + + function keyFor(acc, bp){ return String(acc || '') + '||' + String(bp || ''); } + + function normDecision(v){ + var x = String(v || '').trim().toLowerCase(); + if (x === 'drop') return 'drop'; + if (x === 'hold') return 'hold'; + if (x === 'advance') return 'advance'; + if (x === 'jump') return 'jump'; + return ''; + } + + function colorClass(val){ + switch (val) { + case 'drop': return 'dm-dec-drop'; + case 'hold': return 'dm-dec-hold'; + case 'advance': return 'dm-dec-advance'; + case 'jump': return 'dm-dec-jump'; + default: return 'dm-dec-none'; + } + } + + function decisionSelectHTML(current, acc, bp){ + var cur = normDecision(current); + return '' + + ''; + } + + function applyDecisionColor(sel){ + var v = normDecision(sel.value); + sel.classList.remove('dm-dec-none', 'dm-dec-drop', 'dm-dec-hold', 'dm-dec-advance', 'dm-dec-jump'); + sel.classList.add(colorClass(v)); + } + + function getSelectedMeetingContext(){ + if (typeof window.getSelectedDecisionMeeting === 'function') { + return window.getSelectedDecisionMeeting(); + } + var $checked = $('.dm-meeting-check:checked').first(); + if (!$checked.length) return null; + return { + meeting_id: $checked.data('meeting-id'), + meeting_name: $checked.data('meeting-name'), + meeting_date: $checked.data('meeting-date') + }; + } + + function extractYearFromStage(stageValue){ + var stage = String(stageValue || '').trim(); + if (!stage) return ''; + + var parts = stage.split('-').map(function(part){ + return String(part || '').trim(); + }).filter(Boolean); + + for (var i = 0; i < parts.length; i++) { + if (/^\d{2}$/.test(parts[i]) || /^\d{4}$/.test(parts[i])) { + return parts[i]; + } + } + + return ''; + } + + function normalizeStageList(raw){ + var flat = []; + + function pushValue(v){ + if (v == null) return; + + if (Array.isArray(v)) { + v.forEach(pushValue); + return; + } + + if (typeof v === 'object') { + pushValue(v.name || v.stage || v.value || v.label || ''); + return; + } + + var s = String(v).trim(); + if (!s) return; + + if (s.indexOf(',') !== -1) { + s.split(',').forEach(function(part){ + var item = String(part || '').trim(); + if (item) flat.push(item); + }); + return; + } + + flat.push(s); + } + + pushValue(raw); + + var seen = new Set(); + return flat.filter(function(x){ + if (!x || seen.has(x)) return false; + seen.add(x); + return true; + }); + } + + async function getAvailableStages() { + var out = []; + + try { + var resp = await ajaxJSON('/stages'); + var raw = []; + + if (resp && typeof resp === 'object' && 'stages' in resp) { + raw = resp.stages; + } else { + raw = resp; + } + + out = normalizeStageList(raw); + } catch (e) {} + + return out; + } + + function ensureStageDialog(){ + if (document.getElementById('dm_stage_dialog')) return; + + var html = '' + + '' + + '
' + + '
Select target stage
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + + $('body').append(html); + + $(document).off('click.dmStageCancel', '#dm_stage_cancel').on('click.dmStageCancel', '#dm_stage_cancel', function(){ + var dlg = document.getElementById('dm_stage_dialog'); + if (dlg) dlg.close(''); + }); + } + + function openStageDialog(opts){ + ensureStageDialog(); + + opts = opts || {}; + var accession = opts.accession || ''; + var currentStage = opts.currentStage || ''; + var meetingDate = opts.meetingDate || ''; + var stages = Array.isArray(opts.stages) ? opts.stages : []; + var selectedStage = opts.selectedStage || ''; + + return new Promise(function(resolve){ + var dlg = document.getElementById('dm_stage_dialog'); + var $subtitle = $('#dm_stage_dialog_subtitle'); + var $options = $('#dm_stage_dialog_options'); + + $subtitle.text( + 'Accession: ' + accession + + (meetingDate ? ' | Meeting date: ' + meetingDate : '') + + (currentStage ? ' | Current stage: ' + currentStage : '') + ); + + $options.empty(); + + stages.forEach(function(stg, idx){ + var id = 'dm_stage_opt_' + idx; + var checked = (selectedStage && stg === selectedStage) ? ' checked' : ''; + var row = '' + + '
' + + ' ' + + '
'; + $options.append(row); + }); + + if (!$options.find('input[name="dm_stage_choice"]:checked').length) { + $options.find('input[name="dm_stage_choice"]').first().prop('checked', true); + } + + $(document).off('click.dmStageOk', '#dm_stage_ok').on('click.dmStageOk', '#dm_stage_ok', function(){ + var val = $options.find('input[name="dm_stage_choice"]:checked').val() || ''; + dlg.close(val); + }); + + dlg.addEventListener('close', function onClose(){ + dlg.removeEventListener('close', onClose); + resolve(dlg.returnValue || ''); + }); + + dlg.showModal(); + }); + } + + var decisionDT = (function(){ + var $tbl = $('#decision_table'); + if (!hasDT || !$tbl.length) return null; + + var dt = $.fn.DataTable.isDataTable($tbl[0]) ? $tbl.DataTable() : $tbl.DataTable({ + dom:'ftip', + pageLength:20, + lengthChange:false, + order:[], + orderCellsTop:true, + fixedHeader:true, + autoWidth:false, + scrollX:true, + scrollCollapse:true, + deferRender:true, + drawCallback: function () { + var api = this.api(); + setTimeout(function () { + try { api.columns.adjust(); } catch (e) {} + try { + if (api.fixedHeader) { + api.fixedHeader.adjust(); + } + } catch (e) {} + adjustVisibleDataTables(); + }, 0); + }, + initComplete: function () { + setTimeout(function () { + adjustVisibleDataTables(); + }, 0); + } + }); + + setTimeout(function () { + adjustVisibleDataTables(); + }, 0); + + return dt; + })(); + + function clearDecisionRows(){ + if (!decisionDT) return; + decisionDT.clear().draw(); + } + + function rowsToDataArrays(rows){ + return (rows || []).map(function(r){ + var acc = r.accession || ''; + var bp = r.breeding_program || ''; + var stage = r.stage || ''; + var year = r.year || ''; + var dec = r.decision || ''; + var newStage = r.new_stage || ''; + var fem = r.female_parent || ''; + var male = r.male_parent || ''; + var notes = r.notes || ''; + var decSel = decisionSelectHTML(dec, acc, bp); + + return [acc, bp, stage, year, decSel, newStage, fem, male, notes]; + }); + } + + function normalizeRows(rawRows){ + return (rawRows || []).map(function(r){ + return { + stock_id: (r.stock_id || ''), + accession: (r.accession || ''), + breeding_program: (r.breeding_program || ''), + stage: (r.stage || ''), + year: (r.year || ''), + decision: normDecision(r.decision), + new_stage: (r.new_stage || ''), + female_parent: (r.female_parent || ''), + male_parent: (r.male_parent || ''), + notes: (r.notes || ''), + save_comment: (r.save_comment || '') + }; + }); + } + + function replaceDecisionRows(rows){ + STATE.baseRows = normalizeRows(rows); + STATE.rows = normalizeRows(rows); + STATE.accessions = uniq( + STATE.baseRows.map(function(r){ return r.accession || ''; }).filter(Boolean) + ); + STATE.programs = uniq( + STATE.baseRows.map(function(r){ return r.breeding_program || ''; }).filter(Boolean) + ); + STATE.decisionsMap.clear(); + STATE.baseRows.forEach(function(r){ + var k = keyFor(r.accession, r.breeding_program); + STATE.decisionsMap.set(k, normDecision(r.decision)); + }); + populateBpHeaderFilterOptions(STATE.programs); + renderRows(STATE.rows); + } + + function renderRows(rows){ + if (!decisionDT) return; + clearDecisionRows(); + var data = rowsToDataArrays(rows); + if (data.length) decisionDT.rows.add(data).draw(false); + if (STATE.currentBpFilter) { + decisionDT.column(1).search('^' + escapeRegExp(STATE.currentBpFilter) + '$', true, false).draw(); + } + setTimeout(function(){ adjustVisibleDataTables(); ensureSaveAllUI(); }, 0); + } + + function uniq(arr){ + var s = new Set(); + var out = []; + (arr || []).forEach(function(x){ + var v = (x == null ? '' : String(x)); + if (!s.has(v)) { + s.add(v); + out.push(v); + } + }); + return out; + } + + function escapeHtml(s){ + return String(s || '').replace(/[&<>"']/g, function(c){ + return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c] || c; + }); + } + + function escapeRegExp(s){ + return String(s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function buildCrossProductRows(){ + STATE.rows = (STATE.baseRows || []).slice(); + renderRows(STATE.rows); + } + + function ensureBpHeaderFilter(){ + var $tbl = $('#decision_table'); + if (!$tbl.length) return; + var $th = $tbl.find('thead th').eq(1); + if (!$th.length) return; + if ($th.find('#dm_bp_header_filter').length) return; + + var labelText = ($th.text() || 'Breeding Program').trim().split('\n')[0] || 'Breeding Program'; + $th.html( + '
' + + '
' + escapeHtml(labelText) + '
' + + '' + + '
' + ); + + if (decisionDT) { + try { decisionDT.columns.adjust(); } catch (e) {} + } + + populateBpHeaderFilterOptions(STATE.programs); + setTimeout(adjustVisibleDataTables, 0); + + $(document).off('change.dm','#dm_bp_header_filter').on('change.dm','#dm_bp_header_filter', function(){ + var v = this.value || ''; + STATE.currentBpFilter = v; + if (!decisionDT) return; + if (v === '') { + decisionDT.column(1).search('').draw(); + } else { + decisionDT.column(1).search('^' + escapeRegExp(v) + '$', true, false).draw(); + } + setTimeout(adjustVisibleDataTables, 0); + }); + } + + function populateBpHeaderFilterOptions(programs){ + var $sel = $('#dm_bp_header_filter'); + if (!$sel.length) return; + + var cur = $sel.val() || STATE.currentBpFilter || ''; + $sel.find('option:not([value=""])').remove(); + + var seen = new Set(); + (programs || []).forEach(function(p){ + if (!p) return; + if (seen.has(p)) return; + seen.add(p); + $sel.append(''); + }); + + if (cur && seen.has(cur)) { + $sel.val(cur); + } else { + $sel.val(''); + STATE.currentBpFilter = ''; + } + } + async function loadDecisionsForList(list_id){ + STATE.list_id = list_id || ''; + STATE.rows = []; + STATE.baseRows = []; + STATE.accessions = []; + STATE.decisionsMap.clear(); + + var meetingCtx = getSelectedMeetingContext(); + var meetingId = meetingCtx && meetingCtx.meeting_id ? meetingCtx.meeting_id : ''; + + var loaded = false; + try { + var params = { list_id: list_id }; + if (meetingId) params.meeting_id = meetingId; + + var getRes = await ajaxJSON('/decisions', params); + + if (getRes && Array.isArray(getRes.rows)) { + STATE.baseRows = normalizeRows(getRes.rows); + loaded = true; + } + } catch (e) {} + + if (!loaded) { + try { + var acc = await ajaxJSON('/accessions', { list_id: list_id }); + var names = ((acc && acc.accessions) || []).map(function(a){ return (a && a.name) || ''; }).filter(Boolean); + + STATE.baseRows = names.map(function(nm){ + return { + accession:nm, + breeding_program:'', + stage:'', + year:'', + decision:'', + new_stage:'', + female_parent:'', + male_parent:'', + notes:'' + }; + }); + loaded = true; + } catch (e) {} + } + + STATE.accessions = uniq( + STATE.baseRows.map(function(r){ return r.accession || ''; }).filter(Boolean) + ); + + STATE.programs = uniq( + STATE.baseRows.map(function(r){ return r.breeding_program || ''; }).filter(Boolean) + ); + + STATE.baseRows.forEach(function(r){ + var k = keyFor(r.accession, r.breeding_program); + STATE.decisionsMap.set(k, normDecision(r.decision)); + }); + + populateBpHeaderFilterOptions(STATE.programs); + buildCrossProductRows(); + setTimeout(adjustVisibleDataTables, 0); + } + + function getSelectedMeetingNotes(){ + var $checked = $('.dm-meeting-check:checked').first(); + if (!$checked.length) return ''; + var meetingId = $checked.data('meeting-id'); + if (!meetingId) return ''; + var cached = window.DM_MEETING_CACHE && window.DM_MEETING_CACHE[String(meetingId)]; + return $.trim((cached && cached.notes) || ''); + } + + function getDecisionRowsForSave(){ + return (STATE.rows || []).filter(function(r){ + return r && r.accession && (r.decision || r.new_stage || r.notes || r.save_comment); + }).map(function(r){ + return { + stock_id: r.stock_id || '', + accession: r.accession || '', + breeding_program: r.breeding_program || '', + previous_stage: r.stage || '', + decision: normDecision(r.decision), + new_stage: r.new_stage || '', + notes: r.notes || '', + save_comment: r.save_comment || '' + }; + }); + } + + function renderSaveReport(payload){ + ensureSaveAllUI(); + + $('#dm-save-meeting-name').text(payload.meeting_name || ''); + $('#dm-save-meeting-date').text(payload.date || ''); + $('#dm-save-meeting-attendees').text(payload.attendees || ''); + $('#dm-save-meeting-notes').val(payload.meeting_notes || ''); + + var $tbody = $('#dm-save-report-table tbody'); + $tbody.empty(); + + if (!payload.accessions.length) { + $tbody.append( + 'No accession decisions available to save.' + ); + return; + } + + payload.accessions.forEach(function(row, idx){ + $tbody.append( + '' + + '' + escapeHtml(row.accession || '') + '' + + '' + escapeHtml(row.breeding_program || '') + '' + + '' + escapeHtml(row.previous_stage || '') + '' + + '' + escapeHtml(row.decision || '') + '' + + '' + escapeHtml(row.new_stage || '') + '' + + '' + escapeHtml(row.notes || '') + '' + + '' + + '' + ); + }); + } + + function buildSavePayloadFromState(){ + var meetingCtx = getSelectedMeetingContext(); + if (!meetingCtx) { + alert('Please select one meeting first.'); + return null; + } + + if (!STATE.list_id) { + alert('Please select one accession list first.'); + return null; + } + + var accessions = getDecisionRowsForSave(); + if (!accessions.length) { + alert('There are no accession decisions to save.'); + return null; + } + + return { + meeting_id: meetingCtx.meeting_id || '', + meeting_name: meetingCtx.meeting_name || '', + date: meetingCtx.meeting_date || '', + attendees: getSelectedMeetingAttendees(), + meeting_notes: getSelectedMeetingNotes(), + list_id: STATE.list_id || '', + accessions: accessions + }; + } + + function openSaveReportDialog(payload){ + ensureSaveAllUI(); + renderSaveReport(payload); + + return new Promise(function(resolve){ + var dlg = document.getElementById('dm_save_dialog'); + + function onClose(){ + dlg.removeEventListener('close', onClose); + resolve(dlg.returnValue || ''); + } + + dlg.addEventListener('close', onClose); + dlg.showModal(); + }); + } + + async function saveAllDecisionsToController(payload){ + var $btn = $('#dm_confirm_save_all_btn'); + var originalText = $btn.text(); + $btn.prop('disabled', true).text('Saving...'); + + try { + payload.meeting_notes = $.trim($('#dm-save-meeting-notes').val() || ''); + + $('#dm-save-report-table .dm-save-comment').each(function(){ + var idx = Number($(this).attr('data-idx')); + if (!isNaN(idx) && payload.accessions[idx]) { + payload.accessions[idx].save_comment = $.trim($(this).val() || ''); + } + }); + + var resp = await fetch((window.DM_API_BASE || '/ajax/decisionmeeting') + '/save_all_decisions', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + var ct = resp.headers.get('content-type') || ''; + var data = ct.indexOf('application/json') !== -1 + ? await resp.json() + : { message: await resp.text().catch(function(){ return ''; }) }; + + if (!resp.ok) { + throw new Error((data && (data.message || data.error || data.detail)) || ('HTTP ' + resp.status)); + } + + var dlg = document.getElementById('dm_save_dialog'); + if (dlg) dlg.close('saved'); + + alert((data && (data.message || data.detail)) || 'All decisions were saved successfully.'); + } catch (e) { + alert('Failed to save all decisions: ' + (e && e.message ? e.message : e)); + } finally { + $btn.prop('disabled', false).text(originalText); + } + } + + function getRequiredMeetingAndListContext(){ + var meetingCtx = getSelectedMeetingContext(); + if (!meetingCtx) { + alert('Please select one meeting first.'); + return null; + } + + if (!STATE.list_id) { + alert('Please select one accession list first.'); + return null; + } + + return { + meeting_id: meetingCtx.meeting_id || '', + list_id: STATE.list_id || '' + }; + } + + function downloadDecisionUploadTemplate(){ + var ctx = getRequiredMeetingAndListContext(); + if (!ctx) return; + + var base = window.DM_API_BASE || '/ajax/decisionmeeting'; + var url = base + '/decision_upload_template?list_id=' + encodeURIComponent(ctx.list_id) + + '&meeting_id=' + encodeURIComponent(ctx.meeting_id); + window.location = url; + } + + async function uploadDecisionTemplate(file){ + var ctx = getRequiredMeetingAndListContext(); + if (!ctx || !file) return; + + var formData = new FormData(); + formData.append('decision_upload_file', file); + formData.append('meeting_id', ctx.meeting_id); + formData.append('list_id', ctx.list_id); + + var $btn = $('#dm_upload_template_btn'); + var originalText = $btn.text(); + $btn.prop('disabled', true).text('Uploading...'); + + try { + var resp = await fetch((window.DM_API_BASE || '/ajax/decisionmeeting') + '/upload_decision_template', { + method: 'POST', + headers: { 'Accept': 'application/json' }, + body: formData + }); + + var data = await resp.json(); + if (!resp.ok) { + throw new Error((data && (data.message || data.error || data.detail)) || ('HTTP ' + resp.status)); + } + + replaceDecisionRows(data.rows || []); + } catch (e) { + alert('Failed to upload decision Excel file: ' + (e && e.message ? e.message : e)); + } finally { + $btn.prop('disabled', false).text(originalText); + $('#dm_upload_template_file').val(''); + } + } + + function bindUI(){ + $(document).off('change.dm','#dm_list_sel').on('change.dm','#dm_list_sel', async function(){ + var id = $(this).val() || ''; + + if (id && !getSelectedMeetingContext()) { + alert('Please select one meeting first.'); + $(this).val(''); + STATE.list_id = ''; + STATE.rows = []; + STATE.baseRows = []; + STATE.accessions = []; + STATE.decisionsMap.clear(); + clearDecisionRows(); + setTimeout(adjustVisibleDataTables, 0); + return; + } + + if (id) { + await loadDecisionsForList(id); + } else { + STATE.list_id = ''; + STATE.rows = []; + STATE.baseRows = []; + STATE.accessions = []; + STATE.decisionsMap.clear(); + clearDecisionRows(); + setTimeout(adjustVisibleDataTables, 0); + } + }); + + $(document).off('click.dm','#dm_clear_filters').on('click.dm','#dm_clear_filters', function(){ + $('#dm_list_sel').val(''); + var $bp = $('#dm_bp_header_filter'); + if ($bp.length) $bp.val(''); + STATE.currentBpFilter = ''; + if (decisionDT) { + decisionDT.search('').columns().search('').draw(); + } + STATE.list_id = ''; + STATE.rows = []; + STATE.baseRows = []; + STATE.accessions = []; + STATE.decisionsMap.clear(); + clearDecisionRows(); + setTimeout(adjustVisibleDataTables, 0); + }); + + $(document).off('click.dmSaveAll', '#dm_save_all_btn').on('click.dmSaveAll', '#dm_save_all_btn', async function(){ + var payload = buildSavePayloadFromState(); + if (!payload) return; + await openSaveReportDialog(payload); + }); + + $(document).off('click.dmDownloadTemplate', '#dm_download_template_btn').on('click.dmDownloadTemplate', '#dm_download_template_btn', function(){ + downloadDecisionUploadTemplate(); + }); + + $(document).off('click.dmUploadTemplate', '#dm_upload_template_btn').on('click.dmUploadTemplate', '#dm_upload_template_btn', function(){ + $('#dm_upload_template_file').trigger('click'); + }); + + $(document).off('change.dmUploadTemplateFile', '#dm_upload_template_file').on('change.dmUploadTemplateFile', '#dm_upload_template_file', async function(){ + var file = this.files && this.files[0] ? this.files[0] : null; + await uploadDecisionTemplate(file); + }); + + $(document).off('click.dmConfirmSaveAll', '#dm_confirm_save_all_btn').on('click.dmConfirmSaveAll', '#dm_confirm_save_all_btn', async function(){ + var payload = buildSavePayloadFromState(); + if (!payload) return; + await saveAllDecisionsToController(payload); + }); + + $(document) + .off('change.dmDataset', '#dm_dataset_sel, #dataset_id, #dataset, select[name="dataset_id"], #dataset_select select') + .on('change.dmDataset', '#dm_dataset_sel, #dataset_id, #dataset, select[name="dataset_id"], #dataset_select select', function(){ + var datasetId = $(this).val() || ''; + loadDatasetSummary(datasetId); + refreshPlotSelectors(datasetId); + }); + + $(document) + .off('change.dmBoxTrait', '#dm_box_trait_sel') + .on('change.dmBoxTrait', '#dm_box_trait_sel', function(){ + renderAllPlots(); + }); + + $(document) + .off('change.dmBarTrait', '#dm_bar_trait_sel') + .on('change.dmBarTrait', '#dm_bar_trait_sel', function(){ + renderAllPlots(); + }); + + $(document) + .off('change.dmBoxHi', '#dm_box_highlight_sel') + .on('change.dmBoxHi', '#dm_box_highlight_sel', function(){ + renderAllPlots(); + }); + + $(document) + .off('change.dmBarHi', '#dm_bar_highlight_sel') + .on('change.dmBarHi', '#dm_bar_highlight_sel', function(){ + renderAllPlots(); + }); + + $(document).off('change.dm','.dm-decision-select').on('change.dm','.dm-decision-select', async function(){ + applyDecisionColor(this); + + var acc = this.getAttribute('data-acc') || ''; + var bp = this.getAttribute('data-bp') || ''; + var v = normDecision(this.value); + var $tr = $(this).closest('tr'); + var meetingCtx = getSelectedMeetingContext(); + var meetingDate = (meetingCtx && meetingCtx.meeting_date) || ''; + + var rowObj = null; + for (var i = 0; i < STATE.rows.length; i++) { + if (STATE.rows[i].accession === acc && STATE.rows[i].breeding_program === bp) { + rowObj = STATE.rows[i]; + break; + } + } + + var stageText = rowObj ? (rowObj.stage || '') : ''; + var originalYear = rowObj ? (rowObj.year || '') : ''; + var previousNewStage = rowObj ? (rowObj.new_stage || '') : ''; + var stockId = rowObj ? (rowObj.stock_id || '') : ''; + + var meetingYearFull = ''; + if (meetingDate) { + var m = String(meetingDate).match(/^(\d{4})-/); + if (m) { + meetingYearFull = m[1]; + } else { + var d = new Date(meetingDate); + if (!isNaN(d.getTime())) { + meetingYearFull = String(d.getFullYear()); + } + } + } + + STATE.decisionsMap.set(keyFor(acc, bp), v); + + function updateNewStageValue(newStageValue, decisionValue){ + if (decisionDT) { + var rowData = decisionDT.row($tr).data(); + if (rowData) { + rowData[4] = decisionSelectHTML(decisionValue, acc, bp); + rowData[5] = newStageValue || ''; + decisionDT.row($tr).data(rowData).invalidate().draw(false); + } + } + + for (var a = 0; a < STATE.rows.length; a++) { + if (STATE.rows[a].accession === acc && STATE.rows[a].breeding_program === bp) { + STATE.rows[a].decision = decisionValue; + STATE.rows[a].new_stage = newStageValue || ''; + break; + } + } + + for (var b = 0; b < STATE.baseRows.length; b++) { + if (STATE.baseRows[b].accession === acc && STATE.baseRows[b].breeding_program === bp) { + STATE.baseRows[b].decision = decisionValue; + STATE.baseRows[b].new_stage = newStageValue || ''; + break; + } + } + } + + if (!v) { + updateNewStageValue('', ''); + return; + } + + if (!meetingDate || !meetingYearFull) { + alert('Please select one meeting first so the year can be taken from the meeting date.'); + this.value = ''; + applyDecisionColor(this); + STATE.decisionsMap.set(keyFor(acc, bp), ''); + updateNewStageValue(previousNewStage, ''); + return; + } + + var selectedStage = ''; + var stageYear = v === 'drop' ? (extractYearFromStage(stageText) || originalYear) : meetingYearFull; + if (v === 'advance' || v === 'jump') { + $.ajax({ + url: (window.DM_API_BASE || '/ajax/decisionmeeting') + '/compute_new_stage', + method: 'POST', + dataType: 'json', + data: { + current_stage: stageText, + decision: v, + year: stageYear, + meeting_date: meetingDate, + stock_id: stockId, + selected_stage: '' + }, + success: async function(resp){ + var allowedStages = Array.isArray(resp && resp.allowed_stages) ? resp.allowed_stages : []; + + if (!allowedStages.length) { + alert((resp && resp.warning) || 'No valid target stages available for this accession.'); + updateNewStageValue('', v); + return; + } + + var chosenStage = await openStageDialog({ + accession: acc, + currentStage: stageText, + meetingDate: meetingDate, + stages: allowedStages, + selectedStage: (resp && resp.selected_stage) || '' + }); + + if (!chosenStage) { + $('.dm-decision-select[data-acc="' + acc.replace(/"/g, '"') + '"][data-bp="' + bp.replace(/"/g, '"') + '"]').val(''); + STATE.decisionsMap.set(keyFor(acc, bp), ''); + updateNewStageValue(previousNewStage, ''); + return; + } + + $.ajax({ + url: (window.DM_API_BASE || '/ajax/decisionmeeting') + '/compute_new_stage', + method: 'POST', + dataType: 'json', + data: { + current_stage: stageText, + decision: v, + year: stageYear, + meeting_date: meetingDate, + stock_id: stockId, + selected_stage: chosenStage + }, + success: function(resp2){ + var newStage = (resp2 && resp2.new_stage) || ''; + updateNewStageValue(newStage, v); + }, + error: function(){ + updateNewStageValue('', v); + } + }); + }, + error: function(){ + updateNewStageValue('', v); + } + }); + return; + } + + $.ajax({ + url: (window.DM_API_BASE || '/ajax/decisionmeeting') + '/compute_new_stage', + method: 'POST', + dataType: 'json', + data: { + current_stage: stageText, + decision: v, + year: stageYear, + meeting_date: meetingDate, + stock_id: stockId, + selected_stage: selectedStage + }, + success: function(resp){ + var newStage = (resp && resp.new_stage) || ''; + updateNewStageValue(newStage, v); + }, + error: function(){ + updateNewStageValue('', v); + } + }); + }); + } + bindUI(); + + (async function boot(){ + var base = await detectApiBase(); + if (!base) { + return; + } + + window.DM_API_BASE = base; + + await loadLists(); + await loadDatasets(); + ensureSaveAllUI(); + STATE.programs = await loadBreedingPrograms(); + + var $datasetSel = ensureDatasetControl(); + if ($datasetSel.length && $datasetSel.val()) { + loadDatasetSummary($datasetSel.val()); + } + + ensureBpHeaderFilter(); + populateBpHeaderFilterOptions(STATE.programs); + setTimeout(adjustVisibleDataTables, 0); + })(); + + window.addDecisionRow = function(row){ + var r = normalizeRows([row || {}])[0]; + STATE.decisionsMap.set(keyFor(r.accession, r.breeding_program), normDecision(r.decision)); + if (STATE.accessions.indexOf(r.accession) === -1) STATE.accessions.push(r.accession); + if (STATE.programs.indexOf(r.breeding_program) === -1 && r.breeding_program) { + STATE.programs.push(r.breeding_program); + populateBpHeaderFilterOptions(STATE.programs); + } + + var replaced = false; + for (var i = 0; i < STATE.baseRows.length; i++) { + var br = STATE.baseRows[i]; + if (br.accession === r.accession && br.breeding_program === r.breeding_program) { + STATE.baseRows[i] = r; + replaced = true; + break; + } + } + + if (!replaced) STATE.baseRows.push(r); + buildCrossProductRows(); + setTimeout(adjustVisibleDataTables, 0); + }; + + }); +})(jQuery); + +(function($){ + 'use strict'; + if (window.__DM_CREATE_WIRED__) { + return; + } + window.__DM_CREATE_WIRED__ = true; + + const NS = '.dmCreateV4'; + const API_BASE = (window.DM_API_BASE + || $('#decision_meeting_main').data('dmApiBase') + || '/ajax/decisionmeeting'); + + function parseAttendees(text){ + return (text || '').split(/\n|,/g).map(function(s){ return s.trim(); }).filter(Boolean); + } + + function showErr(msg){ + $('#create-meeting-error').text(msg || 'Unexpected error').show(); + } + + function hideErr(){ + $('#create-meeting-error').hide().text(''); + } + + function ensureMultiProgramControl() { + const $sel = $('#mtg_program'); + if (!$sel.length) return; + if ($.fn.select2 && $sel.data('select2')) { + try { $sel.select2('destroy'); } catch(e) {} + } + if ($.fn.selectpicker && $sel.data('selectpicker')) { + try { $sel.selectpicker('destroy'); } catch(e) {} + } + $sel.attr('multiple', 'multiple'); + $sel.prop('multiple', true); + if (!$sel.attr('size')) $sel.attr('size', 6); + if (!$sel.attr('name') || $sel.attr('name') === 'mtg_program') { + $sel.attr('name', 'mtg_program[]'); + } + } + + function upgradeMultiProgramUI(items) { + const $sel = $('#mtg_program'); + if (!$sel.length) return; + if ($.fn.select2) { + try { + if ($sel.data('select2')) $sel.select2('destroy'); + $sel.select2({ + width: '100%', + placeholder: 'Select breeding program(s)', + closeOnSelect: false + }); + return; + } catch(e) {} + } + if ($.fn.selectpicker) { + try { + if ($sel.data('selectpicker')) $sel.selectpicker('destroy'); + $sel.selectpicker({ + actionsBox: true, + liveSearch: true, + noneSelectedText: 'Select breeding program(s)' + }); + $sel.selectpicker('refresh'); + return; + } catch(e) {} + } + const n = Math.min(10, Math.max(5, (items && items.length) ? items.length : parseInt($sel.attr('size') || 6, 10))); + $sel.attr('size', n); + } + + function buildPayload(){ + const progVals = $('#mtg_program').val(); + const programs = Array.isArray(progVals) + ? progVals.filter(Boolean) + : (progVals ? [progVals] : []); + + return { + meeting_name: $.trim($('#mtg_name').val() || ''), + breeding_program: programs.join(','), + breeding_programs: programs, + location: $.trim($('#mtg_location').val() || ''), + year: String($('#mtg_year').val() || ''), + date: $('#mtg_date').val() || '', + data: $.trim($('#mtg_data').val() || ''), + attendees: parseAttendees($('#mtg_attendees').val()).join(',') + }; + } + + function validate(p){ + if (!p.meeting_name) return 'Please enter the Meeting name.'; + if (!p.location) return 'Please enter the Location.'; + if (!p.year || isNaN(Number(p.year))) return 'Please enter a valid Year.'; + if (!p.date) return 'Please choose a Date.'; + if (!p.breeding_programs || p.breeding_programs.length === 0) return 'Please select at least one Breeding Program.'; + return ''; + } + + function dm_loadLocations() { + const $sel = $('#mtg_location'); + if (!$sel.length) return; + if (!API_BASE) return; + $sel.find('option:not([value=""])').remove(); + $.ajax({ url: API_BASE + '/locations', dataType: 'json' }) + .done(function(items){ + if (!items || !items.length) { + return; + } + (items || []).forEach(function(l){ + const id = l.location_id ?? ''; + const nm = l.name ?? String(id); + if (id !== '') $sel.append(''); + }); + }) + .fail(function(){}); + } + + const PEOPLE_PAGE_SIZE = 10; + let PEOPLE_ALL = []; + let PEOPLE_FILTERED = []; + let PEOPLE_PAGE = 1; + let PEOPLE_SELECTED = new Set(); + + function normPerson(p){ + return { + first_name: (p.first_name ?? p.first ?? p.given_name ?? '').trim(), + last_name: (p.last_name ?? p.last ?? p.family_name ?? '').trim(), + contact_email: (p.contact_email ?? p.email ?? '').trim() + }; + } + + function personKey(p){ + return [ + (p.first_name || '').toLowerCase(), + (p.last_name || '').toLowerCase(), + (p.contact_email || '').toLowerCase() + ].join('|'); + } + + function dm_syncSelectAllState(){ + const $rows = $('#people_table tbody tr'); + if (!$rows.length) { + $('#people_select_all').prop('checked', false); + return; + } + const $checks = $rows.find('input.person-check, input.attendee-check'); + const allChecked = $checks.length > 0 && $checks.filter(':checked').length === $checks.length; + $('#people_select_all').prop('checked', allChecked); + } + + function buildPager(total, page, pageSize){ + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const cur = Math.min(Math.max(1, page), totalPages); + let html = ''; + return html; + } + + function renderPeople(){ + const $tbody = $('#people_table tbody'); + if (!$tbody.length) return; + + const total = PEOPLE_FILTERED.length; + const totalPages = Math.max(1, Math.ceil(total / PEOPLE_PAGE_SIZE)); + if (PEOPLE_PAGE < 1) PEOPLE_PAGE = 1; + if (PEOPLE_PAGE > totalPages) PEOPLE_PAGE = totalPages; + + if (!total) { + $tbody.html('No people found.'); + $('#people_pager').remove(); + dm_syncSelectAllState(); + return; + } + + const start = (PEOPLE_PAGE - 1) * PEOPLE_PAGE_SIZE; + const slice = PEOPLE_FILTERED.slice(start, start + PEOPLE_PAGE_SIZE); + const rows = slice.map(function(p, i){ + const idx = start + i; + const id = `person_${idx}`; + const key = personKey(p); + const em = p.contact_email || ''; + const checked = PEOPLE_SELECTED.has(key) ? ' checked' : ''; + return ( + '' + + '' + + `` + + '' + + `${p.first_name || ''}` + + `${p.last_name || ''}` + + `${em}` + + '' + ); + }).join(''); + + $tbody.html(rows); + const pagerHtml = buildPager(total, PEOPLE_PAGE, PEOPLE_PAGE_SIZE); + if ($('#people_pager').length) { + $('#people_pager').replaceWith(pagerHtml); + } else { + $('#people_table').closest('.table-responsive').after(pagerHtml); + } + dm_syncSelectAllState(); + } + + function applyPeopleSearch(){ + const q = ($.trim($('#people_search').val() || '')).toLowerCase(); + if (!q) { + PEOPLE_FILTERED = PEOPLE_ALL.slice(); + } else { + PEOPLE_FILTERED = PEOPLE_ALL.filter(function(p){ + return (p.first_name || '').toLowerCase().includes(q) + || (p.last_name || '').toLowerCase().includes(q) + || (p.contact_email || '').toLowerCase().includes(q); + }); + } + PEOPLE_PAGE = 1; + renderPeople(); + } + + function dm_loadPeople() { + const $tbody = $('#people_table tbody'); + if (!$tbody.length) return; + if (typeof API_BASE === 'undefined' || !API_BASE) return; + + const term = $.trim($('#people_search').val() || ''); + $tbody.html('Loading…'); + + $.ajax({ + url: API_BASE + '/people', + dataType: 'json', + data: term ? { q: term } : undefined + }) + .done(function(res){ + const items = Array.isArray(res) ? res : (res && res.data) || []; + PEOPLE_ALL = items.map(normPerson); + applyPeopleSearch(); + }) + .fail(function(){ + $tbody.html('Failed to load.'); + }); + } + + function dm_enableRowSelection(tableSelectors) { + (tableSelectors || ['#people_table', '#meeting_table', '#decision_table']).forEach(function(sel){ + const $t = $(sel); + if (!$t.length) return; + $t.off('click.dmPickRow').on('click.dmPickRow', 'tbody tr', function ev(e){ + if ($(e.target).is('input,button,a,select,label,textarea')) return; + const $row = $(this); + const nowSelected = !$row.hasClass('selected'); + $row.toggleClass('selected', nowSelected); + let $chk = $row.find('input.attendee-check[type=checkbox]').first(); + if (!$chk.length) $chk = $row.find('input.person-check[type=checkbox]').first(); + if ($chk.length) $chk.prop('checked', nowSelected).trigger('change'); + }); + }); + } + + $(document) + .off('change' + NS, '#people_table tbody input.person-check, #people_table tbody input.attendee-check') + .on('change' + NS, '#people_table tbody input.person-check, #people_table tbody input.attendee-check', function(){ + const $cb = $(this); + const key = String($cb.data('key') || ''); + const on = $cb.is(':checked'); + const $row = $cb.closest('tr'); + if (on) PEOPLE_SELECTED.add(key); + else PEOPLE_SELECTED.delete(key); + $row.toggleClass('selected', on); + dm_syncSelectAllState(); + }); + + $(document) + .off('change' + NS, '#people_select_all') + .on('change' + NS, '#people_select_all', function(){ + const on = $(this).is(':checked'); + $('#people_table tbody input.person-check, #people_table tbody input.attendee-check').each(function(){ + const $cb = $(this); + if ($cb.is(':checked') !== on) { + $cb.prop('checked', on).trigger('change'); + } + }); + }); + + function dm_collectSelectedNames() { + const out = []; + $('#people_table tbody input.person-check:checked, #people_table tbody input.attendee-check:checked').each(function(){ + const $cb = $(this); + const first = String($cb.data('first') || '').trim(); + const last = String($cb.data('last') || '').trim(); + let name = [first, last].filter(Boolean).join(' ').trim(); + if (!name) { + const $tr = $cb.closest('tr'); + const tds = $tr.children('td'); + const alt = [tds.eq(1).text(), tds.eq(2).text()].map(function(s){ return (s || '').trim(); }).filter(Boolean).join(' '); + name = alt || String($cb.data('email') || '').trim(); + } + if (name) out.push(name); + }); + const seen = new Set(); + return out.filter(function(n){ + n = n.trim(); + return n && !seen.has(n) && seen.add(n); + }); + } + + $(document) + .off('click' + NS, '#create_meeting_dialog, [data-dm="open-create-meeting"]') + .on('click' + NS, '#create_meeting_dialog, [data-dm="open-create-meeting"]', function (e) { + e.preventDefault(); + const $form = $('#create-meeting-form'); + if ($form.length) $form[0].reset(); + const now = new Date(); + $('#mtg_year').val(now.getFullYear()); + $('#mtg_date').val(now.toISOString().slice(0,10)); + hideErr(); + ensureMultiProgramControl(); + $.ajax({ url: API_BASE + '/programs', dataType: 'json' }) + .done(function(items){ + const $sel = $('#mtg_program'); + if (!$sel.length) return; + $sel.find('option').remove(); + (items || []).forEach(function(p){ + const id = (p.program_id ?? p.name ?? ''); + const nm = (p.name ?? String(p.program_id)); + $sel.append(''); + }); + upgradeMultiProgramUI(items); + $sel.trigger('change'); + }); + dm_loadLocations(); + $('#people_search').val(''); + PEOPLE_PAGE = 1; + PEOPLE_SELECTED = new Set(); + $('#mtg_attendees').val(''); + dm_loadPeople(); + const $modal = $('#createMeetingModal'); + if ($modal.length) { + $modal.modal('show'); + setTimeout(function(){ $('#mtg_name').trigger('focus'); }, 150); + } + dm_enableRowSelection(['#people_table']); + }); + + $(document) + .off('input' + NS, '#people_search') + .on('input' + NS, '#people_search', function(){ + applyPeopleSearch(); + }); + + $(document) + .off('click' + NS, '#people_pager a.page-link') + .on('click' + NS, '#people_pager a.page-link', function(e){ + e.preventDefault(); + const pg = parseInt($(this).data('page'), 10); + if (!isNaN(pg)) { + PEOPLE_PAGE = pg; + renderPeople(); + } + }); + + $(document) + .off('click' + NS, '#create_meeting_submit, #create_meeting_save') + .on('click' + NS, '#create_meeting_submit, #create_meeting_save', function (e) { + e.preventDefault(); + const $form = $('#create-meeting-form'); + if ($form.length) $form.trigger('submit'); + }); + + let creating = false; + $(document) + .off('submit' + NS, '#create-meeting-form') + .on('submit' + NS, '#create-meeting-form', function (e) { + e.preventDefault(); + if (creating) return; + hideErr(); + + const p = buildPayload(); + const selNames = dm_collectSelectedNames(); + if (selNames.length) { + p.attendees = selNames.join(','); + p.attendees_list = selNames; + } + + const v = validate(p); + if (v) { + showErr(v); + return; + } + + creating = true; + const $footerBtns = $('#createMeetingModal .modal-footer .btn').prop('disabled', true).addClass('disabled'); + + $.ajax({ + url: API_BASE + '/create', + type: 'POST', + data: p, + dataType: 'json' + }) + .done(function (r) { + if (r && (r.ok || r.success)) { + $('#createMeetingModal').modal('hide'); + document.dispatchEvent(new CustomEvent('meeting:created', { detail: Object.assign({}, p, r) })); + } else { + showErr((r && (r.message || r.detail)) || 'Unexpected server response.'); + } + }) + .fail(function (xhr) { + const serverMsg = + (xhr.responseJSON && (xhr.responseJSON.message || xhr.responseJSON.detail)) || + xhr.responseText || + ''; + + if (xhr.status === 403) { + if (/login required/i.test(serverMsg)) { + showErr('You must be logged in to create a meeting.'); + } else if (serverMsg) { + showErr(serverMsg); + } else { + showErr('You are not allowed to create meetings.'); + } + } + else if (xhr.status === 404) { + showErr('Create endpoint not found at ' + API_BASE + '/create'); + } + else if (xhr.status === 400) { + showErr(serverMsg || 'Invalid input. Please check the required fields.'); + } + else { + showErr('Error ' + xhr.status + ': ' + (serverMsg || xhr.statusText || 'request failed')); + } + }) + .always(function(){ + creating = false; + $footerBtns.prop('disabled', false).removeClass('disabled'); + }); + }); + +})(jQuery); + +(function($){ + 'use strict'; + var hasDT = $.fn && $.fn.DataTable; + var meetingDT = null; + + function adjustMeetingDT(){ + if (!meetingDT) return; + + try { meetingDT.columns.adjust(); } catch (e) {} + + var $tbl = $('#meeting_table'); + var $wrap = $tbl.closest('.dataTables_wrapper'); + var $scrollHead = $wrap.find('.dataTables_scrollHeadInner'); + var $scrollHeadTable = $wrap.find('.dataTables_scrollHeadInner table'); + var $scrollBodyTable = $wrap.find('.dataTables_scrollBody table'); + + if ($scrollBodyTable.length && $scrollHead.length && $scrollHeadTable.length) { + var bodyWidth = $scrollBodyTable.outerWidth(); + if (bodyWidth) { + $scrollHead.width(bodyWidth); + $scrollHeadTable.width(bodyWidth); + } + } + } + + function linkify(name, id){ + var tmpl = window.DM_MEETING_URL_TMPL; + if (!tmpl || !id) return name || ''; + var href = String(tmpl).replace('{id}', String(id)); + var safe = String(name || '').replace(/&/g,'&').replace(//g,'>'); + return '' + safe + ''; + } + + function escAttr(v){ + return String(v == null ? '' : v) + .replace(/&/g,'&') + .replace(/"/g,'"') + .replace(//g,'>'); + } + + function escHtml(v){ + return String(v == null ? '' : v) + .replace(/&/g,'&') + .replace(//g,'>') + .replace(/"/g,'"') + .replace(/'/g,'''); + } + + function parseJSON(s){ + try { return JSON.parse(s || '{}') || {}; } + catch (e) { return {}; } + } + + function isMeetingSaved(meetingJson){ + var j = meetingJson || {}; + if (j.saved === true) return true; + if (j.is_saved === true) return true; + if (j.meeting_saved === true) return true; + if (j.decisions_saved === true) return true; + if (String(j.status || '').toLowerCase() === 'saved') return true; + if (String(j.save_status || '').toLowerCase() === 'saved') return true; + if (j.saved_at) return true; + if (j.date_saved) return true; + return false; + } + + function getMeetingProgramsText(j){ + return Array.isArray(j.breeding_programs) + ? j.breeding_programs.join(', ') + : (j.breeding_programs || j.breeding_program || ''); + } + + function getMeetingAttendeesText(j){ + return Array.isArray(j.attendees_list) + ? j.attendees_list.join(', ') + : (j.attendees || ''); + } + + function getMeetingNotesText(j){ + return j.notes || j.meeting_notes || j.data || ''; + } + + function meetingCheckboxHtml(id, name, date, saved){ + if (saved) { + return 'Saved'; + } + return ''; + } + + function meetingDownloadBtnHtml(id, saved){ + return ''; + } + + function getSelectedMeeting(){ + var $checked = $('.dm-meeting-check:checked').first(); + if (!$checked.length) return null; + return { + meeting_id: $checked.data('meeting-id'), + meeting_name: $checked.data('meeting-name'), + meeting_date: $checked.data('meeting-date') + }; + } + + window.getSelectedDecisionMeeting = getSelectedMeeting; + + window.requireSelectedDecisionMeeting = function(){ + var sel = getSelectedMeeting(); + if (!sel) { + alert('Please select one meeting first.'); + return null; + } + return sel; + }; + + function ensureDT(){ + var $tbl = $('#meeting_table'); + if (!hasDT || !$tbl.length) return null; + if ($.fn.DataTable.isDataTable($tbl[0])) { + meetingDT = $tbl.DataTable(); + return meetingDT; + } + meetingDT = $tbl.DataTable({ + dom: 'lftip', + pageLength: 10, + lengthMenu: [[10,25,50],[10,25,50]], + order: [[3,'desc']], + autoWidth: false, + scrollX: true, + scrollCollapse: true, + deferRender: true, + columns: [ + { title:'Select', className:'text-center', orderable:false, searchable:false }, + { title:'Meeting', className:'text-left' }, + { title:'Programs', className:'text-left' }, + { title:'Date', className:'text-nowrap' }, + { title:'Location', className:'text-left' }, + { title:'Attendees', className:'text-left' }, + { title:'Download', className:'text-center', orderable:false, searchable:false } + ], + drawCallback: function () { + var api = this.api(); + setTimeout(function () { + try { api.columns.adjust(); } catch (e) {} + }, 0); + } + }); + return meetingDT; + } + + function rowsToArrays(rows){ + return (rows || []).map(function(r){ + var j = parseJSON(r.meeting_json); + var id = r.project_id; + var name = j.meeting_name || r.project_name || ''; + var date = j.date || ''; + var loc = j.location_name || j.location || ''; + var progs = getMeetingProgramsText(j); + var atts = getMeetingAttendeesText(j); + var notes = getMeetingNotesText(j); + var saved = isMeetingSaved(j); + + window.DM_MEETING_CACHE[String(id)] = { + id: id, + meeting_name: name, + meeting_date: date, + location: loc, + programs_text: progs, + attendees_text: atts, + notes: notes, + saved: saved, + meeting_json: j + }; + + return [ + meetingCheckboxHtml(id, name, date, saved), + linkify(name, id), + escHtml(progs), + escHtml(date), + escHtml(loc), + escHtml(atts), + meetingDownloadBtnHtml(id, saved) + ]; + }); + } + + async function loadMeetingTracker(){ + var base = window.DM_API_BASE || '/ajax/decisionmeeting'; + var url = base + '/meetings'; + var $tbl = $('#meeting_table'); + if (!$tbl.length) return; + ensureDT(); + if (!meetingDT) return; + $tbl.addClass('dm-loading'); + + try { + var r = await fetch(url, { headers:{ 'Accept':'application/json' } }); + if (!r.ok) throw new Error('HTTP ' + r.status + ' ' + r.statusText); + var j = await r.json(); + var rows = j.rows || []; + window.DM_MEETING_CACHE = {}; + var data = rowsToArrays(rows); + meetingDT.clear(); + if (data.length) meetingDT.rows.add(data); + meetingDT.draw(false); + setTimeout(adjustMeetingDT, 0); + } catch (err) { + meetingDT.clear().draw(); + } finally { + $tbl.removeClass('dm-loading'); + setTimeout(adjustMeetingDT, 0); + } + } + + async function downloadMeetingReport(meetingId){ + var base = (window.DM_API_BASE || '/ajax/decisionmeeting'); + var url = base + '/meeting_report_html?meeting_id=' + encodeURIComponent(meetingId); + + try { + var resp = await fetch(url, { + headers: { 'Accept': 'text/html,application/json' } + }); + + var ct = resp.headers.get('content-type') || ''; + + if (!resp.ok) { + if (ct.indexOf('application/json') !== -1) { + var errJson = await resp.json(); + throw new Error(errJson.message || errJson.error || ('HTTP ' + resp.status)); + } else { + var txt = await resp.text().catch(function(){ return ''; }); + throw new Error(txt || ('HTTP ' + resp.status)); + } + } + + window.open(url, '_blank'); + } catch (err) { + alert(err && err.message ? err.message : 'Could not open meeting report.'); + throw err; + } + } + + document.addEventListener('meeting:created', function(){ + setTimeout(loadMeetingTracker, 200); + }); + + var dmMeetingResizeTimer = null; + + $(window) + .off('resize.dmMeetingTable orientationchange.dmMeetingTable') + .on('resize.dmMeetingTable orientationchange.dmMeetingTable', function(){ + clearTimeout(dmMeetingResizeTimer); + dmMeetingResizeTimer = setTimeout(function(){ + adjustMeetingDT(); + }, 120); + }); + + $(document) + .off('shown.bs.collapse.dmMeeting shown.bs.tab.dmMeeting shown.bs.modal.dmMeeting') + .on('shown.bs.collapse.dmMeeting shown.bs.tab.dmMeeting shown.bs.modal.dmMeeting', function(){ + setTimeout(function(){ + adjustMeetingDT(); + }, 100); + }); + + $(document) + .off('change.dmMeetingSelect', '.dm-meeting-check') + .on('change.dmMeetingSelect', '.dm-meeting-check', function(){ + if (this.checked) { + $('.dm-meeting-check').not(this).prop('checked', false); + } + }); + + $(document) + .off('click.dmMeetingDownload', '.dm-meeting-download-btn') + .on('click.dmMeetingDownload', '.dm-meeting-download-btn', async function(){ + var $btn = $(this); + var meetingId = $btn.data('meeting-id'); + var original = $btn.text(); + + if (!meetingId) { + alert('Meeting ID not found.'); + return; + } + + $btn.prop('disabled', true).text('Preparing...'); + + try { + await downloadMeetingReport(meetingId); + } catch (e) { + alert('Could not open the meeting report.'); + } finally { + $btn.prop('disabled', false).text(original); + } + }); + + $(function(){ + if ($('#meeting_table').length) setTimeout(loadMeetingTracker, 0); + }); +})(jQuery); diff --git a/js/source/entries/wizard.js b/js/source/entries/wizard.js index cf5444dba1..5397b33a61 100644 --- a/js/source/entries/wizard.js +++ b/js/source/entries/wizard.js @@ -12,6 +12,7 @@ const initialtypes = [ "genotyping_protocols", "genotyping_projects", "locations", + "populations", "seedlots", "trait_components", "traits", @@ -33,6 +34,7 @@ const types = { "subplots": "Subplots", "plants": "Plants", "tissue_sample": "Tissue Samples", + "populations": "Populations", "seedlots": "Seedlots", "trait_components": "Trait Components", "traits": "Traits", @@ -76,6 +78,8 @@ function makeURL(target, id) { case "genotyping_plates": case "genotyping_projects": return document.location.origin + `/breeders/trial/${id}`; + case "populations": + return document.location.origin + `/stock/${id}/view`; case "trial_designs": case "trial_types": case "years": diff --git a/js/source/legacy/CXGN/TrialTreeFolders.js b/js/source/legacy/CXGN/TrialTreeFolders.js index 8200296938..7dd33383d1 100644 --- a/js/source/legacy/CXGN/TrialTreeFolders.js +++ b/js/source/legacy/CXGN/TrialTreeFolders.js @@ -1,31 +1,31 @@ jQuery(document).ready(function($) { - jQuery("[name='refresh_jstree_html']").click(function(){ + jQuery("[name='refresh_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function(){ refreshTrailJsTree(1); }); - jQuery("[name='refresh_crosses_jstree_html']").click(function(){ + jQuery("[name='refresh_crosses_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function(){ refreshCrossJsTree(1); }); - jQuery("[name='refresh_genotyping_trial_jstree_html']").click( function() { + jQuery("[name='refresh_genotyping_trial_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function() { refreshGenotypingTrialJsTree(1); }); - jQuery("[name='refresh_genotyping_project_jstree_html']").click( function() { + jQuery("[name='refresh_genotyping_project_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function() { refreshGenotypingProjectJsTree(1); }); - jQuery("[name='refresh_activity_jstree_html']").click(function(){ + jQuery("[name='refresh_activity_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function(){ refreshActivityJsTree(1); }); - jQuery("[name='refresh_transformation_project_jstree_html']").click( function() { + jQuery("[name='refresh_transformation_project_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function() { refreshTransformationProjectJsTree(1); }); - jQuery("[name='refresh_propagation_project_jstree_html']").click( function() { + jQuery("[name='refresh_propagation_project_jstree_html']").off('click.cxgnTrialTreeFolders').on('click.cxgnTrialTreeFolders', function() { refreshPropagationProjectJsTree(1); }); @@ -34,7 +34,8 @@ jQuery(document).ready(function($) { function refreshTrailJsTree(refreshpage) { setTimeout(() => { jQuery.ajax({ - url: '/ajax/breeders/get_trials_with_folders?type=trial', + url: '/ajax/breeders/get_trials_with_folders_cached?type=trial&refresh=1&_=' + new Date().getTime(), + cache: false, beforeSend: function() { jQuery("#working_modal").modal("show"); }, diff --git a/js/source/legacy/solGS/analysisSelect.js b/js/source/legacy/solGS/analysisSelect.js index 703da6255d..3625aceac5 100644 --- a/js/source/legacy/solGS/analysisSelect.js +++ b/js/source/legacy/solGS/analysisSelect.js @@ -34,7 +34,6 @@ solGS.analysisSelect = { } return this.datasetId; }, - getAnalysisPopName: function () { var analysisPopName = jQuery("#analysis_pop_name").val(); if (analysisPopName) { @@ -93,8 +92,7 @@ solGS.analysisSelect = { console.log("Tools Compatibility Check: ", toolsCompatibilityCheck); return toolsCompatibilityCheck; -}, - + }, } @@ -105,11 +103,21 @@ jQuery(document).on("change", "#analysis_select", function () { jQuery("#dataset_trials_analysis_message").empty(); jQuery("#run_analysis").prop("disabled", false); if (selectedAnalysis) { - jQuery("#analysis_type").val(selectedAnalysis) + jQuery("#analysis_type").val(selectedAnalysis); + solGS.analysisSelect.getAnalysisPopId(); + solGS.analysisSelect.getAnalysisPopName(); + solGS.analysisSelect.getAnalysisType(); + solGS.analysisSelect.getDataStructure(); + + console.log("Analysis Pop ID: ", solGS.analysisSelect.analysisPopId); + console.log("Analysis Pop Name: ", solGS.analysisSelect.analysisPopname); + console.log("Analysis Type: ", solGS.analysisSelect.analysisType); + console.log("Data Structure: ", solGS.analysisSelect.dataStructure); + var datasetId = solGS.analysisSelect.getDatasetId(); console.log("Dataset ID: ", datasetId); - implementedAnalyses = solGS.analysisSelect.implementedAnalyses; + var implementedAnalyses = solGS.analysisSelect.implementedAnalyses; if (!implementedAnalyses.includes(selectedAnalysis)) { selectedAnalysis = selectedAnalysis.replace(/_/, ' '); jQuery("#dataset_trials_analysis_message").html( @@ -156,11 +164,13 @@ jQuery(document).ready(function () { var analysisPopId = solGS.analysisSelect.getAnalysisPopId(); if (!analysisType) { - jQuery("#dataset_trials_analysis_message").html("Please select an analysis type.").show(); + jQuery("#dataset_trials_analysis_message").html("Please select an analysis type.").show(); + return; } if (!analysisPopId) { - jQuery("#dataset_trials_analysis_message").html("Please select an analysis population.").show(); + jQuery("#dataset_trials_analysis_message").html("Please select an analysis population.").show(); + return; } console.log("Running analysis: ", analysisType, " on population: ", analysisPopId); @@ -172,13 +182,13 @@ jQuery(document).ready(function () { jQuery("#corr_pop_id").val(solGS.analysisSelect.getAnalysisPopId()); jQuery("#data_type").val("Phenotype"); - if (jQuery("#corr_pop_id").val().match(/dataset/) === "") { - jQuery("#data_structure").val('dataset'); + if (!jQuery("#corr_pop_id").val().match(/dataset/)) { + jQuery("#data_structure").val('dataset'); } corrArgs = solGS.correlation.getPhenoCorrArgs(); if (!corrArgs['corr_pop_name']) { - corrArgs['corr_pop_name'] = solGS.analysisSelect.getAnalysisPopName(); + corrArgs['corr_pop_name'] = solGS.analysisSelect.getAnalysisPopName(); } console.log("Correlation args: ", JSON.stringify(corrArgs)); @@ -215,5 +225,4 @@ jQuery(document).ready(function () { }); } }); - }); diff --git a/js/source/legacy/solGS/correlation.js b/js/source/legacy/solGS/correlation.js index 4c21d6f69e..cc304b2d79 100644 --- a/js/source/legacy/solGS/correlation.js +++ b/js/source/legacy/solGS/correlation.js @@ -476,8 +476,9 @@ jQuery(document).ready(function () { jQuery(corrMsgDiv).html("Running correlation... please wait...").show(); solGS.correlation.runPhenoCorrelation(corrArgs).done(function (res) { - if (res.status.match(/success/)) { - corrArgs["corr_table_file"] = res.corre_table_file; + if (res.data || (res.status && res.status.match(/success/))) { + corrArgs["corr_table_file"] = res.corre_table_file; + var corrDownload = solGS.correlation.createCorrDownloadLink(corrArgs); var heatmapArgs = { scatter_input_data: res.corr_input_data, @@ -488,23 +489,11 @@ jQuery(document).ready(function () { axis_mode: 'two', }; - solGS.heatmap.plot(heatmapArgs); + solGS.heatmap.plot(heatmapArgs); } else { - jQuery(corrMsgDiv).html(res.status + " There is no correlation output for this dataset.").fadeOut(8400); + jQuery(corrMsgDiv).html((res.status || "Error.") + " There is no correlation output for this dataset.").fadeOut(8400); } - jQuery(runCorrBtnId).show(); - jQuery(`${canvas} .multi-spinner-container`).hide(); - jQuery(corrMsgDiv).empty(); - }); - - solGS.correlation.runPhenoCorrelation(corrArgs).fail(function (res) { - jQuery(`${canvas} .multi-spinner-container`).hide(); - jQuery(corrMsgDiv).html("Error occured running the correlation analysis.").fadeOut(8400); - jQuery(runCorrBtnId).show(); - }); - }}); - jQuery(document).on("click", "#run_genetic_correlation", function () { var corrPopId = jQuery("#corr_selected_pop_id").val(); diff --git a/js/source/legacy/solGS/trialsSelect.js b/js/source/legacy/solGS/trialsSelect.js index 95e4d61313..4c21f84edf 100644 --- a/js/source/legacy/solGS/trialsSelect.js +++ b/js/source/legacy/solGS/trialsSelect.js @@ -99,8 +99,7 @@ jQuery(document).ready(function () { // Process and display the trials console.log("Trials for dataset " + datasetName + ": ", trials); var trialSelect = jQuery("#trial_select"); - // trialSelect.empty(); - + trialSelect.empty(); var datasetOption = jQuery("") .attr("value", datasetId) .text("Dataset: " + datasetName); @@ -121,7 +120,6 @@ jQuery(document).ready(function () { }); } - }); jQuery(document).on("change", "#trial_select", function () { @@ -140,10 +138,10 @@ jQuery(document).on("change", "#trial_select", function () { jQuery("#analysis_pop_id").val(`dataset_${selectedTrial}`); jQuery("#analysis_pop_name").val(jQuery("#dataset_name").val()); console.log("Data structure set to 'dataset'"); + console.log("Data structure set to 'dataset'"); } - console.log("You have selected trial with ID: " + selectedTrial); } else { console.log("No trial selected."); } -}); \ No newline at end of file +}); diff --git a/lib/CXGN/Phenotypes/ParseUpload/Plugin/PhenotypeSpreadsheetSimpleGeneric.pm b/lib/CXGN/Phenotypes/ParseUpload/Plugin/PhenotypeSpreadsheetSimpleGeneric.pm index 5d14b54ee2..233952c500 100644 --- a/lib/CXGN/Phenotypes/ParseUpload/Plugin/PhenotypeSpreadsheetSimpleGeneric.pm +++ b/lib/CXGN/Phenotypes/ParseUpload/Plugin/PhenotypeSpreadsheetSimpleGeneric.pm @@ -137,10 +137,11 @@ sub parse { } if ( @trait_values && defined($timestamp) ) { - foreach my $tv (@trait_values) { - if ($tv->[0] ne ".") { - push @{$data{$observationunit_name}->{$trait_name}}, $tv; - } + foreach my $tv (@trait_values) { + my $tv0 = ref($tv) eq 'ARRAY' ? $tv->[0] : $tv; + if (defined $tv0 && $tv0 ne ".") { + push @{$data{$observationunit_name}->{$trait_name}}, $tv; + } } } } diff --git a/lib/CXGN/Trial/TrialDesignStore/PhenotypingTrial.pm b/lib/CXGN/Trial/TrialDesignStore/PhenotypingTrial.pm index ded989a38c..0fbfee7107 100644 --- a/lib/CXGN/Trial/TrialDesignStore/PhenotypingTrial.pm +++ b/lib/CXGN/Trial/TrialDesignStore/PhenotypingTrial.pm @@ -55,7 +55,7 @@ sub validate_design { my $error = ''; if (defined $design_type){ - if ($design_type ne 'CRD' && $design_type ne 'Alpha' && $design_type ne 'MAD' && $design_type ne 'Lattice' && $design_type ne 'Augmented' && $design_type ne 'RCBD' && $design_type ne 'RRC' && $design_type ne 'DRRC' && $design_type ne 'URDD'&& $design_type ne 'ARC' && $design_type ne 'p-rep' && $design_type ne 'splitplot' && $design_type ne 'stripplot' && $design_type ne 'greenhouse' && $design_type ne 'Westcott' && $design_type ne 'Analysis'){ + if ($design_type ne 'CRD' && $design_type ne 'Alpha' && $design_type ne 'MAD' && $design_type ne 'Lattice' && $design_type ne 'Augmented' && $design_type ne 'RCBD' && $design_type ne 'RRC' && $design_type ne 'DRRC' && $design_type ne 'URDD'&& $design_type ne 'ARC' && $design_type ne 'p-rep' && $design_type ne 'splitplot' && $design_type ne 'stripplot' && $design_type ne 'greenhouse' && $design_type ne 'Westcott' && $design_type ne 'Analysis' && $design_type ne 'Meeting'){ $error .= "Design $design_type type must be either: CRD, Alpha, Augmented, Lattice, RCBD, RRC, DRRC, URDD, ARC, MAD, p-rep, greenhouse, Westcott, splitplot or stripplot"; return $error; } diff --git a/lib/SGN/Controller/AJAX/BreederSearch.pm b/lib/SGN/Controller/AJAX/BreederSearch.pm index 33a2e3bb6e..ed09314518 100644 --- a/lib/SGN/Controller/AJAX/BreederSearch.pm +++ b/lib/SGN/Controller/AJAX/BreederSearch.pm @@ -36,7 +36,7 @@ sub get_data : Path('/ajax/breeder/search') Args(0) { print STDERR "Validating criteria_list\n"; foreach my $select (@criteria_list) { #ensure criteria list arguments are one of the possible categories chomp($select); - if (! any { $select eq $_ } ('accessions', 'accessions_ids','organisms','breeding_programs', 'genotyping_protocols', 'genotyping_projects', 'locations', 'plants', 'plots', 'subplots','tissue_sample','seedlots', 'trait_components', 'traits', 'trials', 'trial_designs', 'trial_types', 'years', undef)) { + if (! any { $select eq $_ } ('accessions', 'accessions_ids','organisms','breeding_programs', 'genotyping_protocols', 'genotyping_projects', 'locations', 'plants', 'plots', 'subplots','tissue_sample','populations','seedlots', 'trait_components', 'traits', 'trials', 'trial_designs', 'trial_types', 'years', undef)) { $error = "Valid keys are accessions, organisms, breeding_programs, genotyping_protocols, genotyping_projects, locations, plants, plots, subplots,tissue_sample, seedlots, trait_components, traits, trials, trial_designs, trial_types and years or undef"; $c->stash->{rest} = { error => $error }; return; diff --git a/lib/SGN/Controller/AJAX/DecisionMeeting.pm b/lib/SGN/Controller/AJAX/DecisionMeeting.pm new file mode 100644 index 0000000000..c2177ab28d --- /dev/null +++ b/lib/SGN/Controller/AJAX/DecisionMeeting.pm @@ -0,0 +1,2716 @@ +# lib/SGN/Controller/AJAX/DecisionMeeting.pm +package SGN::Controller::AJAX::DecisionMeeting; +use Moose; +use CXGN::List; +use JSON; +use JSON qw(decode_json); +use JSON qw(encode_json); +use Try::Tiny; +use CXGN::BreedersToolbox::Projects; +use SGN::Model::Cvterm; +use CXGN::Trial::TrialCreate; +use CXGN::People::Person; +use CXGN::Dataset; +use CXGN::Dataset::File; +use CXGN::Phenotypes::File; +use File::Spec qw(catfile); +use File::Basename qw(basename); +use File::Path qw(make_path); +use Scalar::Util qw(looks_like_number); +use Excel::Writer::XLSX; +use Spreadsheet::ParseExcel; +use Spreadsheet::ParseXLSX; +use Spreadsheet::WriteExcel; + +BEGIN { extends 'Catalyst::Controller::REST' } + +__PACKAGE__->config( + namespace => 'ajax/decisionmeeting', + default => 'application/json', + stash_key => 'rest', + map => { 'application/json' => 'JSON', 'text/html' => 'JSON' }, +); + +sub ping : Path('ping') : Args(0) : ActionClass('REST') {} +sub ping_GET { + my ($self, $c) = @_; + $self->status_ok($c, entity => { ok => 1, user => ($c->user ? 1 : 0) }); +} + +sub lists : Path('lists') : Args(0) : ActionClass('REST') {} +sub lists_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $owner_id = $c->user->get_object->get_sp_person_id; + my $type_name = 'accessions'; + + my $chado = $c->dbic_schema('Bio::Chado::Schema', 'sgn_chado'); + my $cvterm_id = $chado->resultset('Cv::Cvterm') + ->search({ name => $type_name }) + ->get_column('cvterm_id')->first; + + unless ($cvterm_id) { + $c->log->warn("lists_GET: cvterm not found for type '$type_name'"); + return $self->status_ok($c, entity => { + lists => [], + type_name => $type_name, + type_id => undef + }); + } + + my $people = $c->dbic_schema('CXGN::People::Schema'); + + my $rs = $people->resultset('List')->search( + { owner => $owner_id, type_id => $cvterm_id }, + { order_by => 'name' } + ); + + my @lists = map { + +{ + list_id => int($_->list_id), + name => $_->name, + type_id => $cvterm_id, + type_name => $type_name, + } + } $rs->all; + + $self->status_ok($c, entity => { + lists => \@lists, + type_name => $type_name, + type_id => $cvterm_id + }); +} + +sub programs : Path('programs') : ActionClass('REST') { } +sub programs_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $schema = $c->dbic_schema('Bio::Chado::Schema'); + my $ps = CXGN::BreedersToolbox::Projects->new({ schema => $schema }); + my $programs = $ps->get_breeding_programs(); + + my @items; + foreach my $p (@{ $programs || [] }) { + if (ref $p eq 'ARRAY') { + my ($id, $name) = ($p->[0], $p->[1]); + push @items, { program_id => $id, name => $name } + if defined $id && defined $name; + } + elsif (ref $p eq 'HASH') { + push @items, { + program_id => $p->{program_id} // $p->{project_id} // $p->{id}, + name => $p->{name} // $p->{project_name}, + }; + } + } + + return $self->status_ok($c, entity => \@items); +} + +sub locations : Path('locations') : ActionClass('REST') { } +sub locations_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $schema = $c->dbic_schema('Bio::Chado::Schema'); + my $ps = CXGN::BreedersToolbox::Projects->new({ schema => $schema }); + my $locs = $ps->get_locations() || []; + + my @items; + foreach my $r (@$locs) { + my ($id, $desc, $lat, $lon, $alt, $count) = @$r; + next unless defined $id; + + push @items, { + location_id => $id, + name => defined $desc && $desc ne '' ? $desc : "Location $id", + latitude => $lat, + longitude => $lon, + altitude => $alt, + plot_count => $count, + }; + } + + return $self->status_ok($c, entity => \@items); +} + +sub decisions : Path('decisions') : Args(0) : ActionClass('REST') { } +sub decisions_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $list_id = $c->req->param('list_id'); + my $meeting_id = $c->req->param('meeting_id'); + my $selected_program = $c->req->param('breeding_program') // ''; + + return $self->status_bad_request($c, message => 'Missing list_id') + unless $list_id; + + my $entity = $self->_decision_rows_entity( + $c, + list_id => $list_id, + meeting_id => $meeting_id, + breeding_program => $selected_program, + ); + + return $self->status_ok($c, entity => $entity); +} + +sub _decision_rows_entity { + my ($self, $c, %args) = @_; + + my $list_id = $args{list_id}; + my $meeting_id = $args{meeting_id}; + my $selected_program = $args{breeding_program} // ''; + + my $dbh = $c->dbc->dbh; + my $schema = $c->dbic_schema('Bio::Chado::Schema', 'sgn_chado'); + + my $decision_format = $c->config->{decision_format} || 'state,yy,stage'; + my $breeding_stages = $c->config->{breeding_stages} || 'T1,T2,Y1,Y2,Y3,Y4,Y5'; + + my $notes_prop_name = 'notes'; + my $year_prop_name = 'acquisition date'; + + my $list = CXGN::List->new({ dbh => $dbh, list_id => $list_id }); + my $els = $list->elements || []; + my @accessions = grep { defined $_ && $_ ne '' } @$els; + + return { rows => [] } unless @accessions; + + my $ps = CXGN::BreedersToolbox::Projects->new({ schema => $schema }); + my $programs = $ps->get_breeding_programs() || []; + + my @program_names; + my %seen_program; + my %program_id_to_name; + + foreach my $p (@$programs) { + my ($pid, $nm) = ('', ''); + + if (ref($p) eq 'ARRAY') { + $pid = $p->[0] // ''; + $nm = $p->[1] // ''; + } + elsif (ref($p) eq 'HASH') { + $pid = $p->{program_id} // $p->{project_id} // $p->{id} // ''; + $nm = $p->{name} // $p->{project_name} // ''; + } + + next unless defined $nm && $nm ne ''; + + $program_id_to_name{$pid} = $nm if $pid ne ''; + + next if $seen_program{$nm}++; + push @program_names, $nm; + } + + if (!$selected_program && $meeting_id) { + my $sth = $dbh->prepare(q{ + SELECT pp.value + FROM projectprop pp + WHERE pp.project_id = ? + AND pp.type_id = ( + SELECT cvterm_id + FROM cvterm + WHERE name = 'meeting_json' + LIMIT 1 + ) + ORDER BY pp.projectprop_id DESC + LIMIT 1 + }); + $sth->execute($meeting_id); + + my ($meeting_json) = $sth->fetchrow_array; + if ($meeting_json) { + my $decoded = {}; + eval { $decoded = decode_json($meeting_json); }; + $decoded ||= {}; + + if ($decoded->{breeding_program_name}) { + $selected_program = $decoded->{breeding_program_name}; + } + elsif ($decoded->{breeding_program_choice}) { + my $bp = $decoded->{breeding_program_choice}; + $selected_program = exists $program_id_to_name{$bp} + ? $program_id_to_name{$bp} + : $bp; + } + elsif ($decoded->{breeding_program}) { + my $bp = $decoded->{breeding_program}; + $selected_program = exists $program_id_to_name{$bp} + ? $program_id_to_name{$bp} + : $bp; + } + } + } + + if ($selected_program && exists $program_id_to_name{$selected_program}) { + $selected_program = $program_id_to_name{$selected_program}; + } + + my @programs_to_use = $selected_program ? ($selected_program) : @program_names; + + my %pedigree_by_acc; + if (@accessions) { + my $female_cvterm = SGN::Model::Cvterm->get_cvterm_row($schema, 'female_parent', 'stock_relationship'); + my $male_cvterm = SGN::Model::Cvterm->get_cvterm_row($schema, 'male_parent', 'stock_relationship'); + + my $female_type_id = $female_cvterm ? $female_cvterm->cvterm_id : undef; + my $male_type_id = $male_cvterm ? $male_cvterm->cvterm_id : undef; + + if ($female_type_id || $male_type_id) { + my $placeholders = join(',', ('?') x @accessions); + + my @bind = (); + my $sql = qq{ + SELECT + s.uniquename AS accession, + mother.uniquename AS female_parent, + father.uniquename AS male_parent + FROM stock s + }; + + if ($female_type_id) { + $sql .= qq{ + LEFT JOIN stock_relationship m_rel + ON s.stock_id = m_rel.object_id + AND m_rel.type_id = ? + LEFT JOIN stock mother + ON m_rel.subject_id = mother.stock_id + }; + push @bind, $female_type_id; + } + else { + $sql .= qq{ LEFT JOIN stock mother ON 1=0 }; + } + + if ($male_type_id) { + $sql .= qq{ + LEFT JOIN stock_relationship f_rel + ON s.stock_id = f_rel.object_id + AND f_rel.type_id = ? + LEFT JOIN stock father + ON f_rel.subject_id = father.stock_id + }; + push @bind, $male_type_id; + } + else { + $sql .= qq{ LEFT JOIN stock father ON 1=0 }; + } + + $sql .= qq{ WHERE s.uniquename IN ($placeholders) }; + push @bind, @accessions; + + my $sth = $dbh->prepare($sql); + $sth->execute(@bind); + + while (my $row = $sth->fetchrow_hashref) { + $pedigree_by_acc{$row->{accession}} = { + female_parent => $row->{female_parent} || '', + male_parent => $row->{male_parent} || '', + }; + } + } + } + + my $notes_cvterm = SGN::Model::Cvterm->get_cvterm_row($schema, $notes_prop_name, 'stock_property'); + my $year_cvterm = SGN::Model::Cvterm->get_cvterm_row($schema, $year_prop_name, 'stock_property'); + + my @rows; + + foreach my $acc (@accessions) { + my $stock_row = $schema->resultset('Stock::Stock')->search( + { uniquename => $acc }, + { rows => 1 } + )->first; + + my $female_parent = exists $pedigree_by_acc{$acc} + ? ($pedigree_by_acc{$acc}{female_parent} || '') + : ''; + my $male_parent = exists $pedigree_by_acc{$acc} + ? ($pedigree_by_acc{$acc}{male_parent} || '') + : ''; + + my $notes_value = ''; + my $year_value = ''; + + if ($stock_row) { + if ($notes_cvterm) { + my $notes_prop = $stock_row->search_related( + 'stockprops', + { type_id => $notes_cvterm->cvterm_id }, + { order_by => { -desc => 'stockprop_id' }, rows => 1 } + )->first; + $notes_value = defined($notes_prop) ? ($notes_prop->value // '') : ''; + } + + if ($year_cvterm) { + my $year_prop = $stock_row->search_related( + 'stockprops', + { type_id => $year_cvterm->cvterm_id }, + { order_by => { -desc => 'stockprop_id' }, rows => 1 } + )->first; + $year_value = defined($year_prop) ? ($year_prop->value // '') : ''; + } + } + + foreach my $bp (@programs_to_use) { + my $stage_prop_name = $bp . '_Stage'; + my $stage_value = ''; + + if ($stock_row) { + my $stage_cvterm = SGN::Model::Cvterm->get_cvterm_row( + $schema, + $stage_prop_name, + 'stock_property' + ); + + if ($stage_cvterm) { + my $prop = $stock_row->search_related( + 'stockprops', + { type_id => $stage_cvterm->cvterm_id }, + { order_by => { -desc => 'stockprop_id' }, rows => 1 } + )->first; + + $stage_value = defined($prop) ? ($prop->value // '') : ''; + } + } + + my $decision_value = ''; + my $new_stage = $self->_compute_new_stage( + current_stage_value => $stage_value, + decision_value => $decision_value, + year_value => $year_value, + decision_format => $decision_format, + breeding_stages => $breeding_stages, + ); + + push @rows, { + stock_id => $stock_row ? $stock_row->stock_id : undef, + accession => $acc, + breeding_program => $bp, + stage => $stage_value, + year => $year_value, + decision => $decision_value, + new_stage => $new_stage, + female_parent => $female_parent, + male_parent => $male_parent, + notes => $notes_value, + }; + } + } + + return { rows => \@rows }; +} + +sub _decision_upload_headers { + return ( + 'Accession', + 'Breeding Program', + 'Previous Stage', + 'Decision', + 'New Stage', + 'Notes', + 'Comment', + ); +} + +sub _normalize_decision_upload_header { + my ($self, $value) = @_; + $value = defined $value ? "$value" : ''; + $value =~ s/^\s+|\s+$//g; + $value = lc($value); + $value =~ s/[^a-z0-9]+/ /g; + $value =~ s/\s+/ /g; + $value =~ s/^\s+|\s+$//g; + return $value; +} + +sub _parse_decision_upload_file { + my ($self, %args) = @_; + + my $filename = $args{filename} || ''; + my $original_filename = $args{original_filename} || $filename; + my @errors; + + my ($extension) = $original_filename =~ /(\.[^.]+)$/; + $extension = lc($extension || ''); + + my $parser; + if ($extension eq '.xlsx') { + $parser = Spreadsheet::ParseXLSX->new(); + } + elsif ($extension eq '.xls') { + $parser = Spreadsheet::ParseExcel->new(); + } + else { + return (undef, ['The uploaded file must be an Excel .xls or .xlsx file.']); + } + + my $excel_obj = $parser->parse($filename); + if (!$excel_obj) { + my $err = eval { $parser->error() } || 'Could not parse the Excel file.'; + return (undef, [$err]); + } + + my $worksheet = ($excel_obj->worksheets())[0]; + unless ($worksheet) { + return (undef, ['Spreadsheet must be on the first worksheet.']); + } + + my ($row_min, $row_max) = $worksheet->row_range(); + my ($col_min, $col_max) = $worksheet->col_range(); + if (!defined $row_max || $row_max < 1) { + return (undef, ['Spreadsheet is missing data rows.']); + } + + my @expected_headers = $self->_decision_upload_headers(); + my @normalized_expected = map { $self->_normalize_decision_upload_header($_) } @expected_headers; + + for my $idx (0 .. $#expected_headers) { + my $cell = $worksheet->get_cell($row_min, $idx); + my $header = $cell ? $cell->value() : ''; + my $normalized = $self->_normalize_decision_upload_header($header); + if ($normalized ne $normalized_expected[$idx]) { + my $col_letter = chr(65 + $idx); + push @errors, "Cell ${col_letter}1 must contain '$expected_headers[$idx]'."; + } + } + + return (undef, \@errors) if @errors; + + my @rows; + my %allowed_decisions = map { $_ => 1 } qw(drop hold advance jump); + + for my $row ($row_min + 1 .. $row_max) { + my @values; + for my $col (0 .. $#expected_headers) { + my $cell = $worksheet->get_cell($row, $col); + my $value = $cell ? $cell->value() : ''; + $value = '' unless defined $value; + $value =~ s/^\s+|\s+$//g; + push @values, $value; + } + + next unless grep { defined $_ && $_ ne '' } @values; + + my $row_number = $row + 1; + my ($accession, $breeding_program, $previous_stage, $decision, $new_stage, $notes, $comment) = @values; + + if ($accession eq '') { + push @errors, "Cell A$row_number: accession is required."; + } + if ($breeding_program eq '') { + push @errors, "Cell B$row_number: breeding program is required."; + } + + my $decision_norm = lc($decision || ''); + if ($decision_norm ne '' && !$allowed_decisions{$decision_norm}) { + push @errors, "Cell D$row_number: decision must be one of drop, hold, advance, or jump."; + } + + push @rows, { + accession => $accession, + breeding_program => $breeding_program, + previous_stage => $previous_stage, + decision => $decision_norm, + new_stage => $new_stage, + notes => $notes, + save_comment => $comment, + row_number => $row_number, + }; + } + + if (!@rows) { + push @errors, 'Spreadsheet contains no accession decision rows.'; + } + + return (undef, \@errors) if @errors; + return (\@rows, []); +} + +sub _decision_format_config { + my ($self, $c) = @_; + return $c->config->{decision_format} || 'state,year yy,stage'; +} + +sub _breeding_stages_config { + my ($self, $c) = @_; + my $raw_stages_conf = $c->config->{breeding_stages}; + my $breeding_stages = ''; + + if (ref($raw_stages_conf) eq 'ARRAY') { + $breeding_stages = defined($raw_stages_conf->[0]) ? $raw_stages_conf->[0] : ''; + } + else { + $breeding_stages = defined($raw_stages_conf) ? $raw_stages_conf : ''; + } + + return $breeding_stages; +} + +sub _meeting_year_from_meeting_id { + my ($self, $c, $meeting_id) = @_; + return '' unless $meeting_id; + + my $dbh = $c->dbc->dbh; + my $sth = $dbh->prepare(q{ + SELECT pp.value + FROM projectprop pp + WHERE pp.project_id = ? + AND pp.type_id = ( + SELECT cvterm_id + FROM cvterm + WHERE name = 'meeting_json' + LIMIT 1 + ) + ORDER BY pp.projectprop_id DESC + LIMIT 1 + }); + $sth->execute($meeting_id); + + my ($meeting_json) = $sth->fetchrow_array; + return '' unless $meeting_json; + + my $decoded = {}; + eval { $decoded = decode_json($meeting_json); }; + $decoded ||= {}; + + my $date = $decoded->{date} || ''; + return $1 if $date =~ /^(\d{4})-/; + + return ''; +} + +sub _stage_name_suggestion { + my ($self, $candidate, $choices) = @_; + + $candidate = defined $candidate ? "$candidate" : ''; + $candidate =~ s/^\s+|\s+$//g; + return '' if $candidate eq ''; + + my $cand_lc = lc($candidate); + foreach my $choice (@{$choices || []}) { + next unless defined $choice && $choice ne ''; + return $choice if lc($choice) eq $cand_lc; + } + foreach my $choice (@{$choices || []}) { + next unless defined $choice && $choice ne ''; + return $choice if index(lc($choice), $cand_lc) >= 0 || index($cand_lc, lc($choice)) >= 0; + } + return $choices && @{$choices} ? $choices->[0] : ''; +} + +sub _is_drop_stage_value { + my ($self, $value) = @_; + $value = defined $value ? "$value" : ''; + $value =~ s/^\s+|\s+$//g; + return 0 if $value eq ''; + return $value =~ /^DROP(?:-|$)/i ? 1 : 0; +} + +sub _compute_stage_transition_data { + my ($self, %args) = @_; + + my $current_stage = $args{current_stage}; + my $decision = lc($args{decision} // ''); + my $year = $args{year}; + my $stock_id = $args{stock_id}; + my $selected_stage = $args{selected_stage} || ''; + my $decision_format = $args{decision_format} || 'state,year yy,stage'; + my $breeding_stages = $args{breeding_stages} || ''; + my $schema = $args{schema}; + + my @ordered_stages = grep { defined($_) && $_ ne '' } + map { my $x = $_; $x =~ s/^\s+|\s+$//g; $x } + split(/\s*,\s*/, $breeding_stages); + + my %pos; + @pos{@ordered_stages} = (0 .. $#ordered_stages); + + my $current_stage_token = ''; + + if (defined $current_stage && $current_stage ne '') { + my $tmp = $current_stage; + $tmp =~ s/^\s+|\s+$//g; + + if (exists $pos{$tmp}) { + $current_stage_token = $tmp; + } + elsif ($tmp =~ /-([^-]+)$/) { + my $last = $1; + $last =~ s/^\s+|\s+$//g; + if (exists $pos{$last}) { + $current_stage_token = $last; + } + } + } + + my @allowed_stages; + if ($current_stage_token ne '' && exists $pos{$current_stage_token}) { + my $idx = $pos{$current_stage_token}; + + if ($decision eq 'advance') { + @allowed_stages = @ordered_stages[($idx + 1) .. $#ordered_stages] + if $idx < $#ordered_stages; + } + elsif ($decision eq 'jump') { + my $start = $idx + 2; + @allowed_stages = @ordered_stages[$start .. $#ordered_stages] + if $start <= $#ordered_stages; + } + } + + my $stage_only = ''; + my $state = $self->_get_stockprop_value( + schema => $schema, + stock_id => $stock_id, + prop_name => 'state', + ); + + my $state_for_format = $state; + + if ($decision eq 'advance' || $decision eq 'jump') { + my %allowed_lookup = map { $_ => 1 } @allowed_stages; + + if ($selected_stage && $allowed_lookup{$selected_stage}) { + $stage_only = $selected_stage; + } + else { + $stage_only = ''; + } + } + elsif ($decision eq 'hold') { + $stage_only = $current_stage_token || ''; + } + elsif ($decision eq 'drop') { + $stage_only = $current_stage_token || ''; + $state_for_format = 'DROP'; + } + else { + return { + new_stage => '', + selected_stage => '', + allowed_stages => [], + state => '', + stock_id => $stock_id, + decision_format => $decision_format, + current_stage_token => $current_stage_token, + ordered_stages => \@ordered_stages, + }; + } + + my $new_stage = ''; + if ($decision eq 'jump') { + if ($stage_only ne '' && $current_stage_token ne '') { + my $jump_year = $year // ''; + $jump_year =~ s/^\s+|\s+$//g; + + if (($decision_format || '') =~ /\byear\s*yy\b/i) { + $jump_year = substr($jump_year, -2) if $jump_year ne ''; + } + + my $jump_from = $current_stage_token; + $jump_from =~ s/^\s+|\s+$//g; + my @jump_parts = grep { defined $_ && $_ ne '' } split /-/, $jump_from; + $jump_from = @jump_parts ? $jump_parts[-1] : ''; + + $new_stage = join('-', grep { defined($_) && $_ ne '' } + 'JUMP', + $jump_year, + $jump_from, + $stage_only, + ); + } + } + elsif ($decision eq 'advance') { + if ($stage_only ne '') { + $new_stage = $self->_format_decision_stage( + decision_format => $decision_format, + year => $year, + stage => $stage_only, + state => $state_for_format, + ); + } + } + else { + $new_stage = $self->_format_decision_stage( + decision_format => $decision_format, + year => $year, + stage => $stage_only, + state => $state_for_format, + ); + } + + return { + new_stage => $new_stage, + selected_stage => $stage_only, + allowed_stages => \@allowed_stages, + state => $state_for_format, + stock_id => $stock_id, + decision_format => $decision_format, + current_stage_token => $current_stage_token, + ordered_stages => \@ordered_stages, + }; +} + +sub _validate_uploaded_decision_rows { + my ($self, %args) = @_; + + my $c = $args{c}; + my $schema = $args{schema}; + my $meeting_year = $args{meeting_year} || ''; + my $current_rows = $args{current_rows} || []; + my $parsed_rows = $args{parsed_rows} || []; + + my $decision_format = $self->_decision_format_config($c); + my $breeding_stages = $self->_breeding_stages_config($c); + + my %current_lookup; + foreach my $row (@$current_rows) { + my $key = join("\t", lc($row->{accession} || ''), lc($row->{breeding_program} || '')); + $current_lookup{$key} = $row; + } + + my @errors; + my @unmatched_rows; + + foreach my $uploaded (@$parsed_rows) { + my $key = join("\t", lc($uploaded->{accession} || ''), lc($uploaded->{breeding_program} || '')); + my $target = $current_lookup{$key}; + unless ($target) { + push @unmatched_rows, { + accession => $uploaded->{accession} || '', + breeding_program => $uploaded->{breeding_program} || '', + row_number => $uploaded->{row_number}, + }; + next; + } + + my $decision = $uploaded->{decision} || ''; + my $new_stage = $uploaded->{new_stage} || ''; + my $row_number = $uploaded->{row_number} || '?'; + my $current_stage_value = $target->{stage} || ''; + + if ($self->_is_drop_stage_value($current_stage_value)) { + if ($decision ne '') { + push @errors, "Row $row_number: current stage '$current_stage_value' is already a DROP stage, so no further decision can be applied to this accession."; + next; + } + next; + } + + if ($decision eq '') { + if ($new_stage ne '') { + push @errors, "Row $row_number: new stage '$new_stage' was provided but decision is empty."; + } + next; + } + + my $transition = $self->_compute_stage_transition_data( + current_stage => $target->{stage}, + decision => $decision, + year => $meeting_year, + stock_id => $target->{stock_id}, + selected_stage => '', + decision_format => $decision_format, + breeding_stages => $breeding_stages, + schema => $schema, + ); + + my @allowed = @{$transition->{allowed_stages} || []}; + my @all_stages = @{$transition->{ordered_stages} || []}; + my $uploaded_token = $self->_normalize_stage_token($new_stage, $breeding_stages); + + if (($decision eq 'advance' || $decision eq 'jump') && !@allowed) { + push @errors, "Row $row_number: decision '$decision' is not compatible with current stage '" . ($target->{stage} || '') . "'."; + next; + } + + if ($decision eq 'advance' || $decision eq 'jump') { + if ($new_stage eq '') { + push @errors, "Row $row_number: decision '$decision' requires a new stage."; + next; + } + + if ($uploaded_token eq '') { + my $suggest = $self->_stage_name_suggestion($new_stage, \@allowed) || $self->_stage_name_suggestion($new_stage, \@all_stages); + my $msg = "Row $row_number: stage '$new_stage' is not stored."; + $msg .= " Did you mean '$suggest'?" if $suggest ne ''; + push @errors, $msg; + next; + } + + my %allowed_lookup = map { $_ => 1 } @allowed; + if (!$allowed_lookup{$uploaded_token}) { + push @errors, "Row $row_number: this stage '$new_stage' is not compatible with the decision '$decision'. Allowed target stages: " . join(', ', @allowed) . "."; + next; + } + + my $expected = $self->_compute_stage_transition_data( + current_stage => $target->{stage}, + decision => $decision, + year => $meeting_year, + stock_id => $target->{stock_id}, + selected_stage => $uploaded_token, + decision_format => $decision_format, + breeding_stages => $breeding_stages, + schema => $schema, + ); + + if (($expected->{new_stage} || '') ne $new_stage && $new_stage ne $uploaded_token) { + push @errors, "Row $row_number: this stage '$new_stage' is not compatible with the decision '$decision'. Did you mean '$expected->{new_stage}'?"; + next; + } + } + else { + my $expected = $self->_compute_stage_transition_data( + current_stage => $target->{stage}, + decision => $decision, + year => $meeting_year, + stock_id => $target->{stock_id}, + selected_stage => '', + decision_format => $decision_format, + breeding_stages => $breeding_stages, + schema => $schema, + ); + my $expected_new_stage = $expected->{new_stage} || ''; + my $current_stage_for_hold = $target->{stage} || ''; + + if ($new_stage eq '') { + my $required_stage = $decision eq 'hold' ? ($current_stage_for_hold || $expected_new_stage) : $expected_new_stage; + push @errors, "Row $row_number: decision '$decision' requires new stage '$required_stage'."; + next; + } + + if ($decision eq 'hold') { + if ($new_stage ne $expected_new_stage && $new_stage ne $current_stage_for_hold) { + my $suggest = $current_stage_for_hold || $expected_new_stage; + push @errors, "Row $row_number: this stage '$new_stage' is not compatible with the decision '$decision'. Did you mean '$suggest'?"; + next; + } + } + elsif ($new_stage ne $expected_new_stage) { + push @errors, "Row $row_number: this stage '$new_stage' is not compatible with the decision '$decision'. Did you mean '$expected_new_stage'?"; + next; + } + } + } + + return (\@errors, \@unmatched_rows); +} + +sub _normalize_stage_token { + my ($self, $value, $breeding_stages) = @_; + + my $raw = defined $value ? "$value" : ''; + $raw =~ s/^\s+|\s+$//g; + return '' if $raw eq ''; + + my @ordered_stages = $self->_config_list($breeding_stages); + my %valid = map { $_ => 1 } @ordered_stages; + + return $raw if $valid{$raw}; + + my $token = $self->_extract_stage_token($raw); + $token = defined $token ? "$token" : ''; + $token =~ s/^\s+|\s+$//g; + return $token if $token ne '' && $valid{$token}; + + if ($raw =~ /-([^-]+)$/) { + my $last = $1; + $last =~ s/^\s+|\s+$//g; + return $last if $valid{$last}; + } + + foreach my $stg (sort { length($b) <=> length($a) } @ordered_stages) { + if ($raw =~ /\Q$stg\E/) { + return $stg; + } + } + + return ''; +} + +sub stages_GET : Path('/ajax/decisionmeeting/stages') Args(0) { + my ($self, $c) = @_; + + unless ($c->user) { + $c->res->status(403); + $c->res->content_type('application/json'); + $c->res->body(encode_json({ error => 'Login required' })); + $c->detach(); + } + + my $conf_stages = $c->config->{breeding_stages}; + my $raw = ''; + + if (ref($conf_stages) eq 'ARRAY') { + $raw = defined $conf_stages->[0] ? $conf_stages->[0] : ''; + } + elsif (defined $conf_stages) { + $raw = $conf_stages; + } + + $raw =~ s/^\s+|\s+$//g; + + my @stages = grep { defined($_) && $_ ne '' } + map { + my $x = $_; + $x =~ s/^\s+|\s+$//g; + $x; + } + split(/\s*,\s*/, $raw); + + $c->res->status(200); + $c->res->content_type('application/json'); + $c->res->body(encode_json({ stages => \@stages })); + $c->detach(); +} + +sub compute_new_stage : Path('compute_new_stage') : Args(0) : ActionClass('REST') { } + +sub compute_new_stage_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $current_stage = $c->req->param('current_stage'); + my $decision = lc($c->req->param('decision') // ''); + my $year = $c->req->param('year'); + my $stock_id = $c->req->param('stock_id'); + my $selected_stage = $c->req->param('selected_stage') || ''; + + my $decision_format = $self->_decision_format_config($c); + my $breeding_stages = $self->_breeding_stages_config($c); + + my $schema = $c->dbic_schema('Bio::Chado::Schema', 'sgn_chado'); + my $result = $self->_compute_stage_transition_data( + current_stage => $current_stage, + decision => $decision, + year => $year, + stock_id => $stock_id, + selected_stage => $selected_stage, + decision_format => $decision_format, + breeding_stages => $breeding_stages, + schema => $schema, + ); + + return $self->status_ok($c, entity => $result); +} + +sub compute_new_stage_POST { + my ($self, $c) = @_; + return $self->compute_new_stage_GET($c); +} + +sub _format_decision_stage { + my ($self, %args) = @_; + + my $decision_format = $args{decision_format} // ''; + my $year = $args{year} // ''; + my $stage = $args{stage} // ''; + my $state = $args{state} // ''; + + my ($format_only) = split /\#/, $decision_format, 2; + $format_only //= ''; + $format_only =~ s/^\s+|\s+$//g; + + my @parts; + my @tokens = grep { $_ ne '' } map { + my $x = $_; + $x =~ s/^\s+|\s+$//g; + $x; + } split /,/, $format_only; + + for my $token (@tokens) { + my ($field, $modifier) = split /\s+/, $token, 2; + $field //= ''; + $modifier //= ''; + + if ($field eq 'state') { + push @parts, $state if defined $state && $state ne ''; + } + elsif ($field eq 'year') { + my $y = $year // ''; + if ($modifier eq 'yy') { + $y = substr($y, -2); + } + elsif ($modifier eq 'YYYY' || $modifier eq 'yyyy' || $modifier eq '') { + } + push @parts, $y if $y ne ''; + } + elsif ($field eq 'stage') { + push @parts, $stage if defined $stage && $stage ne ''; + } + } + + my $final = join('-', @parts); + return $final; +} + +sub _get_stockprop_value { + my ($self, %args) = @_; + + my $schema = $args{schema}; + my $stock_id = $args{stock_id}; + my $prop_name = $args{prop_name}; + + return '' unless $schema && $stock_id && $prop_name; + + my $cvterm_row = SGN::Model::Cvterm->get_cvterm_row($schema, $prop_name, 'stock_property'); + return '' unless $cvterm_row; + + my $type_id = $cvterm_row->cvterm_id; + + my $stockprop = $schema->resultset('Stock::Stockprop')->search( + { + stock_id => $stock_id, + type_id => $type_id, + }, + { + order_by => { -desc => 'stockprop_id' }, + rows => 1, + } + )->single; + + my $value = $stockprop ? $stockprop->value : ''; + return $value // ''; +} + +sub datasets : Path('datasets') : Args(0) : ActionClass('REST') {} +sub datasets_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $dbh = $c->dbc->dbh; + my @datasets; + + eval { + my $sql = q{ + SELECT sp_dataset_id, name + FROM sgn_people.sp_dataset + ORDER BY name + }; + + my $sth = $dbh->prepare($sql); + $sth->execute(); + + while (my ($dataset_id, $name) = $sth->fetchrow_array) { + push @datasets, { + dataset_id => int($dataset_id), + name => ($name || "Dataset $dataset_id"), + }; + } + }; + + if ($@) { + return $self->status_ok($c, entity => { + error => "Failed to load datasets", + details => "$@", + datasets => [] + }); + } + + return $self->status_ok($c, entity => { + datasets => \@datasets + }); +} + +sub dataset_summary : Path('dataset_summary') : Args(0) : ActionClass('REST') { } +sub dataset_summary_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $dataset_id = $c->req->param('dataset_id'); + return $self->status_bad_request($c, message => 'Missing dataset_id') + unless $dataset_id; + + $c->tempfiles_subdir("decisionmeeting"); + my ($fh, $tempfile) = $c->tempfile(TEMPLATE => "decisionmeeting/dm_XXXXX"); + + my $sp_person_id = $c->user ? $c->user->get_object->get_sp_person_id : undef; + my $people_schema = $c->dbic_schema("CXGN::People::Schema", undef, $sp_person_id); + my $schema = $c->dbic_schema("Bio::Chado::Schema", "sgn_chado"); + my $temppath = $c->config->{basepath} . "/" . $tempfile; + + my $ds_json = CXGN::Dataset->new( + people_schema => $people_schema, + schema => $schema, + sp_dataset_id => $dataset_id + ); + + $ds_json->retrieve_traits(); + my $ds_traits = $ds_json->traits(); + + if (!$ds_traits || ref($ds_traits) ne 'ARRAY' || !@$ds_traits) { + return $self->status_ok($c, entity => { + summary => [], + error => "No traits found in the dataset. Please select a dataset with trial(s) and trait(s)." + }); + } + + my $ds = CXGN::Dataset::File->new( + people_schema => $people_schema, + schema => $schema, + sp_dataset_id => $dataset_id, + exclude_dataset_outliers => 1, + file_name => $temppath, + quotes => 0 + ); + + $ds->retrieve_phenotypes(); + + my $phenofile = $temppath . "_phenotype.txt"; + + unless (-e $phenofile) { + return $self->status_ok($c, entity => { + summary => [], + error => "Phenotype file was not generated." + }); + } + + open(my $in, '<', $phenofile) or die "Cannot open phenotype file '$phenofile': $!"; + + my $header = <$in>; + unless (defined $header) { + close($in); + return $self->status_ok($c, entity => { + summary => [], + error => "Phenotype file is empty." + }); + } + + chomp($header); + $header =~ s/\r$//; + my @cols = split(/\t/, $header, -1); + + my $germplasm_idx = -1; + for my $i (0 .. $#cols) { + if (defined $cols[$i] && $cols[$i] eq 'germplasmName') { + $germplasm_idx = $i; + last; + } + } + + if ($germplasm_idx < 0) { + close($in); + return $self->status_ok($c, entity => { + summary => [], + error => "germplasmName column not found in phenotype file." + }); + } + + my @trait_cols; + for my $i (0 .. $#cols) { + my $col = $cols[$i]; + next unless defined $col; + + $col =~ s/^\s+|\s+$//g; + next unless $col =~ /:/; + + push @trait_cols, [$i, $col]; + } + + my %grouped_values; + + while (my $line = <$in>) { + chomp($line); + $line =~ s/\r$//; + next if $line =~ /^\s*$/; + + my @fields = split(/\t/, $line, -1); + + next unless defined $fields[$germplasm_idx]; + my $germplasm = $fields[$germplasm_idx]; + $germplasm =~ s/^\s+|\s+$//g; + next if $germplasm eq ''; + + foreach my $tc (@trait_cols) { + my ($idx, $trait) = @$tc; + next unless defined $fields[$idx]; + + my $v = $fields[$idx]; + $v =~ s/^\s+|\s+$//g; + + next if $v eq ''; + next unless $v =~ /^-?(?:\d+(?:\.\d+)?|\.\d+)$/; + + push @{ $grouped_values{$germplasm}{$trait} }, $v + 0; + } + } + close($in); + + my @rows; + foreach my $germplasm (sort keys %grouped_values) { + foreach my $trait (sort keys %{ $grouped_values{$germplasm} }) { + my @vals = @{ $grouped_values{$germplasm}{$trait} || [] }; + next unless @vals; + + my $n = scalar(@vals); + + my $sum = 0; + $sum += $_ for @vals; + my $avg = $sum / $n; + + my ($min, $max) = ($vals[0], $vals[0]); + for my $v (@vals) { + $min = $v if $v < $min; + $max = $v if $v > $max; + } + + my $sq = 0; + $sq += ($_ - $avg) ** 2 for @vals; + my $std = $n > 1 ? sqrt($sq / ($n - 1)) : 0; + + push @rows, { + accession => $germplasm, + trait => $trait, + min => sprintf("%.3f", $min), + max => sprintf("%.3f", $max), + average => sprintf("%.3f", $avg), + std => sprintf("%.3f", $std), + }; + } + } + + return $self->status_ok($c, entity => { + summary => \@rows, + tempfile => $tempfile . "_phenotype.txt", + }); +} + +sub accessions : Path('accessions') : Args(0) : ActionClass('REST') {} +sub accessions_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $dataset_id = $c->req->param('dataset_id'); + my $list_id = $c->req->param('list_id'); + + my $sp_person_id = $c->user ? $c->user->get_object->get_sp_person_id : undef; + my $people_schema = $c->dbic_schema("CXGN::People::Schema", undef, $sp_person_id); + my $schema = $c->dbic_schema("Bio::Chado::Schema", "sgn_chado"); + my $dbh = $c->dbc->dbh; + + my @names; + + if ($dataset_id) { + my $ds = CXGN::Dataset->new( + people_schema => $people_schema, + schema => $schema, + sp_dataset_id => $dataset_id + ); + eval { $ds->retrieve_accessions() }; + if ($ds->can('accessions') && ref($ds->accessions) eq 'ARRAY') { + @names = @{$ds->accessions}; + } + elsif (my $ret = eval { $ds->retrieve_accessions() }) { + @names = @{ $ret->{data} || [] } if ref($ret) eq 'HASH'; + } + elsif ($ds->can('accession_list') && ref($ds->accession_list) eq 'ARRAY') { + @names = @{$ds->accession_list}; + } + } + elsif ($list_id) { + my $list = CXGN::List->new({ dbh => $dbh, list_id => $list_id }); + my $els = $list->elements; + @names = @$els if $els && ref($els) eq 'ARRAY'; + } + + my @accs = map { +{ accession_id => undef, name => "$_" } } + grep { defined && $_ ne '' } @names; + + $self->status_ok($c, entity => { accessions => \@accs }); +} + +sub _dm_trim { + my ($self, $v) = @_; + $v = '' unless defined $v; + $v =~ s/^\s+//; + $v =~ s/\s+$//; + return $v; +} + +sub _dm_is_numeric { + my ($self, $v) = @_; + return 0 unless defined $v; + $v = $self->_dm_trim($v); + return 0 if $v eq ''; + return $v =~ /^-?(?:\d+(?:\.\d+)?|\.\d+)$/ ? 1 : 0; +} + +sub _dm_build_dataset_phenofile { + my ($self, $c, $dataset_id) = @_; + + $c->tempfiles_subdir("decisionmeeting"); + my ($fh, $tempfile) = $c->tempfile(TEMPLATE => "decisionmeeting/dm_XXXXX"); + + my $sp_person_id = $c->user ? $c->user->get_object->get_sp_person_id : undef; + my $people_schema = $c->dbic_schema("CXGN::People::Schema", undef, $sp_person_id); + my $schema = $c->dbic_schema("Bio::Chado::Schema", "sgn_chado"); + my $temppath = $c->config->{basepath} . "/" . $tempfile; + + my $ds_json = CXGN::Dataset->new( + people_schema => $people_schema, + schema => $schema, + sp_dataset_id => $dataset_id + ); + + $ds_json->retrieve_traits(); + my $ds_traits = $ds_json->traits(); + + if (!$ds_traits || ref($ds_traits) ne 'ARRAY' || !@$ds_traits) { + return { + error => "No traits found in the dataset. Please select a dataset with trial(s) and trait(s).", + summary => [], + }; + } + + my $ds = CXGN::Dataset::File->new( + people_schema => $people_schema, + schema => $schema, + sp_dataset_id => $dataset_id, + exclude_dataset_outliers => 1, + file_name => $temppath, + quotes => 0 + ); + + $ds->retrieve_phenotypes(); + + my $phenofile = $temppath . "_phenotype.txt"; + + unless (-e $phenofile) { + return { + error => "Phenotype file was not generated.", + summary => [], + }; + } + + return { + phenofile => $phenofile, + tempfile => $tempfile . "_phenotype.txt", + ds_traits => $ds_traits, + }; +} + +sub dataset_traits : Path('dataset_traits') : Args(0) : ActionClass('REST') { } +sub dataset_traits_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $dataset_id = $c->req->param('dataset_id'); + return $self->status_bad_request($c, message => 'Missing dataset_id') + unless $dataset_id; + + my $res = $self->_dm_build_dataset_phenofile($c, $dataset_id); + if ($res->{error}) { + return $self->status_ok($c, entity => { + traits => [], + accessions => [], + error => $res->{error}, + }); + } + + my $phenofile = $res->{phenofile}; + + open(my $in, '<', $phenofile) or die "Cannot open phenotype file '$phenofile': $!"; + + my $header = <$in>; + unless (defined $header) { + close($in); + return $self->status_ok($c, entity => { + traits => [], + accessions => [], + error => "Phenotype file is empty.", + }); + } + + chomp($header); + $header =~ s/\r$//; + my @cols = split(/\t/, $header, -1); + + my $germplasm_idx = -1; + for my $i (0 .. $#cols) { + my $col = $self->_dm_trim($cols[$i]); + if ($col eq 'germplasmName') { + $germplasm_idx = $i; + last; + } + } + + if ($germplasm_idx < 0) { + close($in); + return $self->status_ok($c, entity => { + traits => [], + accessions => [], + error => "germplasmName column not found in phenotype file.", + }); + } + + my @traits; + for my $i (0 .. $#cols) { + my $col = $self->_dm_trim($cols[$i]); + next if $col eq ''; + next unless $col =~ /:/; + push @traits, $col; + } + + my %seen_acc; + my @accessions; + + while (my $line = <$in>) { + chomp($line); + $line =~ s/\r$//; + next if $line =~ /^\s*$/; + + my @fields = split(/\t/, $line, -1); + next unless defined $fields[$germplasm_idx]; + + my $acc = $self->_dm_trim($fields[$germplasm_idx]); + next if $acc eq ''; + next if $seen_acc{$acc}++; + + push @accessions, $acc; + } + close($in); + + @traits = sort @traits; + @accessions = sort @accessions; + + return $self->status_ok($c, entity => { + traits => \@traits, + accessions => \@accessions, + tempfile => $res->{tempfile}, + }); +} + +sub dataset_plot_data : Path('dataset_plot_data') : Args(0) : ActionClass('REST') { } +sub dataset_plot_data_GET { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $dataset_id = $c->req->param('dataset_id'); + my $trait = $c->req->param('trait'); + + return $self->status_bad_request($c, message => 'Missing dataset_id') + unless $dataset_id; + + return $self->status_bad_request($c, message => 'Missing trait') + unless defined $trait && $trait ne ''; + + $trait = $self->_dm_trim($trait); + + my $res = $self->_dm_build_dataset_phenofile($c, $dataset_id); + if ($res->{error}) { + return $self->status_ok($c, entity => { + trait => $trait, + rows => [], + accession_summary => [], + error => $res->{error}, + }); + } + + my $phenofile = $res->{phenofile}; + + open(my $in, '<', $phenofile) or die "Cannot open phenotype file '$phenofile': $!"; + + my $header = <$in>; + unless (defined $header) { + close($in); + return $self->status_ok($c, entity => { + trait => $trait, + rows => [], + accession_summary => [], + error => "Phenotype file is empty.", + }); + } + + chomp($header); + $header =~ s/\r$//; + my @cols = split(/\t/, $header, -1); + + my $germplasm_idx = -1; + my $trait_idx = -1; + + for my $i (0 .. $#cols) { + my $col = $self->_dm_trim($cols[$i]); + + if ($col eq 'germplasmName') { + $germplasm_idx = $i; + } + if ($col eq $trait) { + $trait_idx = $i; + } + } + + if ($germplasm_idx < 0) { + close($in); + return $self->status_ok($c, entity => { + trait => $trait, + rows => [], + accession_summary => [], + error => "germplasmName column not found in phenotype file.", + }); + } + + if ($trait_idx < 0) { + close($in); + return $self->status_ok($c, entity => { + trait => $trait, + rows => [], + accession_summary => [], + error => "Trait column not found in phenotype file.", + }); + } + + my @rows; + my %grouped; + + while (my $line = <$in>) { + chomp($line); + $line =~ s/\r$//; + next if $line =~ /^\s*$/; + + my @fields = split(/\t/, $line, -1); + + next unless defined $fields[$germplasm_idx]; + next unless defined $fields[$trait_idx]; + + my $acc = $self->_dm_trim($fields[$germplasm_idx]); + my $val = $self->_dm_trim($fields[$trait_idx]); + + next if $acc eq ''; + next unless $self->_dm_is_numeric($val); + + my $num = $val + 0; + + push @rows, { + accession => $acc, + value => $num, + }; + + push @{ $grouped{$acc} }, $num; + } + close($in); + + my @summary; + foreach my $acc (sort keys %grouped) { + my @vals = @{ $grouped{$acc} || [] }; + next unless @vals; + + my $n = scalar(@vals); + + my $sum = 0; + $sum += $_ for @vals; + my $mean = $sum / $n; + + my ($min, $max) = ($vals[0], $vals[0]); + for my $v (@vals) { + $min = $v if $v < $min; + $max = $v if $v > $max; + } + + my $sq = 0; + $sq += ($_ - $mean) ** 2 for @vals; + my $std = $n > 1 ? sqrt($sq / ($n - 1)) : 0; + + push @summary, { + accession => $acc, + n => $n, + mean => sprintf("%.6f", $mean), + std => sprintf("%.6f", $std), + min => sprintf("%.6f", $min), + max => sprintf("%.6f", $max), + }; + } + + return $self->status_ok($c, entity => { + trait => $trait, + rows => \@rows, + accession_summary => \@summary, + tempfile => $res->{tempfile}, + }); +} + +sub save_all_decisions : Path('save_all_decisions') : Args(0) : ActionClass('REST') { } +sub save_all_decisions_POST { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $payload = $c->req->data; + + unless ($payload && ref($payload) eq 'HASH') { + return $self->status_bad_request($c, message => 'Missing or invalid JSON payload'); + } + + my $meeting_id = $payload->{meeting_id}; + unless ($meeting_id) { + return $self->status_bad_request($c, message => 'Missing meeting_id in payload'); + } + + my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; + my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); + my @user_roles = $c->user()->roles; + + my $raw_decision_role = $c->config->{decision_role}; + my $decision_role_conf = ''; + + if (ref($raw_decision_role) eq 'ARRAY') { + $decision_role_conf = defined($raw_decision_role->[0]) ? $raw_decision_role->[0] : ''; + } + else { + $decision_role_conf = $raw_decision_role // ''; + } + + my @allowed_roles = grep { $_ ne '' } + map { + my $x = $_ // ''; + $x =~ s/^\s+|\s+$//g; + $x; + } + split(/\s*,\s*/, $decision_role_conf); + + my %allowed = map { $_ => 1 } @allowed_roles; + my $can_save = 0; + + foreach my $role (@user_roles) { + if ($allowed{$role}) { + $can_save = 1; + last; + } + } + + unless ($can_save) { + return $self->status_forbidden( + $c, + message => 'You are not allowed to save accessions stage change' + ); + } + + $payload->{saved} = JSON::true; + $payload->{saved_at} = scalar localtime(); + $payload->{saved_status} = 'successfully'; + + my $json_text; + eval { + require JSON; + $json_text = JSON->new->allow_nonref->canonical->encode($payload); + }; + if ($@) { + return $self->status_bad_request($c, message => 'Could not encode payload to JSON'); + } + + my $dbh = $c->dbc->dbh; + + eval { + my $sth = $dbh->prepare(q{ + UPDATE projectprop + SET value = ? + WHERE project_id = ? + AND type_id = ( + SELECT cvterm_id + FROM cvterm + WHERE name = 'meeting_json' + ) + }); + $sth->execute($json_text, $meeting_id); + + my $raw_conf = $c->config->{saved_program_stage}; + my $saved_program_stage = ''; + + if (ref($raw_conf) eq 'ARRAY') { + $saved_program_stage = defined($raw_conf->[0]) ? $raw_conf->[0] : ''; + } + else { + $saved_program_stage = $raw_conf // ''; + } + + my $accessions = $payload->{accessions} || []; + + foreach my $acc (@$accessions) { + next unless $acc && ref($acc) eq 'HASH'; + + my $stock_id = $acc->{stock_id}; + my $breeding_program = $acc->{breeding_program} // ''; + my $new_stage = $acc->{new_stage} // ''; + + next unless $stock_id; + next unless $breeding_program ne ''; + next unless $new_stage ne ''; + + my $stage_prop_name = ''; + + foreach my $pair (split(/\s*,\s*/, $saved_program_stage)) { + next unless $pair; + + my ($program_name, $prop_name) = split(/\s*\|\s*/, $pair, 2); + + $program_name = '' unless defined $program_name; + $prop_name = '' unless defined $prop_name; + + $program_name =~ s/^\s+|\s+$//g; + $prop_name =~ s/^\s+|\s+$//g; + + if ($program_name eq $breeding_program) { + $stage_prop_name = $prop_name; + last; + } + } + + next unless $stage_prop_name; + + my $cvterm_row = SGN::Model::Cvterm->get_cvterm_row( + $schema, + $stage_prop_name, + 'stock_property' + ); + + unless ($cvterm_row) { + die "Could not find stock_property cvterm [$stage_prop_name]"; + } + + my $type_id = $cvterm_row->cvterm_id; + + my $stockprop = $schema->resultset('Stock::Stockprop')->search( + { + stock_id => $stock_id, + type_id => $type_id, + }, + { + order_by => { -desc => 'stockprop_id' }, + rows => 1, + } + )->single; + + if ($stockprop) { + $stockprop->value($new_stage); + $stockprop->update(); + } + else { + $schema->resultset('Stock::Stockprop')->create({ + stock_id => $stock_id, + type_id => $type_id, + value => $new_stage, + rank => 0, + }); + } + } + }; + if ($@) { + return $self->status_bad_request($c, message => "Failed to save decisions: $@"); + } + + $c->stash( + current_view => 'JSON', + json_data => { + success => JSON::true, + meeting_id => $meeting_id, + saved_status => 'successfully', + message => 'Decisions saved successfully' + } + ); +} + +sub decision_upload_template : Path('decision_upload_template') : Args(0) { + my ($self, $c) = @_; + + unless ($c->user) { + $c->res->status(403); + $c->res->content_type('application/json'); + $c->res->body(encode_json({ error => 'Login required' })); + return; + } + + my $list_id = $c->req->param('list_id'); + my $meeting_id = $c->req->param('meeting_id'); + + unless ($list_id) { + $c->res->status(400); + $c->res->content_type('application/json'); + $c->res->body(encode_json({ error => 'Missing list_id' })); + return; + } + + my $entity = $self->_decision_rows_entity( + $c, + list_id => $list_id, + meeting_id => $meeting_id, + ); + + my @headers = $self->_decision_upload_headers(); + my @rows = @{$entity->{rows} || []}; + + $c->tempfiles_subdir("decisionmeeting"); + my $temp_dir = $c->config->{basepath} . "/$c->{tempfiles_subdir}"; + if (!-d $temp_dir) { + make_path($temp_dir) or die "Could not create temp directory $temp_dir: $!"; + } + my $tempfile = $c->config->{basepath} . "/" . $c->tempfile(TEMPLATE => 'decisionmeeting/dm_template_XXXXXX'); + my $wb = Excel::Writer::XLSX->new($tempfile); + die "Could not create Excel template" unless $wb; + + my $ws = $wb->add_worksheet('Decisions'); + for my $col (0 .. $#headers) { + $ws->write(0, $col, $headers[$col]); + } + + my $line = 1; + foreach my $row (@rows) { + $ws->write_row($line, 0, [ + $row->{accession} || '', + $row->{breeding_program} || '', + $row->{stage} || '', + $row->{decision} || '', + $row->{new_stage} || '', + $row->{notes} || '', + $row->{save_comment} || '', + ]); + $line++; + } + $wb->close(); + + open(my $fh, '<', $tempfile) or die "Could not open template file: $!"; + binmode $fh; + local $/; + my $output = <$fh>; + close($fh); + unlink $tempfile; + + my $filename = 'decision_meeting_upload_template.xlsx'; + $c->res->status(200); + $c->res->content_type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $c->res->header('Content-Disposition', qq[attachment; filename="$filename"]); + $c->res->body($output); +} + +sub upload_decision_template : Path('upload_decision_template') : Args(0) : ActionClass('REST') { } +sub upload_decision_template_POST { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $list_id = $c->req->param('list_id'); + my $meeting_id = $c->req->param('meeting_id'); + + return $self->status_bad_request($c, message => 'Missing list_id') + unless $list_id; + return $self->status_bad_request($c, message => 'Missing meeting_id') + unless $meeting_id; + + my $upload = $c->req->upload('decision_upload_file'); + return $self->status_bad_request($c, message => 'Missing uploaded Excel file') + unless $upload; + + my ($parsed_rows, $parse_errors) = $self->_parse_decision_upload_file( + filename => $upload->tempname, + original_filename => $upload->filename, + ); + + if ($parse_errors && @$parse_errors) { + return $self->status_bad_request($c, message => join(' ', @$parse_errors)); + } + + my $entity = $self->_decision_rows_entity( + $c, + list_id => $list_id, + meeting_id => $meeting_id, + ); + + my @current_rows = @{$entity->{rows} || []}; + my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; + my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); + my $meeting_year = $self->_meeting_year_from_meeting_id($c, $meeting_id); + + unless ($meeting_year) { + return $self->status_bad_request($c, message => 'The selected meeting does not have a valid date/year, so uploaded stages cannot be validated.'); + } + + my ($validation_errors, $unmatched_rows) = $self->_validate_uploaded_decision_rows( + c => $c, + schema => $schema, + meeting_year => $meeting_year, + current_rows => \@current_rows, + parsed_rows => $parsed_rows, + ); + + if ($validation_errors && @$validation_errors) { + return $self->status_bad_request($c, message => join(' ', @$validation_errors)); + } + + my %current_lookup; + foreach my $row (@current_rows) { + my $key = join("\t", lc($row->{accession} || ''), lc($row->{breeding_program} || '')); + $current_lookup{$key} = $row; + } + + my $updated_count = 0; + foreach my $uploaded (@{$parsed_rows || []}) { + my $key = join("\t", lc($uploaded->{accession} || ''), lc($uploaded->{breeding_program} || '')); + my $target = $current_lookup{$key}; + next unless $target; + + $target->{decision} = $uploaded->{decision} || ''; + $target->{new_stage} = $uploaded->{new_stage} || ''; + $target->{notes} = $uploaded->{notes} || ''; + $target->{save_comment} = $uploaded->{save_comment} || ''; + $updated_count++; + } + + return $self->status_ok($c, entity => { + success => JSON::true, + rows => \@current_rows, + updated_count => $updated_count, + unmatched_rows => $unmatched_rows, + message => 'Decision upload processed successfully', + }); +} + +sub meetings : Path('meetings') : Args(0) : ActionClass('REST') {} +sub meetings_GET { + my ($self, $c) = @_; + my $dbh = $c->dbc->dbh; + + my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; + my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); + + my $design_type_row = SGN::Model::Cvterm->get_cvterm_row($schema, 'design', 'project_property'); + my $mtg_json_type_row = SGN::Model::Cvterm->get_cvterm_row($schema, 'meeting_json', 'project_property'); + + my $design_type_id = $design_type_row ? $design_type_row->cvterm_id : undef; + my $mtg_json_type_id = $mtg_json_type_row ? $mtg_json_type_row->cvterm_id : undef; + + if (!$design_type_id || !$mtg_json_type_id) { + $c->stash->{rest} = { rows => [] }; + $c->detach($c->view('JSON')); + return; + } + + my $ps = CXGN::BreedersToolbox::Projects->new({ schema => $schema }); + my $programs = $ps->get_breeding_programs() || []; + + my %program_id_to_name; + foreach my $p (@$programs) { + my ($pid, $pname) = ('', ''); + + if (ref($p) eq 'ARRAY') { + $pid = defined $p->[0] ? $p->[0] : ''; + $pname = defined $p->[1] ? $p->[1] : ''; + } + elsif (ref($p) eq 'HASH') { + $pid = $p->{program_id} // $p->{project_id} // $p->{id} // ''; + $pname = $p->{name} // $p->{project_name} // ''; + } + + next unless defined $pid && $pid ne ''; + next unless defined $pname && $pname ne ''; + + $program_id_to_name{$pid} = $pname; + } + + my $sth_a = $dbh->prepare(qq{ + SELECT p.project_id, p.name AS project_name + FROM project p + JOIN projectprop pp + ON pp.project_id = p.project_id + AND pp.type_id = ? + AND pp.value = 'Meeting' + }); + $sth_a->execute($design_type_id); + + my @projects; + my @ids; + while (my $r = $sth_a->fetchrow_hashref) { + push @projects, $r; + push @ids, $r->{project_id}; + } + + if (!@ids) { + $c->stash->{rest} = { rows => [] }; + $c->detach($c->view('JSON')); + return; + } + + my $ph = join(',', ('?') x @ids); + my $sql_b = qq{ + SELECT projectprop_id, project_id, value::text AS meeting_json + FROM projectprop + WHERE type_id = ? + AND project_id IN ($ph) + ORDER BY projectprop_id DESC + }; + my $sth_b = $dbh->prepare($sql_b); + $sth_b->execute($mtg_json_type_id, @ids); + + my %json_for; + while (my $r = $sth_b->fetchrow_hashref) { + next if exists $json_for{ $r->{project_id} }; + $json_for{ $r->{project_id} } = $r->{meeting_json}; + } + + my @rows; + for my $p (@projects) { + my $pid = $p->{project_id}; + my $mj = $json_for{$pid}; + next unless defined $mj; + + my $decoded = {}; + eval { $decoded = decode_json($mj) if $mj; }; + $decoded ||= {}; + + my $is_saved = 0; + if ( + exists $decoded->{saved_status} + && defined $decoded->{saved_status} + && $decoded->{saved_status} eq 'successfully' + ) { + $is_saved = 1; + } + + if (exists $decoded->{breeding_programs} && ref($decoded->{breeding_programs}) eq 'ARRAY') { + my @translated = map { + defined $_ && exists $program_id_to_name{$_} + ? $program_id_to_name{$_} + : $_ + } @{ $decoded->{breeding_programs} || [] }; + + if (@translated > 1) { + @translated = ($translated[0]); + } + + $decoded->{breeding_programs} = \@translated; + $decoded->{breeding_program} = $translated[0] // ''; + } + elsif (exists $decoded->{breeding_program}) { + my $raw = $decoded->{breeding_program}; + my @vals = ref($raw) eq 'ARRAY' + ? @$raw + : grep { defined $_ && $_ ne '' } map { s/^\s+|\s+$//gr } split(/\s*,\s*/, ($raw // '')); + + my @translated = map { + defined $_ && exists $program_id_to_name{$_} + ? $program_id_to_name{$_} + : $_ + } @vals; + + if (@translated > 1) { + @translated = ($translated[0]); + } + + $decoded->{breeding_programs} = \@translated; + $decoded->{breeding_program} = $translated[0] // ''; + } + + $decoded->{saved} = $is_saved ? JSON::true : JSON::false; + + push @rows, { + project_id => $pid, + project_name => $p->{project_name}, + meeting_json => encode_json($decoded), + meeting_saved => $is_saved ? JSON::true : JSON::false, + }; + } + + $c->stash->{rest} = { rows => \@rows }; + $c->detach($c->view('JSON')); +} + +sub meeting_report_html : Path('/ajax/decisionmeeting/meeting_report_html') Args(0) { + my ($self, $c) = @_; + + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $meeting_id = $c->req->param('meeting_id'); + return $self->status_bad_request($c, message => 'Missing meeting_id') + unless $meeting_id; + + my $dbh = $c->dbc->dbh; + + my $sth = $dbh->prepare(q{ + SELECT p.project_id, p.name, pp.value + FROM project p + LEFT JOIN projectprop pp + ON pp.project_id = p.project_id + AND pp.type_id = ( + SELECT cvterm_id + FROM cvterm + WHERE name = 'meeting_json' + LIMIT 1 + ) + WHERE p.project_id = ? + LIMIT 1 + }); + $sth->execute($meeting_id); + + my ($project_id, $project_name, $json_value) = $sth->fetchrow_array; + + unless ($project_id) { + $c->res->status(404); + $c->res->content_type('text/plain; charset=utf-8'); + $c->res->body("Meeting not found"); + return; + } + + my $data = {}; + if ($json_value) { + eval { $data = decode_json($json_value); }; + if ($@) { + $data = {}; + } + } + + my $meeting_notes = $data->{meeting_notes} // ''; + my $accessions = $data->{accessions} || []; + my $attendees = $data->{attendees}; + + my $saved_status = lc($data->{saved_status} // ''); + + if (!$saved_status || $saved_status ne 'successfully') { + $c->res->status(409); + $c->res->content_type('application/json; charset=utf-8'); + $c->res->body(encode_json({ + error => 'Report not available yet', + message => 'This meeting report cannot be downloaded because decisions have not been saved yet.' + })); + return; + } + + my $rows_html = ''; + foreach my $acc (@$accessions) { + my $name = $acc->{accession} // ''; + my $bp = $acc->{breeding_program} // ''; + my $previous = $acc->{previous_stage} // ''; + my $decision = $acc->{decision} // ''; + my $new_stage = $acc->{new_stage} // ''; + my $notes = $acc->{notes} // ''; + my $comment = $acc->{save_comment} // ''; + + for ($name, $bp, $previous, $decision, $new_stage, $notes, $comment) { + $_ = '' unless defined $_; + s/&/&/g; + s//>/g; + s/"/"/g; + } + + $rows_html .= qq{ + + $name + $bp + $previous + $decision + $new_stage + $notes + $comment + + }; + } + + my $safe_project_name = $project_name // ''; + $safe_project_name =~ s/&/&/g; + $safe_project_name =~ s//>/g; + $safe_project_name =~ s/"/"/g; + + $meeting_notes =~ s/&/&/g; + $meeting_notes =~ s//>/g; + $meeting_notes =~ s/"/"/g; + $meeting_notes =~ s/\n/
/g; + + my $attendees_html = ''; + if (ref($attendees) eq 'ARRAY') { + my @safe_attendees = map { + my $x = defined $_ ? $_ : ''; + $x =~ s/&/&/g; + $x =~ s//>/g; + $x =~ s/"/"/g; + $x; + } @$attendees; + $attendees_html = join(', ', @safe_attendees); + } + else { + $attendees_html = defined $attendees ? $attendees : ''; + $attendees_html =~ s/&/&/g; + $attendees_html =~ s//>/g; + $attendees_html =~ s/"/"/g; + } + + my $html = qq{ + + + + + Meeting Report - $safe_project_name + + + +
+ +
+ +

Meeting Report

+
+ Meeting: $safe_project_name
+ Meeting ID: $meeting_id
+ Attendees: $attendees_html +
+ +

Meeting Notes

+
$meeting_notes
+ +

Decisions

+ + + + + + + + + + + + + + $rows_html + +
AccessionBreeding ProgramPrevious StageDecisionNew StageCurrent NotesComment Before Save
+ + + + + }; + + $c->res->content_type('text/html; charset=utf-8'); + $c->res->body($html); +} + +sub create : Path('create') Args(0) { + my ($self, $c) = @_; + return $self->status_forbidden($c, message => 'Login required') + unless $c->user; + + my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; + my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); + my @user_roles = $c->user()->roles; + + my $raw_decision_role = $c->config->{decision_role}; + my $decision_role_conf = ''; + + if (ref($raw_decision_role) eq 'ARRAY') { + $decision_role_conf = defined($raw_decision_role->[0]) ? $raw_decision_role->[0] : ''; + } + else { + $decision_role_conf = $raw_decision_role // ''; + } + + my @allowed_roles = grep { $_ ne '' } + map { + my $x = $_ // ''; + $x =~ s/^\s+|\s+$//g; + $x; + } + split(/\s*,\s*/, $decision_role_conf); + + my %allowed = map { $_ => 1 } @allowed_roles; + my $can_create_meeting = 0; + + foreach my $role (@user_roles) { + if ($allowed{$role}) { + $can_create_meeting = 1; + last; + } + } + + unless ($can_create_meeting) { + return $self->status_forbidden( + $c, + message => 'You are not allowed to create meetings' + ); + } + + my $p = $c->req->params; + + my $dbh = $c->dbc->dbh(); + my $person = $c->user->get_object; + my $owner_id = $person->get_sp_person_id; + my $operator = $person->get_username; + + my $trial_name_in = $p->{meeting_name} // ''; + my $program_in = $p->{breeding_program} // ''; + my $location_in = $p->{location} // ''; + my $trial_year = $p->{year} // ''; + my $planting_date = $p->{date} // undef; + my $description = $p->{data} // ''; + my $meeting_status= $p->{meeting_status} // ''; + + return $self->status_bad_request($c, message => "Missing meeting_name") + unless $trial_name_in; + return $self->status_bad_request($c, message => "Missing breeding_program") + unless ($program_in || ref($p->{breeding_programs}) eq 'ARRAY'); + return $self->status_bad_request($c, message => "Missing location") + unless $location_in; + + my @program_in_list = + ref($p->{breeding_programs}) eq 'ARRAY' + ? grep { defined($_) && $_ ne '' } @{$p->{breeding_programs}} + : (grep { length($_) } map { s/^\s+|\s+$//gr } split(/\s*,\s*/, ($program_in // ''))); + + my $program_choice = $program_in_list[0] // ''; + return $self->status_bad_request($c, message => "Missing breeding_program") + unless $program_choice; + + my $program_name = _resolve_program_name($schema, $program_choice) + or return $self->status_bad_request($c, message => "Breeding program not found: '$program_choice'"); + my $location_name = _resolve_location_name($schema, $location_in) + or return $self->status_bad_request($c, message => "Location not found: '$location_in'"); + + my @program_name_list = grep { defined($_) && $_ ne '' } + map { scalar _resolve_program_name($schema, $_) } @program_in_list; + + my $trial_name = $trial_name_in; + + my $design_cv = 'experiment_meeting'; + my $design_term = 'meeting_project'; + + my $design_cvterm_row = SGN::Model::Cvterm->get_cvterm_row($schema, $design_term, $design_cv); + die "Cvterm not found: term='$design_term' cv='$design_cv'\n" + unless $design_cvterm_row; + + my $design_cvterm_id = $design_cvterm_row->cvterm_id; + + my @att_raw; + + if (ref($p->{attendees_list}) eq 'ARRAY') { + push @att_raw, @{$p->{attendees_list}}; + } + + if (ref($p->{attendees}) eq 'ARRAY') { + for my $chunk (@{$p->{attendees}}) { + next unless defined $chunk; + push @att_raw, split(/\n|,/, $chunk); + } + } + elsif (defined $p->{attendees} && $p->{attendees} ne '') { + push @att_raw, split(/\n|,/, $p->{attendees}); + } + + if (!@att_raw) { + my $bd = eval { $c->req->body_data } || {}; + if (ref($bd->{attendees}) eq 'ARRAY') { + push @att_raw, @{$bd->{attendees}}; + } + elsif (defined $bd->{attendees} && $bd->{attendees} ne '') { + push @att_raw, split(/\n|,/, $bd->{attendees}); + } + } + + my @att_clean = grep { length($_) } map { s/^\s+|\s+$//gr } @att_raw; + my %seen; + my $attendees = [ grep { my $k = lc($_); !$seen{$k}++ } @att_clean ]; + + my $design_hash = {}; + + my $tc = CXGN::Trial::TrialCreate->new({ + chado_schema => $schema, + dbh => $dbh, + owner_id => $owner_id, + operator => $operator, + design_type => 'Meeting', + design => $design_hash, + program => $program_name, + trial_year => $trial_year, + planting_date => $planting_date, + trial_location => $location_name, + trial_name => $trial_name, + trial_description => $description, + project_type => 'meeting_project', + }); + + my ($project_id, $nd_experiment_id); + my $err; + try { + $tc->save_trial(); + + $project_id = eval { $tc->get_trial_id } || eval { $tc->get_project_id } || undef; + $nd_experiment_id = eval { $tc->get_nd_experiment_id } || undef; + + my $proj_row = $project_id + ? $schema->resultset('Project::Project')->find({ project_id => $project_id }) + : $schema->resultset('Project::Project')->find({ name => $trial_name }); + + die "Project not found after save_trial" unless $proj_row; + + $project_id = $proj_row->project_id; + + my $meeting_payload = { + attendees => $attendees, + meeting_status => ($meeting_status || undef), + breeding_programs => \@program_in_list, + breeding_program_names => \@program_name_list, + breeding_program_choice => $program_choice, + breeding_program_name => $program_name, + year => ($trial_year || undef), + date => ($planting_date || undef), + location => $location_name, + location_raw => $location_in, + }; + my $val = encode_json($meeting_payload); + + my $pp_type = SGN::Model::Cvterm->get_cvterm_row($schema, 'meeting_json', 'project_property') + or die "cvterm meeting_json not found in cv project_property"; + my $type_id = $pp_type->cvterm_id; + + my $existing = $proj_row->search_related('projectprops', { type_id => $type_id })->first; + if ($existing) { + $existing->update({ value => $val }); + } + else { + $proj_row->create_related('projectprops', { type_id => $type_id, value => $val }); + } + + } catch { + $err = "$_"; + $c->log->error("DecisionMeeting save trial error: $err"); + }; + + if ($err) { + $c->res->content_type('application/json'); + $c->res->body(encode_json({ + ok => \0, + msg => "Error creating meeting trial: $err", + echo => { + meeting_name => $trial_name_in, + breeding_programs => \@program_in_list, + breeding_program => $program_choice, + location => $location_in, + year => $trial_year, + date => $planting_date, + attendees => $attendees, + }, + })); + return; + } + + $c->res->content_type('application/json'); + $c->res->body(encode_json({ + ok => \1, + msg => "Meeting saved as trial '$trial_name' (type=meeting_project).", + project_id => $project_id, + nd_experiment_id => $nd_experiment_id, + design_cvterm_id => $design_cvterm_id, + echo => { + meeting_name => $trial_name_in, + breeding_programs => \@program_in_list, + breeding_program => $program_name, + location => $location_name, + year => $trial_year, + date => $planting_date, + attendees => $attendees, + }, + })); +} + +sub _trim { + my ($self, $v) = @_; + return '' unless defined $v; + $v =~ s/^\s+//; + $v =~ s/\s+$//; + return $v; +} + +sub _config_list { + my ($self, $raw) = @_; + return () unless defined $raw && $raw ne ''; + return grep { $_ ne '' } map { $self->_trim($_) } split /,/, $raw; +} + +sub _extract_stage_token { + my ($self, $stage_value) = @_; + return '' unless defined $stage_value && $stage_value ne ''; + my @parts = grep { defined $_ && $_ ne '' } split /-/, $stage_value; + return '' unless @parts; + return $parts[-1]; +} + +sub _extract_yy { + my ($self, $year_value) = @_; + return '' unless defined $year_value && $year_value ne ''; + + my $v = $self->_trim($year_value); + + if ($v =~ /^\d{2}$/) { + return $v; + } + if ($v =~ /^\d{4}$/) { + return substr($v, -2); + } + if ($v =~ /(19\d{2}|20\d{2})/) { + return substr($1, -2); + } + if ($v =~ /(?:^|\D)(\d{2})(?:\D|$)/) { + return $1; + } + + return ''; +} + +sub _compute_new_stage { + my ($self, %args) = @_; + + my $current_stage_value = $args{current_stage_value} // ''; + my $decision_value = lc($args{decision_value} // ''); + my $breeding_stages = $args{breeding_stages}; + + return '' unless $decision_value; + + my $current_token = $self->_extract_stage_token($current_stage_value); + return '' unless $current_token; + + my @ordered_stages = $self->_config_list($breeding_stages); + return '' unless @ordered_stages; + + my %pos; + @pos{@ordered_stages} = (0 .. $#ordered_stages); + + my $next_token = $current_token; + + if ($decision_value eq 'drop') { + $next_token = $current_token; + } + elsif ($decision_value eq 'hold') { + $next_token = $current_token; + } + elsif ($decision_value eq 'advance') { + if (exists $pos{$current_token} && $pos{$current_token} < $#ordered_stages) { + $next_token = $ordered_stages[ $pos{$current_token} + 1 ]; + } + } + elsif ($decision_value eq 'jump') { + if (exists $pos{$current_token}) { + my $jump_to = $pos{$current_token} + 2; + $jump_to = $#ordered_stages if $jump_to > $#ordered_stages; + $next_token = $ordered_stages[$jump_to]; + } + } + else { + return ''; + } + + return $next_token; +} + +sub _resolve_program_name { + my ($schema, $in) = @_; + return $in unless defined $in && $in =~ /^\d+$/; + my $row = $schema->resultset('Project::Project')->find({ project_id => $in }); + return $row ? $row->name : undef; +} + +sub _resolve_location_name { + my ($schema, $in) = @_; + return $in unless defined $in && $in =~ /^\d+$/; + my $row = $schema->resultset('NaturalDiversity::NdGeolocation')->find({ nd_geolocation_id => $in }) + || $schema->resultset('NdGeolocation')->find({ nd_geolocation_id => $in }); + return unless $row; + return $row->can('description') ? ($row->description // '') : ($row->can('name') ? $row->name : ''); +} + +sub people : Path('people') : Args(0) : ActionClass('REST') { } +sub people_GET { + my ($self, $c) = @_; + + my $dbh = $c->dbc->dbh; + my $sth = $dbh->prepare(q{ + SELECT first_name, last_name, contact_email + FROM sgn_people.sp_person + ORDER BY last_name, first_name + }); + $sth->execute(); + + my @rows; + while (my ($first_name, $last_name, $contact_email) = $sth->fetchrow_array) { + push @rows, { + first_name => $first_name // '', + last_name => $last_name // '', + contact_email => $contact_email // '', + }; + } + $sth->finish; + + return $self->status_ok($c, entity => \@rows); +} + +1; diff --git a/lib/SGN/Controller/AJAX/Login.pm b/lib/SGN/Controller/AJAX/Login.pm index 0355b60bdf..1512bfabe0 100644 --- a/lib/SGN/Controller/AJAX/Login.pm +++ b/lib/SGN/Controller/AJAX/Login.pm @@ -5,6 +5,7 @@ use Moose; use CXGN::People::Login; use CXGN::People::Person; use CXGN::Login; +use CXGN::Cookie; BEGIN { extends 'Catalyst::Controller::REST' } @@ -92,7 +93,11 @@ sub log_out :Path('/user/logout') Args(0) { my $self = shift; my $c = shift; - # implement + $c->res->status(200); + $c->res->content_type('text/plain; charset=utf-8'); + CXGN::Cookie::set_cookie( $CXGN::Login::LOGIN_COOKIE_NAME, "" ); + $c->res->body('Logged out'); + $c->detach(); } diff --git a/lib/SGN/Controller/AJAX/TrialMetadata.pm b/lib/SGN/Controller/AJAX/TrialMetadata.pm index edc7798bc3..465a39506d 100644 --- a/lib/SGN/Controller/AJAX/TrialMetadata.pm +++ b/lib/SGN/Controller/AJAX/TrialMetadata.pm @@ -2905,6 +2905,12 @@ sub delete_field_coord : Path('/ajax/phenotype/delete_field_coords') Args(0) { my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; my $schema = $c->dbic_schema('Bio::Chado::Schema', undef, $sp_person_id); + $c->stash->{trial_id} = $trial_id; + $c->stash->{schema} = $schema; + $c->stash->{trial} = CXGN::Trial->new({ + bcs_schema => $schema, + trial_id => $trial_id, + }); if ($self->privileges_denied($c)) { $c->stash->{rest} = { error => "You have insufficient access privileges to update this map." }; diff --git a/lib/SGN/Controller/AJAX/Trials.pm b/lib/SGN/Controller/AJAX/Trials.pm index 4ace6dd2fb..07f1067424 100644 --- a/lib/SGN/Controller/AJAX/Trials.pm +++ b/lib/SGN/Controller/AJAX/Trials.pm @@ -42,7 +42,7 @@ sub get_trials : Path('/ajax/breeders/get_trials') Args(0) { sub get_trials_with_folders : Path('/ajax/breeders/get_trials_with_folders') Args(0) { my $self = shift; my $c = shift; - my $tree_type = $c->req->param('type') || 'trial'; #can be 'trial' or 'genotyping_trial', 'cross' + my $tree_type = $c->req->param('type') || 'trial'; my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); @@ -51,17 +51,53 @@ sub get_trials_with_folders : Path('/ajax/breeders/get_trials_with_folders') Arg if ($@) { print STDERR "Couldn't create $dir: $@"; } - my $filename = $dir."/entire_jstree_html_$tree_type.txt"; + my $filename = $dir . "/entire_jstree_html_$tree_type.txt"; _write_cached_folder_tree($schema, $tree_type, $filename); $c->stash->{rest} = { status => 1 }; } +sub _get_trial_designs_by_ids { + my ($schema, $trial_ids_ref) = @_; + + my %design_by_trial_id; + return \%design_by_trial_id unless $trial_ids_ref && @$trial_ids_ref; + + my $design_cvterm = SGN::Model::Cvterm->get_cvterm_row($schema, 'design', 'project_property'); + return \%design_by_trial_id unless $design_cvterm; + + my $design_cvterm_id = $design_cvterm->cvterm_id(); + + my $projects_rs = $schema->resultset('Project::Project')->search( + { 'me.project_id' => { -in => $trial_ids_ref } }, + { prefetch => 'projectprops' } + ); + + while (my $project = $projects_rs->next) { + my $project_id = $project->project_id; + my $design = ''; + + my @props = $project->projectprops; + foreach my $pp (@props) { + if ($pp->type_id == $design_cvterm_id) { + $design = $pp->value // ''; + last; + } + } + + $design_by_trial_id{$project_id} = $design; + } + + return \%design_by_trial_id; +} + sub get_trials_with_folders_cached : Path('/ajax/breeders/get_trials_with_folders_cached') Args(0) { my $self = shift; my $c = shift; - my $tree_type = $c->req->param('type') || 'trial'; #can be 'trial','genotyping_trial', 'cross', 'genotyping_project', 'activity' + my $tree_type = $c->req->param('type') || 'trial'; + my $force_refresh = $c->req->param('refresh') ? 1 : 0; + my $sp_person_id = $c->user() ? $c->user->get_object()->get_sp_person_id() : undef; my $schema = $c->dbic_schema("Bio::Chado::Schema", undef, $sp_person_id); @@ -70,20 +106,53 @@ sub get_trials_with_folders_cached : Path('/ajax/breeders/get_trials_with_folder if ($@) { print "Couldn't create $dir: $@"; } - my $filename = $dir."/entire_jstree_html_$tree_type.txt"; + + my $filename = $dir . "/entire_jstree_html_$tree_type.txt"; my $html = ''; - open(my $fh, '< :encoding(UTF-8)', $filename) or warn "cannot open file $filename $!"; - { + + if (!$force_refresh && open(my $fh, '<:encoding(UTF-8)', $filename)) { local $/; $html = <$fh>; + close($fh); + } + elsif (!$force_refresh) { + warn "cannot open file $filename $!"; } - close($fh); - if (!$html) { + if ($force_refresh || !$html) { $html = _write_cached_folder_tree($schema, $tree_type, $filename); } - #print STDERR $html; + if ($tree_type eq 'trial' && $html) { + my %trial_ids; + while ($html =~ m{/breeders/trial/(\d+)}g) { + $trial_ids{$1} = 1; + } + + if (%trial_ids) { + my @trial_ids = sort { $a <=> $b } keys %trial_ids; + my $design_by_trial_id = _get_trial_designs_by_ids($schema, \@trial_ids); + + $html =~ s{(]*data-jstree='{"type":"trial"}'[^>]*\bid="(\d+)"[^>]*)(>)}{ + my $start_tag = $1; + my $trial_id = $2; + my $end_tag = $3; + my $design = $design_by_trial_id->{$trial_id} // ''; + + $design =~ s/&/&/g; + $design =~ s/"/"/g; + $design =~ s//>/g; + + if ($start_tag =~ /\bdata-design=/) { + $start_tag . $end_tag; + } else { + $start_tag . qq{ data-design="$design"} . $end_tag; + } + }gex; + } + } + $c->stash->{rest} = { html => $html }; } @@ -91,24 +160,26 @@ sub _write_cached_folder_tree { my $schema = shift; my $tree_type = shift; my $filename = shift; - my $p = CXGN::BreedersToolbox::Projects->new( { schema => $schema } ); + my $p = CXGN::BreedersToolbox::Projects->new({ schema => $schema }); my $projects = $p->get_breeding_programs(); my $html = ""; - my $folder_obj = CXGN::Trial::Folder->new( { bcs_schema => $schema, folder_id => @$projects[0]->[0] }); + my $folder_obj = CXGN::Trial::Folder->new({ bcs_schema => $schema, folder_id => @$projects[0]->[0] }); print STDERR "Starting trial tree refresh for $tree_type at time ".localtime()."\n"; foreach my $project (@$projects) { - my %project = ( "id" => $project->[0], "name" => $project->[1]); + my %project = ("id" => $project->[0], "name" => $project->[1]); $html .= $folder_obj->get_jstree_html(\%project, $schema, 'breeding_program', $tree_type); } print STDERR "Finished trial tree refresh for $tree_type at time ".localtime()."\n"; - my $OUTFILE; - open $OUTFILE, '> :encoding(UTF-8)', $filename or die "Error opening $filename: $!"; - print { $OUTFILE } $html or croak "Cannot write to $filename: $!"; - close $OUTFILE or croak "Cannot close $filename: $!"; + my $tmpfile = $filename . ".tmp"; + open(my $OUTFILE, '>:encoding(UTF-8)', $tmpfile) or die "Error opening $tmpfile: $!"; + print {$OUTFILE} $html or croak "Cannot write to $tmpfile: $!"; + close($OUTFILE) or croak "Cannot close $tmpfile: $!"; + + rename($tmpfile, $filename) or die "Cannot rename $tmpfile to $filename: $!"; return $html; } diff --git a/lib/SGN/Controller/BreedersToolbox.pm b/lib/SGN/Controller/BreedersToolbox.pm index 53851e02d0..cc785eee04 100644 --- a/lib/SGN/Controller/BreedersToolbox.pm +++ b/lib/SGN/Controller/BreedersToolbox.pm @@ -196,6 +196,53 @@ sub manage_tissue_samples : Path("/breeders/samples") Args(0) { $c->stash->{template} = '/breeders_toolbox/manage_samples.mas'; } +sub index :Path('/breeders/meeting') :Args(0) { + my $self = shift; + my $c = shift; + if (! $c->user) { + $c->res->redirect(uri( path => '/user/login', query => { goto_url => $c->req->uri->path_query } ) ); + return; + } + + my $raw_decision_role = $c->config->{decision_role}; + my $decision_role_conf = ''; + + if (ref($raw_decision_role) eq 'ARRAY') { + $decision_role_conf = defined($raw_decision_role->[0]) ? $raw_decision_role->[0] : ''; + } + else { + $decision_role_conf = $raw_decision_role // ''; + } + + my @allowed_roles = grep { $_ ne '' } + map { + my $x = $_ // ''; + $x =~ s/^\s+|\s+$//g; + $x; + } + split(/\s*,\s*/, $decision_role_conf); + + my %allowed = map { $_ => 1 } @allowed_roles; + my $can_access = 0; + + foreach my $role ($c->user->roles) { + if ($allowed{$role}) { + $can_access = 1; + last; + } + } + + if (!$can_access) { + $c->res->status(403); + $c->res->content_type('text/plain; charset=utf-8'); + $c->res->body('You are not authorized to access the decision meeting page.'); + return; + } + + $c->stash->{template} = '/breeders_toolbox/decision_meeting.mas'; + +} + sub manage_locations : Path("/breeders/locations") Args(0) { my $self = shift; diff --git a/mason/breeders_toolbox/decision_meeting.mas b/mason/breeders_toolbox/decision_meeting.mas new file mode 100644 index 0000000000..65d533144c --- /dev/null +++ b/mason/breeders_toolbox/decision_meeting.mas @@ -0,0 +1,25 @@ +<%args> + + +<& /util/import_javascript.mas, + classes => ['CXGN.BreederSearch','CXGN.Trial','jquery.iframe-post-form' ], + entries => ['decision_meeting'] &> + +<& /page/page_title.mas, title=>"Decision Meeting" &> + +
+ +<%perl> +my $buttons_html = ""; + + +<& /breeders_toolbox/decision_meeting/add_meeting_dialog.mas &> + + +<& /page/detail_page_2_col_section.mas, info_section_collapsed => 0, info_section_title => "

Decision Meeting

", info_section_subtitle => 'Create meeting setup', buttons_html => $buttons_html, icon_class => "glyphicon glyphicon-list-alt", info_section_id => "decision_meeting_section" &> + +<& /page/detail_page_2_col_section.mas, info_section_collapsed => 0, info_section_title => "

Accession Tracker

", info_section_subtitle => 'Accessions, stages, and decisions',icon_class => "glyphicon glyphicon-signal", info_section_id => "accession_meeting_section" &> + +
+ + diff --git a/mason/breeders_toolbox/decision_meeting/accession_meeting_section.mas b/mason/breeders_toolbox/decision_meeting/accession_meeting_section.mas new file mode 100644 index 0000000000..84bf133e98 --- /dev/null +++ b/mason/breeders_toolbox/decision_meeting/accession_meeting_section.mas @@ -0,0 +1,73 @@ +<%doc>Accession Tracker + +<& /util/import_javascript.mas, classes => ['jquery', 'CXGN.BreedersToolbox.HTMLSelect'], entries => ['decision_meeting'] &> + +
+ <&| /page/info_section.mas, + title=>'Accession — Decision Tracker', + is_subsection=>1, collapsible=>1, collapsed=>0, + subtitle=>'Track decisions per accession. Use the per-column filters below the header.' + &> +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + +
AccessionBreeding ProgramStageYearDecisionNew StageFemale ParentMale ParentNotes
+
+ +
+
+ + + <&| /page/info_section.mas, + title=>'Data Summary and Graphics', + is_subsection=>1, collapsible=>1, collapsed=>0, + subtitle=>"" + &> +
+
+
+ + +
+
+
+ + + + + + + + + + + + +
AccessionTraitMinMaxAverageStd
+
+
+
+
+ + +
diff --git a/mason/breeders_toolbox/decision_meeting/add_meeting_dialog.mas b/mason/breeders_toolbox/decision_meeting/add_meeting_dialog.mas new file mode 100644 index 0000000000..38c4468462 --- /dev/null +++ b/mason/breeders_toolbox/decision_meeting/add_meeting_dialog.mas @@ -0,0 +1,99 @@ +<%args> +$high_dimensional_phenotype_type => undef #either NIRS, Transcriptomics, Metabolomics + + + + + + diff --git a/mason/breeders_toolbox/decision_meeting/decision_meeting_section.mas b/mason/breeders_toolbox/decision_meeting/decision_meeting_section.mas new file mode 100644 index 0000000000..cbd4c2f512 --- /dev/null +++ b/mason/breeders_toolbox/decision_meeting/decision_meeting_section.mas @@ -0,0 +1,31 @@ +<%doc>Decision Meeting + +
+ + <&| /page/info_section.mas, + title=>'Meeting Tracker', + is_subsection=>1, collapsible=>1, collapsed=>0, + subtitle=>'View Meetings Record' + &> +
+
+
+ + + + + + + + + + + + + +
SelectMeetingProgramsDateLocationAttendeesDownload
+
+
+
+ +
\ No newline at end of file diff --git a/mason/breeders_toolbox/trialtreefolders.mas b/mason/breeders_toolbox/trialtreefolders.mas index b51ec09343..cf46615677 100644 --- a/mason/breeders_toolbox/trialtreefolders.mas +++ b/mason/breeders_toolbox/trialtreefolders.mas @@ -10,20 +10,20 @@ $management_factor_types => () $design_types => () -<& /util/import_javascript.mas, classes => [ 'jstree.dist.jstree', 'bootstrap_min.js', 'jquery.iframe-post-form', 'CXGN.List', 'CXGN.BreedersToolbox.AddTrial','CXGN.BreedersToolbox.UploadTrial','CXGN.BreedersToolbox.Trial', 'CXGN.Trial','CXGN.BreedersToolbox.GenotypingTrial','CXGN.BreedersToolbox.Accessions', 'CXGN.BreedersToolbox.UploadPedigrees','CXGN.BreedersToolbox.Crosses','CXGN.BreedersToolbox.FieldBook','CXGN.BreedersToolbox.UploadPhenotype', 'CXGN.BreederSearch', 'CXGN.Trial','CXGN.TrialTreeFolders' ] &> +<& /util/import_javascript.mas, classes => [ 'jstree.dist.jstree', 'bootstrap_min.js', 'jquery.iframe-post-form', 'CXGN.List', 'CXGN.BreedersToolbox.AddTrial','CXGN.BreedersToolbox.UploadTrial','CXGN.BreedersToolbox.Trial', 'CXGN.Trial','CXGN.BreedersToolbox.GenotypingTrial','CXGN.BreedersToolbox.Accessions', 'CXGN.BreedersToolbox.UploadPedigrees','CXGN.BreedersToolbox.Crosses','CXGN.BreedersToolbox.FieldBook','CXGN.BreedersToolbox.UploadPhenotype', 'CXGN.BreederSearch', 'CXGN.Trial' ] &> <& /util/import_css.mas, paths => ['/static/documents/inc/jstree_theme/jstree-bstheme-min.css'] &> -<& /breeders_toolbox/folder/folder_set.mas, project_type => 'field_trial' &> -<& /breeders_toolbox/folder/folder_new.mas, project_type => 'field_trial' &> -<& /breeders_toolbox/folder/folder_move.mas, project_type => 'field_trial' &> +<& /breeders_toolbox/folder/folder_set.mas, project_type => 'field_trial', include_trialtreefolders_js => 0 &> +<& /breeders_toolbox/folder/folder_new.mas, project_type => 'field_trial', include_trialtreefolders_js => 0 &> +<& /breeders_toolbox/folder/folder_move.mas, project_type => 'field_trial', include_trialtreefolders_js => 0 &> <& /breeders_toolbox/folder/folders_edit.mas, folder_tree_type => 'Trial', folder_tree_identifier => 'trial_list', folder_tree_refresh_name => 'refresh_jstree_html' &> -<& /breeders_toolbox/trial/trial_create_dialogs.mas, locations => \@locations, breeding_programs => $breeding_programs, management_factor_types => $management_factor_types, design_types => $design_types &> +<& /breeders_toolbox/trial/trial_create_dialogs.mas, locations => \@locations, breeding_programs => $breeding_programs, management_factor_types => $management_factor_types, design_types => $design_types, include_trialtreefolders_js => 0 &> <& /breeders_toolbox/trial/download_trials_phenotypes_dialog.mas &> -<& /breeders_toolbox/trial/trial_upload_dialogs.mas, locations => \@locations, breeding_programs => $breeding_programs, design_types => $design_types &> +<& /breeders_toolbox/trial/trial_upload_dialogs.mas, locations => \@locations, breeding_programs => $breeding_programs, design_types => $design_types, include_trialtreefolders_js => 0 &> <& /breeders_toolbox/add_accessions_dialogs.mas, preferred_species=>$preferred_species, editable_stock_props=>$editable_stock_props, editable_stock_props_definitions=>$editable_stock_props_definitions &> <& /breeders_toolbox/upload_seedlots_dialogs.mas &> <& /breeders_toolbox/add_seedlot_dialogs.mas, timestamp=>$timestamp &> @@ -79,134 +79,167 @@ $design_types => () jQuery(document).ready(function($) { - jQuery.ajax( { - url: '/ajax/breeders/get_trials_with_folders_cached?type=trial', - success: function(response) { - var html = '
    '+response.html+'
'; - - jQuery('#trial_list').html(html); - //console.log(html); - jQuery('#trial_list').jstree( { - "core": { 'themes': { 'name': 'proton', 'responsive': true}}, - "valid_children" : [ "folder", "trial", "breeding_program", "analyses" ], - "types" : { - "breeding_program" : { - "icon": 'glyphicon glyphicon-briefcase text-info', - }, - "folder" : { - "icon": 'glyphicon glyphicon-folder-open text-danger', - }, - "trial" : { - "icon": 'glyphicon glyphicon-leaf text-success', - }, - "analyses" : { - "icon": 'glyphicon glyphicon-stats text-success', - }, - "sampling_trial" : { - "icon": 'glyphicon glyphicon-th text-success', - }, - }, - "search" : { - "case_insensitive" : true, - }, - "plugins" : ["html_data","types","search"], - - }); - - }, - error: function(response) { - alert("An error occurred while loading the trial data."); + var trialTreeSearchTimer = null; + + function filterMeetingTrials(rawHtml) { + var $container = $('
    ' + rawHtml + '
'); + + $container.find('li[data-design="Meeting"]').remove(); + + $container.find('li').each(function() { + var $li = $(this); + var dataJstree = $li.attr('data-jstree') || ''; + var $childUl = $li.children('ul'); + + if (!$childUl.length) { + return; + } + + var childCount = $childUl.children('li').length; + var isFolder = dataJstree.indexOf('"type":"folder"') > -1; + var isProgram = dataJstree.indexOf('"type":"breeding_program"') > -1; + + if (childCount === 0 && isProgram) { + $li.remove(); + } + }); + + return $container.children('ul').html(); + } + + function buildTrialTree(rawHtml) { + var filteredHtml = filterMeetingTrials(rawHtml); + var finalHtml = '
    ' + filteredHtml + '
'; + + $('#trial_tree_search').val(''); + + if ($.jstree.reference('#trial_list')) { + $('#trial_list').jstree('destroy'); } - }); - jQuery('#refresh_jstree_html_trialtree_button').click(function(){ - jQuery.ajax( { - url: '/ajax/breeders/get_trials_with_folders_cached?type=trial', - success: function(response) { - var html = '
    '+response.html+'
'; - - jQuery('#trial_list').html(html); - //console.log(html); - jQuery('#trial_list').jstree( { - "core": { 'themes': { 'name': 'proton', 'responsive': true}}, - "valid_children" : [ "folder", "trial", "breeding_program", "analyses", "sampling_trial" ], - "types" : { - "breeding_program" : { - "icon": 'glyphicon glyphicon-briefcase text-info', - }, - "folder" : { - "icon": 'glyphicon glyphicon-folder-open text-danger', - }, - "trial" : { - "icon": 'glyphicon glyphicon-leaf text-success', - }, - "analyses" : { - "icon": 'glyphicon glyphicon-stats text-success', - }, - "sampling_trial" : { - "icon": 'glyphicon glyphicon-th text-success', - } - }, - "search" : { - "case_insensitive" : true, - }, - "plugins" : ["html_data","types","search"], - - }); + $('#trial_list').empty().html(finalHtml); + + $('#trial_list').jstree({ + "core": { + "themes": { + "name": "proton", + "responsive": true + } + }, + "valid_children": [ "folder", "trial", "breeding_program", "analyses", "sampling_trial" ], + "types": { + "breeding_program": { + "icon": "glyphicon glyphicon-briefcase text-info" + }, + "folder": { + "icon": "glyphicon glyphicon-folder-open text-danger" + }, + "trial": { + "icon": "glyphicon glyphicon-leaf text-success" + }, + "analyses": { + "icon": "glyphicon glyphicon-stats text-success" + }, + "sampling_trial": { + "icon": "glyphicon glyphicon-th text-success" + } + }, + "search": { + "case_insensitive": true, + "show_only_matches": false + }, + "plugins": ["html_data", "types", "search"] + }); + } + function loadTrialTree(forceRefresh) { + var url = '/ajax/breeders/get_trials_with_folders_cached?type=trial'; + + if (forceRefresh) { + url += '&refresh=1&_=' + new Date().getTime(); + } + + $.ajax({ + url: url, + cache: false, + success: function(response) { + buildTrialTree(response.html || ''); }, - error: function(response) { + error: function(xhr) { + console.log('Trial tree load failed:', xhr.status, xhr.responseText); alert("An error occurred while loading the trial data."); } - }); - }); + }); + } + + loadTrialTree(false); - jQuery("#trial_tree_search").keyup(function() { - var v = jQuery("#trial_tree_search").val(); - jQuery("#trial_list").jstree(true).search(v); + $('#refresh_jstree_html_trialtree_button').off('click').on('click', function() { + loadTrialTree(true); }); + $('#trial_tree_search').off('keyup').on('keyup', function() { + var v = $(this).val(); + var tree = $.jstree.reference('#trial_list'); + + if (trialTreeSearchTimer) { + clearTimeout(trialTreeSearchTimer); + } + + trialTreeSearchTimer = setTimeout(function() { + if (tree && tree._model && tree._model.data) { + tree.search(v); + } + }, 150); + }); + $('#trial_list').on('changed.jstree', function (e, data) { + if (!data || !data.node) { + $('#trials_download_phenotypes_button').attr('disabled', 'disabled'); + $('#folder_edit_options').hide(); + return; + } - jQuery('#trial_list').on("changed.jstree", function (e, data) { - //console.log(data); - if ($('#trial_list').jstree('is_leaf', data.node) && data.node.data.jstree.type == 'trial') { - jQuery('#trials_download_phenotypes_button').removeAttr('disabled'); - jQuery("#folder_edit_options").hide(); - } - else if ($('#trial_list').jstree('is_leaf', data.node) && data.node.data.jstree.type == 'folder') { - jQuery('#trials_download_phenotypes_button').attr('disabled', 'disabled'); - jQuery("#folder_edit_options").show(); - } - else { - jQuery('#trials_download_phenotypes_button').attr('disabled', 'disabled'); - jQuery("#folder_edit_options").hide(); - } + if ($('#trial_list').jstree('is_leaf', data.node) && data.node.data && data.node.data.jstree && data.node.data.jstree.type == 'trial') { + $('#trials_download_phenotypes_button').removeAttr('disabled'); + $('#folder_edit_options').hide(); + } + else if ($('#trial_list').jstree('is_leaf', data.node) && data.node.data && data.node.data.jstree && data.node.data.jstree.type == 'folder') { + $('#trials_download_phenotypes_button').attr('disabled', 'disabled'); + $('#folder_edit_options').show(); + } + else { + $('#trials_download_phenotypes_button').attr('disabled', 'disabled'); + $('#folder_edit_options').hide(); + } }); + $('#trial_list').delegate('li', 'dblclick', function(event) { + var node = $('#trial_list').jstree('get_node', this); + if (!node) { + return; + } - $("#trial_list").delegate("li", "dblclick", function(event){ - var node = $("#trial_list").jstree("get_node", this); - //console.log(node); if (node.id.substr(0,1) !== 'j') { if (node.type == 'folder') { - window.open('/folder/'+node.id); - event.stopPropagation(); + window.open('/folder/' + node.id); + event.stopPropagation(); } else if (node.type == 'breeding_program') { - window.open('/breeders/program/'+node.id); - event.stopPropagation(); + window.open('/breeders/program/' + node.id); + event.stopPropagation(); } else if (node.type == 'analyses') { - window.open('/analyses/'+node.id); - event.stopPropagation(); + window.open('/analyses/' + node.id); + event.stopPropagation(); } else if (node.type == 'trial') { - window.open('/breeders_toolbox/trial/'+node.id); - event.stopPropagation(); + window.open('/breeders_toolbox/trial/' + node.id); + event.stopPropagation(); } else if (node.type == 'sampling_trial') { - window.open('/breeders_toolbox/trial/'+node.id); - event.stopPropagation(); + window.open('/breeders_toolbox/trial/' + node.id); + event.stopPropagation(); } } }); + }); diff --git a/mason/page/detail_page_2_col_section.mas b/mason/page/detail_page_2_col_section.mas index ca2e0b0153..11cffe9f6d 100644 --- a/mason/page/detail_page_2_col_section.mas +++ b/mason/page/detail_page_2_col_section.mas @@ -272,7 +272,6 @@ $logged_in_name => undef % if ($info_section_id eq 'trial_upload_files'){ <& /breeders_toolbox/trial/files_section.mas, trial_id => $trial_id, trial_phenotype_files => $trial_phenotype_files, trial_stock_type => $trial_stock_type, for_analysis_page => $for_analysis_page &> % } #End trial_upload_files - % if ($info_section_id eq 'trial_weather_data'){ <& /breeders_toolbox/trial/weather.mas, location => $location_name &> % } #End trial_weather_data @@ -572,6 +571,13 @@ $logged_in_name => undef <& /breeders_toolbox/cross/all_cross_entries.mas &> %} #End all_cross_entries_section +% if ($info_section_id eq 'decision_meeting_section'){ + <& /breeders_toolbox/decision_meeting/decision_meeting_section.mas &> +% } #End decision_meeting_section +% if ($info_section_id eq 'accession_meeting_section'){ + <& /breeders_toolbox/decision_meeting/accession_meeting_section.mas &> +% } #End accession_meeting_section + % if ($info_section_id eq 'manage_nirs_main'){ <& /tools/nirs/manage_nirs_main.mas, nirs_files => $nirs_files, deleted_nirs_files => $deleted_nirs_files, all_nirs_files => $all_nirs_files, all_deleted_nirs_files => $all_deleted_nirs_files, locations => $locations, sampling_facilities=>$sampling_facilities, sample_tissue_types=>$sample_tissue_types &> % } #End manage_nirs_main diff --git a/mason/site/toolbar/manage.mas b/mason/site/toolbar/manage.mas index d9145f4274..967643e464 100644 --- a/mason/site/toolbar/manage.mas +++ b/mason/site/toolbar/manage.mas @@ -5,6 +5,8 @@
  • Breeding Programs
  • Locations
  • Report Factory
  • +
  • Decision Meetings
  • +
  • Accessions
  • diff --git a/mason/solgs/tools/correlation/plot.mas b/mason/solgs/tools/correlation/plot.mas index 7a3c3c975b..538a6b178a 100644 --- a/mason/solgs/tools/correlation/plot.mas +++ b/mason/solgs/tools/correlation/plot.mas @@ -41,6 +41,4 @@ $data_structure => undef - - <& /util/import_javascript.mas, classes => ["solGS.correlation", "solGS.scatterPlot", "solGS.olsLine", "statistics.simple_statistics"] &> diff --git a/sgn.conf b/sgn.conf index 6141d7a52e..0156dd86af 100644 --- a/sgn.conf +++ b/sgn.conf @@ -41,6 +41,12 @@ trait_ontology_cvterm_name Solanaceae trait ontology # For displaying ontologies in Ontology Browser onto_root_namespaces GO (Gene Ontology), PO (Plant Ontology), SO (Sequence Ontology), PATO (Phenotype and Trait Ontology), SP (Solanaceae Ontology), UO (Units), CASSTISS (Cass tissues) +## Decision Meeting +decision_role curator +decision_format state,year yy,stage #(DROP-23-Y2) +breeding_stages T1,T2,Y0,Y1,Y2,Y3,Y4,Y5,commercial +saved_program_stage BTI|BTI_Stage,Cornell|Cornell_Stage + allow_trait_edits 1 allow_treatment_edits 0 # Marker Metadata Trait Categories @@ -62,6 +68,11 @@ seedlot_maintenance_info_cvterms ###### seedlot material type options: seed, root, clone, plant, tissue culture or gametophyte #default_seedlot_material_type seed + +## Breeding Stages +stageprop test_stage +breeding_stages TC1,TC2,BC1F1,BC2F1,F1,F2,F3,F4,Y1,Y2,Y3 + project_name SGN user_registration_join_breeding_programs 0 # when enabled, a new user can choose which breeding programs to join during registration diff --git a/t/selenium2/breeders/01_folder.t b/t/selenium2/breeders/01_folder.t index 3a08544892..0944c0df52 100644 --- a/t/selenium2/breeders/01_folder.t +++ b/t/selenium2/breeders/01_folder.t @@ -13,6 +13,22 @@ my $t = SGN::Test::WWW::WebDriver->new(); my $f = SGN::Test::Fixture->new(); my $schema = $f->bcs_schema; +sub _safe_end_driver_session { + my ($driver) = @_; + + eval { + local $SIG{ALRM} = sub { die "webdriver teardown timeout\n"; }; + alarm 15; + eval { $driver->get('about:blank'); }; + eval { $driver->quit(); }; + alarm 0; + }; + + if ($@ && $@ !~ /webdriver teardown timeout/) { + diag("webdriver teardown warning: $@"); + } +} + $t->while_logged_in_as("submitter", sub { sleep(2); @@ -170,6 +186,9 @@ $t->while_logged_in_as("submitter", sub { }); -$t->driver->close(); +_safe_end_driver_session($t->driver); $f->clean_up_db(); +# Audit stats are cumulative pg_stat counters, so this test's expected +# project/session audit writes must become the new baseline before DEMOLISH. +$f->auditstats_start($f->get_auditdb_stats()); done_testing(); diff --git a/t/selenium2/breeders/accessions.t b/t/selenium2/breeders/accessions.t index 63d5a43c71..8730e08436 100644 --- a/t/selenium2/breeders/accessions.t +++ b/t/selenium2/breeders/accessions.t @@ -176,6 +176,6 @@ $t->while_logged_in_as("submitter", sub { # is $fuzzy_checkbox->get_attribute('disabled'), 1, 'fuzzy logic checkbox is disabled for submitter'; }); -$t->driver->close(); $f->clean_up_db(); +eval { $t->driver->close(); }; done_testing(); diff --git a/t/selenium2/breeders/create_genotyping_trial.t b/t/selenium2/breeders/create_genotyping_trial.t index 933686ca32..d8369f375c 100644 --- a/t/selenium2/breeders/create_genotyping_trial.t +++ b/t/selenium2/breeders/create_genotyping_trial.t @@ -155,5 +155,6 @@ $t->while_logged_in_as("submitter", sub { } }); -$t->driver()->close(); +$f->clean_up_db(); +eval { $t->driver()->close(); }; done_testing(); diff --git a/t/selenium2/breeders/crosses.t b/t/selenium2/breeders/crosses.t index 8e74cd2799..91988781bc 100644 --- a/t/selenium2/breeders/crosses.t +++ b/t/selenium2/breeders/crosses.t @@ -133,5 +133,6 @@ $t->while_logged_in_as("submitter", sub { }); -$t->driver()->close(); +$f->clean_up_db(); +eval { $t->driver()->close(); }; done_testing(); diff --git a/t/selenium2/breeders/upload_crosses_file.t b/t/selenium2/breeders/upload_crosses_file.t index 10a9a13e92..00ec1721dd 100644 --- a/t/selenium2/breeders/upload_crosses_file.t +++ b/t/selenium2/breeders/upload_crosses_file.t @@ -118,5 +118,6 @@ $t->while_logged_in_as("submitter", sub { } }); -$t->driver()->close(); +$f->clean_up_db(); +eval { $t->driver()->close(); }; done_testing(); diff --git a/t/selenium2/breeders/upload_trial_coords.t b/t/selenium2/breeders/upload_trial_coords.t index 721c59a624..c813a593e2 100644 --- a/t/selenium2/breeders/upload_trial_coords.t +++ b/t/selenium2/breeders/upload_trial_coords.t @@ -57,6 +57,6 @@ $t->while_logged_in_as("submitter", sub { } ); -$t->driver()->close(); +eval { $t->driver()->close(); }; $f->clean_up_db(); done_testing();