diff --git a/core/tpl/frontend/reedcrm_opportunity_chain_matrix.tpl.php b/core/tpl/frontend/reedcrm_opportunity_chain_matrix.tpl.php new file mode 100644 index 00000000..75f6e7fa --- /dev/null +++ b/core/tpl/frontend/reedcrm_opportunity_chain_matrix.tpl.php @@ -0,0 +1,116 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file core/tpl/frontend/reedcrm_opportunity_chain_matrix.tpl.php + * \ingroup reedcrm + * \brief Reusable fragment: opportunity->payment chain as a matrix (pieces = rows, projects = columns). + * Expects $matrixProjects (array of project objects: id, ref, title) + * and $matrixDocs (= reedcrm_get_pwa_projects_documents($ids, true)). + * Uses globals $conf/$langs; chain logic in reedcrm_compute_opportunity_chain(). + */ +if (!defined('DOL_DOCUMENT_ROOT')) { + exit; +} + +$enabledPieces = []; +if (!empty($conf->global->REEDCRM_PWA_SHOW_OPP_AMOUNT)) $enabledPieces['montant'] = ['icon' => 'fas fa-wallet']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_PROPAL)) $enabledPieces['propal'] = ['icon' => 'fas fa-file-signature']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_COMMANDE)) $enabledPieces['commande'] = ['icon' => 'fas fa-shopping-cart']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_COMMANDE_FOURN)) $enabledPieces['commande_fourn'] = ['icon' => 'fas fa-shopping-bag']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_RECEPTION)) $enabledPieces['reception'] = ['icon' => 'fas fa-dolly']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_FACTURE_FOURN)) $enabledPieces['facture_fourn'] = ['icon' => 'fas fa-receipt']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_EXPEDITION)) $enabledPieces['expedition'] = ['icon' => 'fas fa-shipping-fast']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_FACTURE)) $enabledPieces['facture'] = ['icon' => 'fas fa-file-invoice-dollar']; +if (!empty($conf->global->REEDCRM_PWA_SHOW_PAYMENT)) $enabledPieces['payment'] = ['icon' => 'fas fa-coins']; + +$matrixProjects = (isset($matrixProjects) && is_array($matrixProjects)) ? $matrixProjects : []; +$matrixDocs = (isset($matrixDocs) && is_array($matrixDocs)) ? $matrixDocs : []; +$iconsOnly = !empty($conf->global->REEDCRM_PWA_PIECES_ICONS_ONLY); + +// Pre-compute the chain (state + issues) once per project. +$matrixChains = []; +foreach ($matrixProjects as $proj) { + $matrixChains[$proj->id] = reedcrm_compute_opportunity_chain($matrixDocs[$proj->id] ?? []); +} + +// Cancelled/refused statuses, per piece, to strike out the offending document. +$matrixBadStatuses = ['propal' => [3], 'commande' => [-1], 'facture' => [3]]; + +if (!empty($enabledPieces) && !empty($matrixProjects)) : ?> +
+ + + + + id; + $projTitle = trim((string) ($proj->title ?? '')); ?> + + + + + + $item) : + $label = $langs->trans('PwaPieceLabel_' . $key); ?> + + + id] ?? []; + $piece = $docs[$key] ?? null; + $all = (is_array($piece) && !empty($piece['all'])) ? $piece['all'] : []; + $state = $matrixChains[$proj->id][$key]['state'] ?? 'todo'; + $issues = $matrixChains[$proj->id][$key]['issues'] ?? []; + + $hasErr = false; $hasWarn = false; $issueMsgs = []; + foreach ($issues as $iss) { + if ($iss['level'] === 'err') { $hasErr = true; } else { $hasWarn = true; } + $issueMsgs[] = $iss['msg']; + } + $cls = 'pwa-matrix-cell is-' . $state . ($hasErr ? ' has-err' : ($hasWarn ? ' has-warn' : '')); + $tooltip = $issueMsgs ? implode(' ; ', $issueMsgs) : ''; ?> + + + + + +
trans('PwaMatrixStepHeader'); ?> + ref); ?> + + + +
+ + > + + + + trans($statusKey); + $statusLabel = (!empty($doc['status']) && $statusTrans !== $statusKey) ? $statusTrans : ''; + $amountTxt = ($doc['amount'] !== null) ? ' · ' . price($doc['amount'], 0, '', 11, -1, -1, 'auto') : ''; ?> + + + · + + + +
+
+ diff --git a/langs/en_US/reedcrm.lang b/langs/en_US/reedcrm.lang index b391831d..9363da6d 100644 --- a/langs/en_US/reedcrm.lang +++ b/langs/en_US/reedcrm.lang @@ -208,6 +208,10 @@ PwaPieceLabel_expedition = Cust Ship PwaPieceLabel_facture = Cust Inv PwaPieceLabel_payment = Payment PwaCurrentStep = CURRENT +ProjectsOverviewMatrix = Overview (matrix) +ViewAsMatrix = Matrix view +ViewAsList = List view +PwaMatrixStepHeader = Stage REEDCRM_PWA_PIECES_ICONS_ONLY = Icons-only pieces bar REEDCRM_PWA_PIECES_ICONS_ONLY_Desc = Show colored icons only (details on hover) PwaIssueAmountMismatch = Amount %s differs from the reference %s diff --git a/langs/fr_FR/reedcrm.lang b/langs/fr_FR/reedcrm.lang index aa765708..319cd0e0 100644 --- a/langs/fr_FR/reedcrm.lang +++ b/langs/fr_FR/reedcrm.lang @@ -461,6 +461,10 @@ PwaPieceLabel_expedition = Expédition PwaPieceLabel_facture = Facture PwaPieceLabel_payment = Règlement PwaCurrentStep = EN COURS +ProjectsOverviewMatrix = Vue d'ensemble (matrice) +ViewAsMatrix = Vue matrice +ViewAsList = Vue liste +PwaMatrixStepHeader = Étape REEDCRM_PWA_PIECES_ICONS_ONLY = Barre de pièces en pictogrammes seuls REEDCRM_PWA_PIECES_ICONS_ONLY_Desc = Afficher uniquement les icônes colorées (le détail apparaît au survol) PwaIssueAmountMismatch = Montant %s différent de la référence %s diff --git a/lib/reedcrm.lib.php b/lib/reedcrm.lib.php index e2024f3c..292b5741 100644 --- a/lib/reedcrm.lib.php +++ b/lib/reedcrm.lib.php @@ -76,12 +76,14 @@ function reedcrm_admin_prepare_head(): array } /** - * Bulk fetch latest documents for a list of project IDs. + * Bulk fetch documents for a list of project IDs. * - * @param array $projectIds Array of project IDs - * @return array Multi-dimensional array: [project_id][doc_type] => ['ref' => ..., 'amount' => ..., 'url' => ...] + * @param int[] $projectIds Array of project IDs. + * @param bool $includeAll When true, each piece key also carries an 'all' list of every document + * of that type (ordered by rowid); the top-level fields stay the latest one. + * @return array Multi-dimensional array: [project_id][doc_type] => ['ref' => ..., 'amount' => ..., 'url' => ..., 'all' => [...]]. */ -function reedcrm_get_pwa_projects_documents(array $projectIds): array +function reedcrm_get_pwa_projects_documents(array $projectIds, bool $includeAll = false): array { global $db, $conf; @@ -113,41 +115,64 @@ function reedcrm_get_pwa_projects_documents(array $projectIds): array $res = $db->query($sql); if ($res) { while ($row = $db->fetch_object($res)) { - $results[$row->fk_projet]['montant'] = [ - 'ref' => $row->ref, - 'amount' => (float)$row->total_ht, + $entry = [ + 'ref' => $row->ref, + 'amount' => (float) $row->total_ht, 'status' => null, - 'url' => DOL_URL_ROOT . '/projet/card.php?id=' . $row->fk_projet + 'url' => DOL_URL_ROOT . '/projet/card.php?id=' . $row->fk_projet ]; + $results[$row->fk_projet]['montant'] = $entry; + if ($includeAll) { + $results[$row->fk_projet]['montant']['all'] = [$entry]; + } } } } // Helper closure to query simple tables - $querySimpleTable = function ($constantName, $tableName, $key, $urlPath, $hasAmount = true) use ($db, $idListStr, &$results) { + $querySimpleTable = function ($constantName, $tableName, $key, $urlPath, $hasAmount = true) use ($db, $idListStr, $includeAll, &$results) { global $conf; if (empty($conf->global->$constantName)) { return; } - $sql = "SELECT t.fk_projet, t.rowid, t.ref, t.fk_statut AS status" . ($hasAmount ? ", t.total_ht" : "") . " - FROM " . MAIN_DB_PREFIX . $tableName . " t - INNER JOIN ( - SELECT fk_projet, MAX(rowid) as max_id - FROM " . MAIN_DB_PREFIX . $tableName . " - WHERE fk_projet IN (" . $idListStr . ") - GROUP BY fk_projet - ) latest ON t.rowid = latest.max_id"; + $cols = "t.fk_projet, t.rowid, t.ref, t.fk_statut AS status" . ($hasAmount ? ", t.total_ht" : ""); + if ($includeAll) { + $sql = "SELECT " . $cols . " FROM " . MAIN_DB_PREFIX . $tableName . " t" + . " WHERE t.fk_projet IN (" . $idListStr . ") ORDER BY t.fk_projet, t.rowid"; + } else { + $sql = "SELECT " . $cols . " + FROM " . MAIN_DB_PREFIX . $tableName . " t + INNER JOIN ( + SELECT fk_projet, MAX(rowid) as max_id + FROM " . MAIN_DB_PREFIX . $tableName . " + WHERE fk_projet IN (" . $idListStr . ") + GROUP BY fk_projet + ) latest ON t.rowid = latest.max_id"; + } $res = $db->query($sql); if ($res) { while ($row = $db->fetch_object($res)) { - $results[$row->fk_projet][$key] = [ - 'ref' => $row->ref, - 'amount' => $hasAmount ? (float)$row->total_ht : null, + $entry = [ + 'ref' => $row->ref, + 'amount' => $hasAmount ? (float) $row->total_ht : null, 'status' => isset($row->status) ? (int) $row->status : null, - 'url' => DOL_URL_ROOT . $urlPath . $row->rowid + 'url' => DOL_URL_ROOT . $urlPath . $row->rowid ]; + if ($includeAll) { + if (!is_array($results[$row->fk_projet][$key])) { + $results[$row->fk_projet][$key] = ['all' => []]; + } + $results[$row->fk_projet][$key]['all'][] = $entry; + // Rows are ordered by rowid ASC: the last iteration is the representative latest. + $results[$row->fk_projet][$key]['ref'] = $entry['ref']; + $results[$row->fk_projet][$key]['amount'] = $entry['amount']; + $results[$row->fk_projet][$key]['status'] = $entry['status']; + $results[$row->fk_projet][$key]['url'] = $entry['url']; + } else { + $results[$row->fk_projet][$key] = $entry; + } } } }; @@ -175,28 +200,49 @@ function reedcrm_get_pwa_projects_documents(array $projectIds): array // 9. Payment (transitively linked via payments on invoices) if (!empty($conf->global->REEDCRM_PWA_SHOW_PAYMENT)) { - $sql = "SELECT f.fk_projet, p.ref, p.amount, p.rowid - FROM " . MAIN_DB_PREFIX . "paiement p - JOIN " . MAIN_DB_PREFIX . "paiement_facture pf ON pf.fk_paiement = p.rowid - JOIN " . MAIN_DB_PREFIX . "facture f ON f.rowid = pf.fk_facture - INNER JOIN ( - SELECT f2.fk_projet, MAX(p2.rowid) as max_id - FROM " . MAIN_DB_PREFIX . "paiement p2 - JOIN " . MAIN_DB_PREFIX . "paiement_facture pf2 ON pf2.fk_paiement = p2.rowid - JOIN " . MAIN_DB_PREFIX . "facture f2 ON f2.rowid = pf2.fk_facture - WHERE f2.fk_projet IN (" . $idListStr . ") - GROUP BY f2.fk_projet - ) latest ON p.rowid = latest.max_id AND f.fk_projet = latest.fk_projet"; + if ($includeAll) { + $sql = "SELECT DISTINCT f.fk_projet, p.ref, p.amount, p.rowid + FROM " . MAIN_DB_PREFIX . "paiement p + JOIN " . MAIN_DB_PREFIX . "paiement_facture pf ON pf.fk_paiement = p.rowid + JOIN " . MAIN_DB_PREFIX . "facture f ON f.rowid = pf.fk_facture + WHERE f.fk_projet IN (" . $idListStr . ") + ORDER BY f.fk_projet, p.rowid"; + } else { + $sql = "SELECT f.fk_projet, p.ref, p.amount, p.rowid + FROM " . MAIN_DB_PREFIX . "paiement p + JOIN " . MAIN_DB_PREFIX . "paiement_facture pf ON pf.fk_paiement = p.rowid + JOIN " . MAIN_DB_PREFIX . "facture f ON f.rowid = pf.fk_facture + INNER JOIN ( + SELECT f2.fk_projet, MAX(p2.rowid) as max_id + FROM " . MAIN_DB_PREFIX . "paiement p2 + JOIN " . MAIN_DB_PREFIX . "paiement_facture pf2 ON pf2.fk_paiement = p2.rowid + JOIN " . MAIN_DB_PREFIX . "facture f2 ON f2.rowid = pf2.fk_facture + WHERE f2.fk_projet IN (" . $idListStr . ") + GROUP BY f2.fk_projet + ) latest ON p.rowid = latest.max_id AND f.fk_projet = latest.fk_projet"; + } $res = $db->query($sql); if ($res) { while ($row = $db->fetch_object($res)) { - $results[$row->fk_projet]['payment'] = [ - 'ref' => $row->ref, - 'amount' => (float)$row->amount, + $entry = [ + 'ref' => $row->ref, + 'amount' => (float) $row->amount, 'status' => null, - 'url' => DOL_URL_ROOT . '/compta/paiement/card.php?id=' . $row->rowid + 'url' => DOL_URL_ROOT . '/compta/paiement/card.php?id=' . $row->rowid ]; + if ($includeAll) { + if (!is_array($results[$row->fk_projet]['payment'])) { + $results[$row->fk_projet]['payment'] = ['all' => []]; + } + $results[$row->fk_projet]['payment']['all'][] = $entry; + $results[$row->fk_projet]['payment']['ref'] = $entry['ref']; + $results[$row->fk_projet]['payment']['amount'] = $entry['amount']; + $results[$row->fk_projet]['payment']['status'] = $entry['status']; + $results[$row->fk_projet]['payment']['url'] = $entry['url']; + } else { + $results[$row->fk_projet]['payment'] = $entry; + } } } } @@ -343,3 +389,47 @@ function reedcrm_chain_bar_styles(): string .pwa-doc-label { font-weight: 500; color: #64748b; } '; } + +/** + * Return the '; +} diff --git a/view/frontend/pwa_projets.php b/view/frontend/pwa_projets.php index c9647bd1..02d1d279 100644 --- a/view/frontend/pwa_projets.php +++ b/view/frontend/pwa_projets.php @@ -149,11 +149,19 @@ $paginationHtml .= ''; } +require_once DOL_DOCUMENT_ROOT . '/custom/reedcrm/lib/reedcrm.lib.php'; +$selfMatrix = dol_buildpath('/custom/reedcrm/view/frontend/pwa_projets_overview.php', 1); +$qsToggle = (!empty($searchStr) ? '&s=' . urlencode($searchStr) : '') . ($page > 0 ? '&page=' . (int) $page : ''); +$toggleHtml = '' . $langs->trans('ViewAsMatrix') . ''; + $pwaHeaderCenterHtml = '
'; $pwaHeaderCenterHtml .= $searchHtml; $pwaHeaderCenterHtml .= $paginationHtml; +$pwaHeaderCenterHtml .= $toggleHtml; $pwaHeaderCenterHtml .= '
'; +print reedcrm_chain_matrix_styles(); + require_once __DIR__ . '/../../core/tpl/frontend/reedcrm_pwa_header.tpl.php'; print '
'; diff --git a/view/frontend/pwa_projets_overview.php b/view/frontend/pwa_projets_overview.php new file mode 100644 index 00000000..f284ae9d --- /dev/null +++ b/view/frontend/pwa_projets_overview.php @@ -0,0 +1,126 @@ +payment chain across projects. + */ + +if (file_exists('../reedcrm.main.inc.php')) { + require_once __DIR__ . '/../reedcrm.main.inc.php'; +} elseif (file_exists('../../reedcrm.main.inc.php')) { + require_once __DIR__ . '/../../reedcrm.main.inc.php'; +} else { + die('Include of reedcrm main fails'); +} + +global $conf, $db, $hookmanager, $langs, $user; + +require_once DOL_DOCUMENT_ROOT . '/projet/class/project.class.php'; +require_once DOL_DOCUMENT_ROOT . '/custom/reedcrm/lib/reedcrm.lib.php'; + +saturne_load_langs(['projects', 'users', 'companies', 'main']); + +$title = $langs->trans('ProjectsOverviewMatrix'); +$help_url = 'FR:Module_ReedCRM'; +$moreJS = [ + '/custom/saturne/js/saturne.min.js', + '/custom/reedcrm/js/reedcrm.min.js' +]; +$moreCSS = ['/custom/saturne/css/saturne.min.css', '/custom/reedcrm/css/reedcrm.min.css']; + +$conf->dol_hide_topmenu = 1; +$conf->dol_hide_leftmenu = 1; +$conf->global->MAIN_FAVICON_URL = DOL_URL_ROOT . '/custom/reedcrm/img/reedcrm_color_512.png'; + +llxHeader('', $title, $help_url, '', 0, 0, $moreJS, $moreCSS, '', 'template-pwa pwa-projets-overview'); + +if (!$user->rights->projet->lire) { + accessforbidden($langs->trans('NotEnoughPermissions'), 0); + exit; +} + +// 1. Search & pagination params (same contract as pwa_projets.php). +$searchStr = trim(GETPOST('s', 'alpha')); +$page = GETPOST('page', 'int'); +if (empty($page)) $page = 0; + +$limit = getDolGlobalInt('REEDCRM_NB_LATEST_OPPORTUNITIES_FRONTEND', 15); +if ($limit <= 0) $limit = 15; +$offset = $limit * $page; + +// 2. Filters (same as pwa_projets.php). +$filter = []; +if (!empty($searchStr)) { + $searchEscaped = $db->escape($searchStr); + $customSql = "(t.ref LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR t.title LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR t.description LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR eft.reedcrm_firstname LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR eft.reedcrm_lastname LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR eft.projectphone LIKE '%" . $searchEscaped . "%' "; + $customSql .= " OR eft.reedcrm_email LIKE '%" . $searchEscaped . "%' "; + $customSql .= ")"; + $filter['customsql'] = $customSql; +} + +// 3. Fetch total + current page. +$moreparamsCount = ['count' => true]; +$totalProjects = saturne_fetch_all_object_type('Project', 'DESC', 't.datec', 0, 0, $filter, 'AND', true, true, false, '', $moreparamsCount); +if (!is_numeric($totalProjects) || $totalProjects < 0) $totalProjects = 0; + +$matrixProjects = saturne_fetch_all_object_type('Project', 'DESC', 't.datec', $limit, $offset, $filter, 'AND', true, true, false, '', [], ', t.description', ['description']); +if (empty($matrixProjects) || !is_array($matrixProjects)) { + $matrixProjects = []; +} +$nbResults = count($matrixProjects); + +$pwaProjectIds = []; +foreach ($matrixProjects as $proj) { + $pwaProjectIds[] = $proj->id; +} +$matrixDocs = reedcrm_get_pwa_projects_documents($pwaProjectIds, true); + +// --- SEARCH BAR + VIEW TOGGLE for the header --- +$selfList = dol_buildpath('/custom/reedcrm/view/frontend/pwa_projets.php', 1); +$qs = (!empty($searchStr) ? '&s=' . urlencode($searchStr) : '') . ($page > 0 ? '&page=' . (int) $page : ''); +$toggleHtml = '' . $langs->trans('ViewAsList') . ''; + +$searchHtml = ' +
+
+ + +
+ '; +if (!empty($searchStr)) { + $searchHtml .= ''; +} +$searchHtml .= '
'; + +$pwaHeaderCenterHtml = '
'; +$pwaHeaderCenterHtml .= $searchHtml; +$pwaHeaderCenterHtml .= $toggleHtml; +$pwaHeaderCenterHtml .= '
'; + +print reedcrm_chain_matrix_styles(); + +require_once __DIR__ . '/../../core/tpl/frontend/reedcrm_pwa_header.tpl.php'; + +print '
'; + +if (!empty($matrixProjects)) { + require __DIR__ . '/../../core/tpl/frontend/reedcrm_opportunity_chain_matrix.tpl.php'; +} else { + print '
'; + print ' '; + print '
' . $langs->trans('NoRecordFound') . '
'; + print '
'; +} + +print '
'; + +// Include the Bottom Navigation Bar for App +require_once __DIR__ . '/../../core/tpl/frontend/reedcrm_pwa_bottom_nav.tpl.php'; + +llxFooter(); +$db->close();