<?php
namespace ApplicationBundle\Modules\Readiness\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Accounts\Service\ConfigReadinessService;
use ApplicationBundle\Modules\Readiness\Service\ReadinessStatsService;
use ApplicationBundle\Modules\Readiness\Support\ReadinessChecklistCore;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Company / Implementation Readiness — the "Migration Hub" command center (cp-shell).
* Screens are ported from the Stitch "Enterprise Success Command Center" design. One
* generic page action renders pages/<slug>.html.twig from the SCREENS whitelist; the
* shared cp-shell layout builds the grouped sidebar from this same manifest.
*/
class CompanyReadinessController extends GenericController implements SessionCheckInterface
{
/** slug => [title, group, icon]. Order within a group = sidebar order. */
const SCREENS = [
// Journey
'command-center' => ['Command Center', 'Journey', 'dashboard'],
'project-hub' => ['Project Hub', 'Journey', 'account_tree'],
'project-timeline' => ['Project Timeline', 'Journey', 'timeline'],
'rollout-journey' => ['13-Stage Journey', 'Journey', 'map'],
'my-actions' => ['My Actions', 'Journey', 'inbox'],
// Setup & Migration
'scope' => ['Scope & Commercials', 'Setup & Migration', 'assignment'],
'master-data' => ['Master Data Readiness', 'Setup & Migration', 'database'],
'ai-migration' => ['AI / OCR Migration', 'Setup & Migration', 'document_scanner'],
'document-adoption' => ['Document Adoption', 'Setup & Migration', 'description'],
'resource-vault' => ['Resource Vault', 'Setup & Migration', 'folder_open'],
// Readiness
'go-live' => ['Go-Live Readiness', 'Readiness', 'verified'],
'cutover' => ['Cutover Checklist', 'Readiness', 'checklist'],
'uat-punch-list' => ['UAT Punch List', 'Readiness', 'bug_report'],
'rollout-health' => ['Dependency Health', 'Readiness', 'health_and_safety'],
'commercial-audit' => ['Commercial Audit', 'Readiness', 'receipt_long'],
// Adoption
'hypercare' => ['Hypercare & Adoption', 'Adoption', 'support_agent'],
'operational-adoption'=> ['Operational Adoption', 'Adoption', 'insights'],
// Enablement
'erp-mastery' => ['ERP Mastery', 'Enablement', 'school'],
'module-mastery' => ['Module Mastery', 'Enablement', 'grid_view'],
'workshops' => ['Workshops & Support', 'Enablement', 'groups'],
'certification' => ['Certification & Rewards','Enablement', 'workspace_premium'],
// Executive
'executive' => ['Executive Center', 'Executive', 'leaderboard'],
'pmo' => ['Implementation PMO', 'Executive', 'engineering'],
'digital-maturity' => ['Digital Maturity', 'Executive', 'trending_up'],
'closure' => ['Closure & Sign-off', 'Executive', 'task_alt'],
'client-handover' => ['Client Handover', 'Executive', 'handshake'],
// Admin
'team-access' => ['Team Access & Roles', 'Admin', 'admin_panel_settings'],
// GA3 — the five-state capability map (derived on read, never persisted)
'capability-map' => ['Capability Map', 'Executive', 'map'],
];
public function indexAction(Request $request): RedirectResponse
{
return $this->redirect($this->generateUrl('company_readiness_page', ['slug' => 'command-center']));
}
/** Generic screen renderer. Extra live data is injected per-slug where it maps. */
public function pageAction(Request $request, string $slug)
{
if (!isset(self::SCREENS[$slug])) {
throw new NotFoundHttpException('Unknown readiness screen.');
}
[$title, $group] = self::SCREENS[$slug];
$extra = [];
if ($slug === 'capability-map') {
// GA3: real signals -> the five honest states; readiness gaps ride from GA1
$em = $this->getDoctrine()->getManager();
$cm = ReadinessStatsService::checklistMetrics($em);
$items = ReadinessChecklistCore::derive($cm['metrics'], $cm['country']);
$rows = \ApplicationBundle\Modules\Readiness\Support\CapabilityMapCore::derive(
ReadinessStatsService::capabilityMetrics($em), $items);
$extra = [
'cap_rows' => $rows,
'cap_groups' => array_reduce($rows, function ($acc, $r) { $acc[$r['group']][] = $r; return $acc; }, []),
'cap_summary' => \ApplicationBundle\Modules\Readiness\Support\CapabilityMapCore::summarize($rows),
];
} elseif ($slug === 'go-live') {
// Wire the Go-Live readiness gauge + blockers to the real config audit.
$audit = ConfigReadinessService::audit($this->getDoctrine()->getManager());
$total = max(1, count($audit['findings']));
$techCats = ['Control heads', 'Tax', 'Sales', 'Period close'];
$ok = 0; $tT = 0; $tO = 0; $okT = 0; $okO = 0; $blockers = [];
foreach ($audit['findings'] as $f) {
$isTech = in_array($f['category'], $techCats, true);
if ($isTech) { $tT++; } else { $tO++; }
if ($f['status'] === 'ok') { $ok++; $isTech ? $okT++ : $okO++; }
else { $blockers[] = ['label' => $f['label'], 'detail' => $f['blocks'], 'critical' => $f['severity'] === 'critical']; }
}
usort($blockers, function ($a, $b) { return $b['critical'] <=> $a['critical']; });
// Per-area readiness (replaces the illustrative "7-day trend" with a real breakdown).
$byCat = [];
foreach ($audit['findings'] as $f) {
$c = $f['category'] ?: 'Other';
if (!isset($byCat[$c])) { $byCat[$c] = ['ok' => 0, 'total' => 0]; }
$byCat[$c]['total']++;
if ($f['status'] === 'ok') { $byCat[$c]['ok']++; }
}
$areas = [];
foreach ($byCat as $c => $v) {
$areas[] = ['name' => $c, 'pct' => $v['total'] ? (int) round($v['ok'] / $v['total'] * 100) : 100];
}
$extra = [
'readinessIndex' => (int) round($ok / $total * 100),
'technical' => $tT ? (int) round($okT / $tT * 100) : 100,
'operational' => $tO ? (int) round($okO / $tO * 100) : 100,
'blockers' => $blockers,
'critical' => $audit['critical'],
'areas' => $areas,
'findingCount' => count($audit['findings']),
'okCount' => $ok,
];
} elseif ($slug === 'closure' || $slug === 'project-timeline') {
// ERP-018/019 fix: these two screens had NO $extra branch, so their templates hit
// strict_variables undefined-var 500s (closure -> rd/ok/findings; project-timeline ->
// rd_index/rd_areas/rd_blockers). Wire them to the SAME real config audit that the
// go-live/command-center screens already use, in the same shapes.
$audit = ConfigReadinessService::audit($this->getDoctrine()->getManager());
$findingsArr = $audit['findings'];
$total = max(1, count($findingsArr));
$okCnt = 0; $blk = []; $byCat = [];
foreach ($findingsArr as $f) {
$c = $f['category'] ?: 'Other';
if (!isset($byCat[$c])) { $byCat[$c] = ['ok' => 0, 'total' => 0]; }
$byCat[$c]['total']++;
if ($f['status'] === 'ok') { $okCnt++; $byCat[$c]['ok']++; }
else { $blk[] = ['label' => $f['label'], 'detail' => $f['blocks'], 'critical' => $f['severity'] === 'critical']; }
}
usort($blk, function ($a, $b) { return $b['critical'] <=> $a['critical']; });
$areas = [];
foreach ($byCat as $c => $v) { $areas[] = ['name' => $c, 'pct' => $v['total'] ? (int) round($v['ok'] / $v['total'] * 100) : 100]; }
$idx = (int) round($okCnt / $total * 100);
$fCnt = count($findingsArr);
// closure.html.twig sets ok/findings/critical/openItems via {% set %} at the top, but
// those don't reach its insights section (separate Twig scope) — pass them explicitly.
$extra = [
'rd' => ['index' => $idx, 'ok' => $okCnt, 'findings' => $fCnt, 'critical' => $audit['critical'], 'blockers' => $blk, 'areas' => $areas],
'ok' => $okCnt,
'findings' => $fCnt,
'critical' => $audit['critical'],
'openItems' => count($blk),
'rd_index' => $idx,
'rd_areas' => $areas,
'rd_blockers' => $blk,
];
} elseif ($slug === 'master-data') {
// Wire the master-data pipeline to real record counts. A category with records
// is treated as fully ingested (6/6 steps); an empty one as not-started.
$conn = $this->getDoctrine()->getManager()->getConnection();
$cnt = function ($sql) use ($conn) { try { return (int) $conn->fetchOne($sql); } catch (\Throwable $e) { return 0; } };
$defs = [
['Item Group', "SELECT COUNT(*) FROM inv_item_group WHERE (status=1 OR status IS NULL) AND name <> 'Uncategorized'"],
['Product', "SELECT COUNT(*) FROM inv_products WHERE (status=1 OR status IS NULL)"],
['Client / Customer', "SELECT COUNT(*) FROM acc_clients WHERE (delete_flag=0 OR delete_flag IS NULL)"],
['Supplier', "SELECT COUNT(*) FROM acc_suppliers WHERE (delete_flag=0 OR delete_flag IS NULL)"],
['Chart of Accounts', "SELECT COUNT(*) FROM acc_accounts_head WHERE (delete_flag=0 OR delete_flag IS NULL)"],
['Tax Codes', "SELECT COUNT(*) FROM tax_config"],
];
$cats = []; $done = 0;
foreach ($defs as [$name, $sql]) {
$c = $cnt($sql);
$cats[] = ['name' => $name, 'count' => $c, 'state' => $c > 0 ? 'done' : 'todo', 'steps' => $c > 0 ? 6 : 0];
if ($c > 0) { $done++; }
}
$extra = ['mdCategories' => $cats, 'mdDone' => $done, 'mdTotal' => count($cats)];
} elseif ($slug === 'my-actions') {
// Wire the action inbox to the logged-in user's open tasks (planning_item).
$conn = $this->getDoctrine()->getManager()->getConnection();
$uid = (int) $request->getSession()->get(UserConstants::USER_ID);
$rows = [];
try {
$rows = $conn->fetchAllAssociative(
"SELECT id, item_alias, task_status,
estimated_completion_time_ts AS due_ts, project_id,
COALESCE(completion_percentage,0) AS pct
FROM planning_item
WHERE (delete_flag = 0 OR delete_flag IS NULL)
AND (assigned_to = ? OR assigned_to_ids LIKE ?)
AND (completion_percentage IS NULL OR completion_percentage < 100)
ORDER BY estimated_completion_time_ts IS NULL, estimated_completion_time_ts ASC
LIMIT 40",
[$uid, '%"' . $uid . '"%']
);
} catch (\Throwable $e) { $rows = []; }
$now = time();
$actions = array_map(function ($r) use ($now) {
$due = (int) $r['due_ts']; $label = 'No date'; $overdue = false;
if ($due > 0) {
$d = (int) floor(($due - $now) / 86400);
if ($d < 0) { $label = 'Overdue'; $overdue = true; }
elseif ($d === 0){ $label = 'Today'; }
elseif ($d === 1){ $label = 'Tomorrow'; }
else { $label = 'In ' . $d . ' days'; }
}
return [
'id' => (int) $r['id'],
'title' => $r['item_alias'] ?: ('Task #' . $r['id']),
'status' => $r['task_status'] ?: 'open',
'percent' => (float) $r['pct'],
'due' => $label,
'overdue' => $overdue,
'project' => (int) $r['project_id'],
];
}, $rows);
$urgent = $actions[0] ?? null;
$extra = [
'urgent' => $urgent,
'actions' => $urgent ? array_slice($actions, 1) : $actions,
'actionCount' => count($actions),
];
} elseif ($slug === 'command-center') {
// Home KPIs: setup-readiness % + critical blockers (config audit) + my open tasks.
$em = $this->getDoctrine()->getManager();
$audit = ConfigReadinessService::audit($em);
$total = max(1, count($audit['findings'])); $ok = 0; $blk = [];
foreach ($audit['findings'] as $f) {
if ($f['status'] === 'ok') { $ok++; }
elseif ($f['severity'] === 'critical') { $blk[] = ['label' => $f['label'], 'detail' => $f['blocks']]; }
}
$conn = $em->getConnection();
$uid = (int) $request->getSession()->get(UserConstants::USER_ID);
$tasks = []; $taskCount = 0;
try {
$where = "(delete_flag=0 OR delete_flag IS NULL) AND (assigned_to=? OR assigned_to_ids LIKE ?) AND (completion_percentage IS NULL OR completion_percentage<100)";
$taskCount = (int) $conn->fetchOne("SELECT COUNT(*) FROM planning_item WHERE $where", [$uid, '%"' . $uid . '"%']);
$tasks = $conn->fetchAllAssociative("SELECT id, item_alias, project_id FROM planning_item WHERE $where ORDER BY estimated_completion_time_ts IS NULL, estimated_completion_time_ts ASC LIMIT 3", [$uid, '%"' . $uid . '"%']);
} catch (\Throwable $e) { $tasks = []; }
$extra = [
'readinessIndex' => (int) round($ok / $total * 100),
'blockers' => array_slice($blk, 0, 3),
'blockerCount' => count($blk),
'tasks' => $tasks,
'taskCount' => $taskCount,
];
} elseif ($slug === 'team-access') {
// Wire the team directory to real users (sys_user) + roles (sys_department_position)
// + last login (sys_login_log).
$conn = $this->getDoctrine()->getManager()->getConnection();
$members = []; $tot = 0; $activeToday = 0; $ready = 0; $noRole = 0; $inactive = 0; $roleCount = 0;
try {
$positions = [];
foreach ($conn->fetchAllAssociative("SELECT position_id, position_name FROM sys_department_position") as $p) {
$positions[(int) $p['position_id']] = $p['position_name'];
}
$roleCount = count($positions);
$lastLogin = [];
foreach ($conn->fetchAllAssociative("SELECT user_id, MAX(log_time) AS lt FROM sys_login_log GROUP BY user_id") as $l) {
$lastLogin[(int) $l['user_id']] = $l['lt'];
}
$rows = $conn->fetchAllAssociative("SELECT user_id, name, email, user_name, position_ids, all_module_access_flag, status FROM sys_user ORDER BY user_id ASC");
$tot = count($rows);
$now = time();
foreach ($rows as $r) {
$uid = (int) $r['user_id'];
$posName = '—';
$pids = json_decode($r['position_ids'] ?: '[]', true);
if (is_array($pids)) {
foreach ($pids as $pid) {
if ((int) $pid > 0 && isset($positions[(int) $pid])) { $posName = $positions[(int) $pid]; break; }
}
}
$ltLabel = 'Never'; $online = false;
if (!empty($lastLogin[$uid])) {
$ts = strtotime($lastLogin[$uid]); $diff = $now - $ts;
if ($diff < 900) { $ltLabel = 'Online'; $online = true; }
elseif ($diff < 3600) { $ltLabel = (int) floor($diff / 60) . 'm ago'; }
elseif ($diff < 86400) { $ltLabel = (int) floor($diff / 3600) . 'h ago'; }
else { $ltLabel = (int) floor($diff / 86400) . 'd ago'; }
if ($diff < 86400) { $activeToday++; }
}
$isActive = ((int) $r['status'] !== 0);
if ($isActive) { $ready++; } else { $inactive++; }
if ((int) $r['all_module_access_flag'] === 1) { $access = 'Full Access'; $accessPill = 'info'; }
elseif (!$isActive) { $access = 'Inactive'; $accessPill = 'err'; }
elseif ($posName === '—') { $access = 'No Role'; $accessPill = 'warn'; $noRole++; }
else { $access = 'Standard'; $accessPill = ''; }
$name = $r['name'] ?: ($r['user_name'] ?: ('User #' . $uid));
$ini = '';
foreach (preg_split('/\s+/', trim($name)) as $w) { if ($w !== '') { $ini .= strtoupper($w[0]); } }
$ini = substr($ini, 0, 2) ?: 'U';
$members[] = [
'name' => $name, 'email' => $r['email'] ?: '—', 'role' => $posName,
'access' => $access, 'accessPill' => $accessPill, 'last' => $ltLabel,
'online' => $online, 'initials' => $ini, 'inactive' => !$isActive,
];
}
} catch (\Throwable $e) { $members = []; }
$extra = [
'members' => $members,
'memberTotal' => $tot,
'activeToday' => $activeToday,
'teamReady' => $tot ? (int) round($ready / $tot * 100) : 0,
'roleCount' => $roleCount,
'noRoleCount' => $noRole,
'inactiveCount' => $inactive,
];
}
// GA1 — Gap→Action checklist (deterministic core over null-honest metrics) on the
// three actionable screens. States are complete/incomplete/not_tracked — a metric the
// service cannot compute renders "not tracked", never fake-complete and never fake-zero.
if (in_array($slug, ['master-data', 'go-live', 'command-center'], true)) {
try {
$cm = ReadinessStatsService::checklistMetrics($this->getDoctrine()->getManager());
$items = ReadinessChecklistCore::derive($cm['metrics'], $cm['country']);
$extra += [
'checklist_groups' => ReadinessChecklistCore::grouped($items),
'checklist_summary' => ReadinessChecklistCore::summarize($items),
'checklist_prereqs' => ReadinessChecklistCore::firstValuePrereqs($items),
'checklist_country' => $cm['country'],
];
} catch (\Throwable $e) {
// A readiness screen must never 500 — the partial defaults to empty.
}
}
// Shared bag of REAL ERP signals — available to every screen as `stats.*`.
$uid = (int) $request->getSession()->get(UserConstants::USER_ID);
$stats = ReadinessStatsService::compute($this->getDoctrine()->getManager(), $uid);
// Every screen now reads live ERP data via `stats` (widgets with no ERP source are
// labelled "Not tracked yet" inline), so the page-level illustrative banner is retired.
return $this->render('@Readiness/pages/' . $slug . '.html.twig', array_merge([
'screens' => self::SCREENS,
'active' => $slug,
'page_title' => $title,
'group' => $group,
'stats' => $stats,
'illustrative' => false,
], $extra));
}
}