<?php
class ProjectsController extends Controller
{
    public function indexAction()
    {
        $m = new Projects();
        // 8Y · filter + paginate register.
        $filter = [
            'q'            => trim((string)($_GET['q']            ?? '')),
            'project_type' => (string)($_GET['type']              ?? ''),
            'customer_id'  => (int)   ($_GET['customer_id']       ?? 0),
            'origin'       => (string)($_GET['origin']            ?? ''),
            'crm_status'   => (string)($_GET['crm_status']        ?? ''),
            'stage'        => (string)($_GET['stage']             ?? ''),
        ];
        $pageSize = 25;
        $page     = max(1, (int)($_GET['page'] ?? 1));
        $total    = $m->countAll($filter);
        $lastPage = max(1, (int)ceil($total / $pageSize));
        if ($page > $lastPage) $page = $lastPage;
        $rows = $m->listAll($filter + ['page' => $page, 'page_size' => $pageSize]);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Projects/index', [
            'page_title' => 'Projects',
            'rows'       => $rows,
            'filter'     => $filter,
            'page'       => $page,
            'page_size'  => $pageSize,
            'total'      => $total,
            'last_page'  => $lastPage,
            'customers'  => $m->distinctCustomers(),
        ]);
    }

    public function addAction()
    {
        $cust = (new Customers())->allActive();
        $this->view->setLayout($this->roleLayout());
        $this->view->render('Projects/add', [
            'page_title' => 'New Project',
            'customers'  => $cust,
            'fb_msg'     => '',
            'form'       => [],
        ]);
    }

    public function saveAction()
    {
        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            Router::redirect('Projects/add');
            return;
        }
        $m = new Projects();
        $r = $m->create($_POST, $this->currentUserId(), $_SESSION['full_name'] ?? '');
        if (!$r['ok']) {
            $this->view->setLayout($this->roleLayout());
            $this->view->render('Projects/add', [
                'page_title' => 'New Project',
                'customers'  => (new Customers())->allActive(),
                'fb_msg'     => '<div class="alert alert-danger">' . htmlspecialchars($r['msg']) . '</div>',
                'form'       => $_POST,
            ]);
            return;
        }
        Router::redirect('Projects/view/' . $r['id']);
    }

    public function viewAction($id = 0)
    {
        $m = new Projects();
        $p = $m->byId((int)$id);
        if (!$p) {
            $this->view->setLayout($this->roleLayout());
            $this->view->render('generals/fb_page', ['fb_msg' => '<div class="alert alert-danger">Project not found.</div>']);
            return;
        }
        // Onboarding snapshot + checklist
        $db      = new DB();
        $onb     = $db->mySelect("SELECT * FROM crm_project_onboarding WHERE project_id = :id LIMIT 1", [':id' => (int)$id]);
        $checkl  = $db->mySelect("SELECT * FROM crm_project_checklist_items WHERE project_id = :id ORDER BY c0d ASC", [':id' => (int)$id]) ?: [];
        // Active approval on this project's onboarding, if any
        $ac      = new ApprovalChain();
        $appr    = $ac->approvalFor('crm_project_onboarding', (int)($onb[0]['c0d'] ?? 0));
        // Type-aware workspace summary (Goods / Works / Services)
        $workspace = $this->buildWorkspaceSummary($p, $db);

        // Slice 8g — Goods CRM authoring surface (only for non-migrated goods projects pre-approval)
        $goodsPo = []; $goodsItems = []; $goodsAppr = [];
        if ($p['project_type'] === 'goods' && (int)$p['migrated_flag'] === 0 && in_array($p['crm_status'], ['PENDING','NOT_STARTED','IN_PROGRESS','SUBMITTED','DECLINED'], true)) {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $go = new GoodsOnboarding();
            // Peek — don't force-create until CRM adds first item
            $exPo = $db->mySelect("SELECT * FROM goods_client_pos WHERE project_id = :p AND status IN ('DRAFT','CONFIRMED') AND deleted = 0 ORDER BY c0d DESC LIMIT 1", [':p' => (int)$p['c0d']]);
            if (!empty($exPo)) {
                $goodsPo    = $exPo[0];
                $goodsItems = $go->itemsFor((int)$goodsPo['c0d']);
            }
            $goodsAppr = $ac->approvalFor(GoodsOnboarding::ENTITY_TYPE, (int)$p['c0d']);
        }

        // Assigned seats (post-onboarding) — for display
        $seatIds = ['pc_id','project_manager_id','accountant_id','procurement_id'];
        $seatMap = [];
        $nameIds = array_filter(array_map(fn($k) => (int)($p[$k] ?? 0), $seatIds));
        if (!empty($nameIds)) {
            $rows = $db->mySelect("SELECT c0d, full_name, user_group_name FROM users WHERE c0d IN (" . implode(',', array_map('intval', $nameIds)) . ")", []) ?: [];
            foreach ($rows as $r) $seatMap[(int)$r['c0d']] = $r;
        }

        // Slice 8Y — source-tender context card for tender-origin projects.
        $sourceCtx = [];
        if (in_array($p['origin'], ['TENDER_BDM','TENDER_PUBLIC'], true)) {
            $sourceCtx = (new Tenders())->sourceTenderContextForProject((int)$id);
        }

        // ── Slice 8Z-v · Services PM workspace data (empty for non-services projects) ──
        $svcSpec        = [];
        $svcEquipment   = [];
        $svcContract    = null;
        $svcSites       = [];
        $svcAssets      = [];
        $svcSlas        = [];
        $svcOverheads   = [];
        $svcOhNotes     = [];
        $svcOpsDocs     = [];
        $svcOpsMandatory= ['ok' => false, 'missing' => [], 'labels' => []];
        if ($p['project_type'] === 'services') {
            // Live-read the tender spec (Vodacom 10 answers) via the tender_tracker link
            $svcTenderRs = $db->mySelect("SELECT c0d FROM tender_tracker WHERE linked_project_id = :p AND deleted = 0 LIMIT 1", [':p' => (int)$id]);
            $svcTenderId = (int)($svcTenderRs[0]['c0d'] ?? 0);
            if ($svcTenderId > 0) {
                if (!class_exists('TenderServiceSpec')) require_once ROOT . '/app/models/TenderServiceSpec.php';
                $tss = new TenderServiceSpec();
                $svcSpec      = $tss->forTender($svcTenderId);
                $svcEquipment = $tss->decodedEquipment($svcSpec);
                // Overheads pool from the tender's latest pricing version
                $ohRs = $db->mySelect(
                    "SELECT o.* FROM pricing_version_overheads o
                       JOIN tender_pricing_versions v ON v.c0d = o.version_id
                      WHERE v.tender_id = :t AND o.deleted = 0
                      ORDER BY o.line_no",
                    [':t' => $svcTenderId]
                ) ?: [];
                $svcOverheads = $ohRs;
                if (!class_exists('ProjectServiceOverheadAnnotations')) require_once ROOT . '/app/models/ProjectServiceOverheadAnnotations.php';
                $svcOhNotes = (new ProjectServiceOverheadAnnotations())->forProjectKeyed((int)$id);
            }
            // Auto-created service_contract from convert-to-project (if any)
            $scRs = $db->mySelect("SELECT * FROM service_contracts WHERE project_id = :p AND deleted = 0 ORDER BY c0d DESC LIMIT 1", [':p' => (int)$id]);
            $svcContract = $scRs[0] ?? null;
            if ($svcContract) {
                if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
                $sc = new ServiceContracts();
                $svcSites  = $sc->sitesFor((int)$svcContract['c0d']);
                $svcAssets = $sc->assetsFor((int)$svcContract['c0d']);
                $svcSlas   = $sc->slasFor((int)$svcContract['c0d']);
            }
            // Ops documents library
            if (!class_exists('ProjectOpsDocuments')) require_once ROOT . '/app/models/ProjectOpsDocuments.php';
            $opd = new ProjectOpsDocuments();
            $svcOpsDocs      = $opd->listFor((int)$id);
            $svcOpsMandatory = $opd->mandatoryCheck((int)$id);
            // Slice 8Z-w/x · templates catalog + callout resolutions (seeded from sitetracker16 patterns)
            if (!class_exists('ServiceTemplatesCatalog')) require_once ROOT . '/app/models/ServiceTemplatesCatalog.php';
            $stc = new ServiceTemplatesCatalog();
            $svcTemplatesGrouped = $stc->grouped();
            $svcCalloutResGrouped = $stc->calloutResolutionsGrouped();
            // Slice 8Z-y · Service Scope Configuration (bindings + packs)
            $svcBindings = [];
            $svcPacks    = [];
            if (!empty($svcContract)) {
                if (!class_exists('ServiceContractTemplateBindings')) require_once ROOT . '/app/models/ServiceContractTemplateBindings.php';
                if (!class_exists('ServiceContractPacks'))            require_once ROOT . '/app/models/ServiceContractPacks.php';
                $svcBindings = (new ServiceContractTemplateBindings())->listFor((int)$svcContract['c0d']);
                $svcPacks    = (new ServiceContractPacks())->listFor((int)$svcContract['c0d']);
            }
            $svcTemplatesFlat = $stc->all(); // for the binding dropdown
        }

        // Slice 8Z-u — Kickoff brief on the linked tender (FM + Exec halves merged).
        // Live-read from tender_exec_kickoff_brief keyed by the tender_id we just fetched
        // in $sourceCtx['tender']. Live-read (not snapshot) so any post-approval brief edit
        // by Exec/Admin flows straight to the PM's project view.
        $kickoffBrief   = [];
        $kickoffCeiling = null;
        $kickoffTid     = 0;
        if (!empty($sourceCtx['tender']['c0d'])) {
            $kickoffTid = (int)$sourceCtx['tender']['c0d'];
            if (!class_exists('TenderExecKickoffBrief')) require_once ROOT . '/app/models/TenderExecKickoffBrief.php';
            $kbm            = new TenderExecKickoffBrief();
            $kickoffBrief   = $kbm->forTender($kickoffTid);
            $kickoffCeiling = $kbm->budgetCeiling($kickoffTid);
        }

        // Slice 8Y — PM reassignment candidates + PC assignment candidates + role-change history.
        $pmCandidates = $db->mySelect(
            "SELECT c0d, full_name FROM users WHERE user_group_name = 'Project Manager' AND status = 'ACTIVE' AND deleted = 0 ORDER BY full_name",
            []
        ) ?: [];
        $pcCandidates = $db->mySelect(
            "SELECT c0d, full_name FROM users WHERE user_group_name = 'Projects Coordinator' AND status = 'ACTIVE' AND deleted = 0 ORDER BY full_name",
            []
        ) ?: [];
        $roleChanges = (new Projects())->roleChangesFor((int)$id);

        // Slice 8Y — Client PO documents + milestones for the two new cards.
        if (!class_exists('ProjectDocuments'))  require_once ROOT . '/app/models/ProjectDocuments.php';
        if (!class_exists('ProjectMilestones')) require_once ROOT . '/app/models/ProjectMilestones.php';
        $docs       = (new ProjectDocuments())->all((int)$id);
        $milestones = (new ProjectMilestones())->all((int)$id);

        // Slice 8Z-a — overheads carry-over for the PC scrub Vue root.
        if (!class_exists('ProjectOverheads')) require_once ROOT . '/app/models/ProjectOverheads.php';
        $projectOverheads = (new ProjectOverheads())->listFor((int)$id, !empty($goodsPo['c0d']) ? (int)$goodsPo['c0d'] : null);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Projects/view', [
            'page_title'  => $p['project_no'] . ' · ' . $p['project_name'],
            'p'           => $p,
            'onb'         => $onb[0] ?? [],
            'checklist'   => $checkl,
            'approval'    => $appr,
            'workspace'   => $workspace,
            'goods_po'    => $goodsPo,
            'goods_items' => $goodsItems,
            'goods_appr'  => $goodsAppr,
            'seats'         => $seatMap,
            'source_ctx'    => $sourceCtx,
            'pm_candidates' => $pmCandidates,
            'pc_candidates' => $pcCandidates,
            'role_changes'  => $roleChanges,
            'documents'     => $docs,
            'milestones'    => $milestones,
            'project_overheads' => $projectOverheads,
            // Slice 8Z-u · Kickoff brief for PM (merged FM + Exec halves, live-read from tender)
            'kickoff_brief'          => $kickoffBrief,
            'kickoff_budget_ceiling' => $kickoffCeiling,
            'kickoff_tender_id'      => $kickoffTid,
            // Slice 8Z-v · Services PM workspace (empty arrays for non-services projects)
            'svc_spec'          => $svcSpec,
            'svc_equipment'     => $svcEquipment,
            'svc_contract'      => $svcContract,
            'svc_sites'         => $svcSites,
            'svc_assets'        => $svcAssets,
            'svc_slas'          => $svcSlas,
            'svc_overheads'     => $svcOverheads,
            'svc_oh_notes'      => $svcOhNotes,
            'svc_ops_docs'      => $svcOpsDocs,
            'svc_ops_mandatory' => $svcOpsMandatory,
            // Slice 8Z-w · seeded templates catalog for the PM → PC picker
            'svc_templates_grouped'    => $svcTemplatesGrouped ?? [],
            // Slice 8Z-x · callout resolution patterns catalog
            'svc_callout_res_grouped'  => $svcCalloutResGrouped ?? [],
            // Slice 8Z-y · Service Scope Configuration
            'svc_bindings'             => $svcBindings ?? [],
            'svc_packs'                => $svcPacks    ?? [],
            'svc_templates_flat'       => $svcTemplatesFlat ?? [],
        ]);
    }

    /**
     * Slice 8Y · PM marks scope + pricing reviewed, with briefing notes to PC + Procurement.
     * POST: project_id, confirmation_notes, notes_pc, notes_procurement, suggested_items.
     */
    public function pmReviewAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Projects())->pmReview(
                (int)($_POST['project_id'] ?? 0),
                [
                    'confirmation_notes' => (string)($_POST['confirmation_notes'] ?? $_POST['notes'] ?? ''),
                    'notes_pc'           => (string)($_POST['notes_pc']           ?? ''),
                    'notes_procurement'  => (string)($_POST['notes_procurement']  ?? ''),
                    'suggested_items'    => (string)($_POST['suggested_items']    ?? ''),
                ],
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Project reviewed. Briefing saved.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-v · Services PM Workspace endpoints ─────────────── */

    /** Batch A — PM confirms the tender-derived DRAFT contract spec matches execution reality. */
    public function confirmSpecAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('Missing contract_id.');
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            (new ServiceContracts())->confirmSpec($cid,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'msg' => 'Spec confirmed.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batch F — PM saves structured instructions to PC (templates/sites/team). */
    public function svcInstructionsSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Projects())->saveSvcInstructions(
                (int)($_POST['project_id'] ?? 0),
                [
                    'templates_needed'   => (string)($_POST['templates_needed']   ?? ''),
                    'sites_instructions' => (string)($_POST['sites_instructions'] ?? ''),
                    'team_instructions'  => (string)($_POST['team_instructions']  ?? ''),
                ],
                (int)($_SESSION['user_id'] ?? 0)
            );
            echo json_encode(['ok' => true, 'msg' => 'Instructions saved.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batch E — PM annotates one tender-overhead line with actual + notes. */
    public function svcOverheadNoteSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $pid = (int)($_POST['project_id']         ?? 0);
            $toh = (int)($_POST['tender_overhead_id'] ?? 0);
            if (!class_exists('ProjectServiceOverheadAnnotations')) require_once ROOT . '/app/models/ProjectServiceOverheadAnnotations.php';
            $res = (new ProjectServiceOverheadAnnotations())->save($pid, $toh, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batches B — Sites CRUD (proxy to ServiceContracts). */
    public function svcSiteSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            $id = (new ServiceContracts())->saveSite($_POST);
            echo json_encode(['ok' => true, 'id' => (int)$id]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }
    public function svcSiteRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['site_id'] ?? 0);
            if ($sid <= 0) throw new InvalidArgumentException('Missing site_id.');
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            (new ServiceContracts())->deleteSite($sid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batches B — Assets CRUD (proxy to ServiceContracts). */
    public function svcAssetSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            $id = (new ServiceContracts())->saveAsset($_POST);
            echo json_encode(['ok' => true, 'id' => (int)$id]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }
    public function svcAssetRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $aid = (int)($_POST['asset_id'] ?? 0);
            if ($aid <= 0) throw new InvalidArgumentException('Missing asset_id.');
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            (new ServiceContracts())->deleteAsset($aid);
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-z-A · PC-facing Sites & POCs page + endpoints ─────── */

    /** Sites page render — PC lands here from the summary card on view/{id}. */
    public function sitesAction($projectId = 0)
    {
        $pid = (int)$projectId;
        if ($pid <= 0) { $this->view->set404(); return; }
        $p = (new Projects())->byId($pid);
        if (empty($p)) { $this->view->set404(); return; }

        if (!class_exists('ServiceContracts'))          require_once ROOT . '/app/models/ServiceContracts.php';
        if (!class_exists('ServiceContractSitePocs'))   require_once ROOT . '/app/models/ServiceContractSitePocs.php';
        if (!class_exists('ServiceContractPocs'))       require_once ROOT . '/app/models/ServiceContractPocs.php';
        if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
        if (!class_exists('AssetTypes'))                require_once ROOT . '/app/models/AssetTypes.php';

        $db = new DB();
        $cr = $db->mySelect(
            "SELECT c0d, spare_policy, sites_target_confirmed, equipment_scope_confirmed_json
               FROM service_contracts
              WHERE project_id = :p AND deleted = 0
              ORDER BY c0d DESC LIMIT 1",
            [':p' => $pid]
        );
        $contract = $cr[0] ?? null;
        $cid      = (int)($contract['c0d'] ?? 0);

        $sites        = $cid > 0 ? (new ServiceContracts())->sitesFor($cid)               : [];
        $pocs         = $cid > 0 ? (new ServiceContractSitePocs())->listForContract($cid) : [];
        $projectPocs  = $cid > 0 ? (new ServiceContractPocs())->listFor($cid)             : [];
        $assetSpecs   = $cid > 0 ? (new ServiceContractAssetSpecs())->listFor($cid)       : [];
        $siteIds      = array_map(fn($s) => (int)$s['c0d'], $sites);
        $assets       = [];
        if ($cid > 0 && !empty($siteIds)) {
            $assets = (new ServiceContracts())->assetsFor($cid);
        }
        $assetTypes = (new AssetTypes())->all(true);

        $role     = Controller::currentRoleSlug();
        $canEdit  = in_array($role, ['administrator','project_manager','projects_coordinator'], true);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Projects/sites', [
            'page_title'   => 'Project sites · ' . ($p['project_no'] ?? ''),
            'p'            => $p,
            'contract'     => $contract,
            'sites'        => $sites,
            'pocs'         => $pocs,
            'project_pocs' => $projectPocs,
            'assets'       => $assets,
            'asset_specs'  => $assetSpecs,
            'asset_types'  => $assetTypes,
            'can_edit'     => $canEdit,
        ]);
    }

    /** Snapshot AJAX — refresh sites + pocs + project pocs + assets in one shot. */
    public function sitesSnapshotAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? $_GET['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('contract_id required.');
            if (!class_exists('ServiceContracts'))          require_once ROOT . '/app/models/ServiceContracts.php';
            if (!class_exists('ServiceContractSitePocs'))   require_once ROOT . '/app/models/ServiceContractSitePocs.php';
            if (!class_exists('ServiceContractPocs'))       require_once ROOT . '/app/models/ServiceContractPocs.php';
            if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
            $sc = new ServiceContracts();
            echo json_encode([
                'ok'           => true,
                'sites'        => $sc->sitesFor($cid),
                'pocs'         => (new ServiceContractSitePocs())->listForContract($cid),
                'project_pocs' => (new ServiceContractPocs())->listFor($cid),
                'assets'       => $sc->assetsFor($cid),
                'asset_specs'  => (new ServiceContractAssetSpecs())->listFor($cid),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Single-site page — the dedicated per-site CRUD surface. Loads one site
     * plus its POCs, its equipment, the parent project + contract, project-level
     * POCs (for the "Inherited" chip strip), and blueprints (for the Add-equipment
     * prefill dropdown).
     */
    public function siteAction($siteId = 0)
    {
        $sid = (int)$siteId;
        if ($sid <= 0) { $this->view->set404(); return; }

        foreach (['ServiceContracts','ServiceContractSitePocs','ServiceContractPocs',
                  'ServiceContractAssetSpecs','AssetTypes','SpareRequests','SpareParts',
                  'Callouts'] as $cls) {
            if (!class_exists($cls)) require_once ROOT . '/app/models/' . $cls . '.php';
        }

        $db = new DB();
        $r = $db->mySelect("SELECT * FROM service_contract_sites WHERE c0d = :id", [':id' => $sid]);
        $site = $r[0] ?? null;
        if (empty($site)) { $this->view->set404(); return; }

        $cid = (int)$site['contract_id'];
        $ct  = $db->mySelect("SELECT c0d, project_id FROM service_contracts WHERE c0d = :c", [':c' => $cid]);
        if (empty($ct)) { $this->view->set404(); return; }
        $projectId = (int)$ct[0]['project_id'];
        $p = (new Projects())->byId($projectId);
        if (empty($p)) { $this->view->set404(); return; }

        $sc = new ServiceContracts();
        $sitePocs    = (new ServiceContractSitePocs())->listFor($sid);
        $projectPocs = (new ServiceContractPocs())->listFor($cid);
        $assets      = $sc->assetsFor($cid, $sid);
        $assetSpecs  = (new ServiceContractAssetSpecs())->listFor($cid);
        $assetTypes  = (new AssetTypes())->all(true);

        // 8Z-z-D · spare requests at this site + attach items for preview
        $srModel  = new SpareRequests();
        $spareReqs = $srModel->listForSite($sid);
        foreach ($spareReqs as &$sr) $sr['items'] = $srModel->itemsFor((int)$sr['c0d']);
        unset($sr);
        // Parts catalogue — trimmed to fields the picker needs
        $spareParts = $db->mySelect(
            "SELECT c0d, sku, name, uom, unit_cost, asset_type_id
               FROM spare_parts WHERE is_active = 1 ORDER BY name LIMIT 500", []
        ) ?: [];

        // 8Z-z-E · callouts at this site + category picker
        $callouts        = (new Callouts())->listForSite($sid);
        $calloutCategories = $db->mySelect(
            "SELECT c0d, code, name, sla_response_hrs, sla_restoration_hrs, color_hex
               FROM callout_categories WHERE is_active = 1 ORDER BY sla_response_hrs", []
        ) ?: [];

        $role     = Controller::currentRoleSlug();
        $canEdit  = in_array($role, ['administrator','project_manager','projects_coordinator'], true);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('Projects/site', [
            'page_title'    => 'Site · ' . ($site['name'] ?? ''),
            'p'             => $p,
            'contract_id'   => $cid,
            'site'          => $site,
            'site_pocs'     => $sitePocs,
            'project_pocs'  => $projectPocs,
            'assets'        => $assets,
            'asset_specs'   => $assetSpecs,
            'asset_types'   => $assetTypes,
            'spare_requests'=> $spareReqs,
            'spare_parts'   => $spareParts,
            'callouts'           => $callouts,
            'callout_categories' => $calloutCategories,
            'can_edit'      => $canEdit,
        ]);
    }

    /** Refresh AJAX for the single-site page. */
    public function siteSnapshotAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['site_id'] ?? $_GET['site_id'] ?? 0);
            if ($sid <= 0) throw new InvalidArgumentException('site_id required.');
            $db = new DB();
            $r = $db->mySelect("SELECT * FROM service_contract_sites WHERE c0d = :id", [':id' => $sid]);
            $site = $r[0] ?? null;
            if (empty($site)) throw new RuntimeException('Site not found.');
            $cid = (int)$site['contract_id'];
            foreach (['ServiceContracts','ServiceContractSitePocs','ServiceContractPocs',
                      'ServiceContractAssetSpecs','SpareRequests','Callouts'] as $cls) {
                if (!class_exists($cls)) require_once ROOT . '/app/models/' . $cls . '.php';
            }
            $sc = new ServiceContracts();
            $srModel = new SpareRequests();
            $spareReqs = $srModel->listForSite($sid);
            foreach ($spareReqs as &$sr) $sr['items'] = $srModel->itemsFor((int)$sr['c0d']);
            unset($sr);
            echo json_encode([
                'ok'             => true,
                'site'           => $site,
                'site_pocs'      => (new ServiceContractSitePocs())->listFor($sid),
                'project_pocs'   => (new ServiceContractPocs())->listFor($cid),
                'assets'         => $sc->assetsFor($cid, $sid),
                'asset_specs'    => (new ServiceContractAssetSpecs())->listFor($cid),
                'spare_requests' => $spareReqs,
                'callouts'       => (new Callouts())->listForSite($sid),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-z-D · Spare-part requests ─────────────────────────── */

    public function spareRequestSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->save($_POST, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function spareRequestRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['request_id'] ?? 0);
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->remove($rid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function spareRequestItemSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['request_id'] ?? 0);
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->itemSave($rid, $_POST));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function spareRequestItemRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $iid = (int)($_POST['item_id'] ?? 0);
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->itemRemove($iid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Submit request → starts the SPARE_REQUEST_APPROVAL chain. */
    public function spareRequestSubmitAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['request_id'] ?? 0);
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->submitForApproval(
                $rid,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            ));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** PC records POC confirmation (step 1 of the chain). */
    public function spareRequestPocConfirmAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['request_id'] ?? 0);
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->recordPocConfirmation(
                $rid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            ));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Procurement decides FROM_STOCK / PROCURE / EMERGENCY_CASH (terminal step). */
    public function spareRequestDecideAjAction()
    {
        header('Content-Type: application/json');
        try {
            $rid = (int)($_POST['request_id'] ?? 0);
            $dec = (string)($_POST['decision'] ?? '');
            $nt  = (string)($_POST['notes']    ?? '');
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode((new SpareRequests())->recordProcurementDecision(
                $rid, $dec, $nt,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            ));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-z-E · Raise callout from the site page ────────────── */
    public function siteCalloutRaiseAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['site_id'] ?? 0);
            if ($sid <= 0) throw new InvalidArgumentException('site_id required.');
            $db = new DB();
            $r = $db->mySelect("SELECT contract_id FROM service_contract_sites WHERE c0d = :id", [':id' => $sid]);
            if (empty($r)) throw new RuntimeException('Site not found.');
            $cid = (int)$r[0]['contract_id'];

            if (!class_exists('Callouts')) require_once ROOT . '/app/models/Callouts.php';
            $payload = [
                'contract_id'      => $cid,
                'site_id'          => $sid,
                'category_id'      => (int)($_POST['category_id'] ?? 0),
                'asset_id'         => !empty($_POST['asset_id']) ? (int)$_POST['asset_id'] : null,
                'reported_by_name' => (string)($_POST['reported_by_name'] ?? ''),
                'reported_by_phone'=> (string)($_POST['reported_by_phone'] ?? ''),
                'reported_at'      => (string)($_POST['reported_at'] ?? ''),
                'description'      => (string)($_POST['description'] ?? ''),
                'is_emergency'     => !empty($_POST['is_emergency']) ? 1 : 0,
            ];
            $newId = (new Callouts())->saveIntake($payload, (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'id' => $newId]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Contract-wide list, mostly for a future queue page. */
    public function spareRequestListAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? $_GET['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('contract_id required.');
            if (!class_exists('SpareRequests')) require_once ROOT . '/app/models/SpareRequests.php';
            echo json_encode(['ok' => true, 'rows' => (new SpareRequests())->listForContract($cid)]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Project-level POC upsert. */
    public function projectPocSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if (!class_exists('ServiceContractPocs')) require_once ROOT . '/app/models/ServiceContractPocs.php';
            echo json_encode((new ServiceContractPocs())->save($cid, $_POST, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function projectPocRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $pid = (int)($_POST['poc_id'] ?? 0);
            if (!class_exists('ServiceContractPocs')) require_once ROOT . '/app/models/ServiceContractPocs.php';
            echo json_encode((new ServiceContractPocs())->remove($pid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-z-B · Equipment blueprints + import ─────────────── */

    /** Blueprint upsert. */
    public function assetSpecSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
            echo json_encode((new ServiceContractAssetSpecs())->save($cid, $_POST, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function assetSpecRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['spec_id'] ?? 0);
            if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
            echo json_encode((new ServiceContractAssetSpecs())->remove($sid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Bulk-apply a blueprint to selected sites. */
    public function assetSpecApplyAjAction()
    {
        header('Content-Type: application/json');
        try {
            $specId  = (int)($_POST['spec_id'] ?? 0);
            $qty     = (int)($_POST['qty_per_site'] ?? 1);
            $siteIds = $_POST['site_ids'] ?? [];
            if (is_string($siteIds)) {
                $decoded = json_decode($siteIds, true);
                $siteIds = is_array($decoded) ? $decoded : array_filter(explode(',', $siteIds));
            }
            $siteIds = array_map('intval', (array)$siteIds);
            if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
            echo json_encode((new ServiceContractAssetSpecs())->applyToSites($specId, $siteIds, $qty, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Bulk Excel import of per-unit equipment. Rows parsed client-side by SheetJS,
     * shipped as rows_json. Match keys:
     *   · site_code           → resolves to site_id (row skipped if not found)
     *   · spec_code           → optional; inherits blueprint defaults, overridden per column
     *   · asset_type_code     → alt to asset_type_id when no spec_code
     *   · asset_no + serial_no + make + model + size_value/unit + install_date + warranty_end
     */
    public function assetsImportAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('contract_id required.');
            $rowsRaw = $_POST['rows_json'] ?? '';
            $rows = is_string($rowsRaw) ? (json_decode($rowsRaw, true) ?: []) : (is_array($rowsRaw) ? $rowsRaw : []);
            if (empty($rows)) throw new InvalidArgumentException('No rows to import.');

            if (!class_exists('ServiceContracts'))          require_once ROOT . '/app/models/ServiceContracts.php';
            if (!class_exists('ServiceContractAssetSpecs')) require_once ROOT . '/app/models/ServiceContractAssetSpecs.php';
            $sc = new ServiceContracts();
            $db = new DB();

            // Build lookups: site_code → site_id, spec_code → spec, asset_type_code → asset_type_id.
            $siteMap = [];
            foreach ($db->mySelect("SELECT c0d, site_code FROM service_contract_sites WHERE contract_id = :c AND is_active = 1", [':c' => $cid]) as $s) {
                if (!empty($s['site_code'])) $siteMap[strtoupper((string)$s['site_code'])] = (int)$s['c0d'];
            }
            $specMap = [];
            foreach ($db->mySelect("SELECT * FROM service_contract_asset_specs WHERE contract_id = :c AND deleted = 0", [':c' => $cid]) as $sp) {
                $specMap[strtoupper((string)$sp['spec_code'])] = $sp;
            }
            $typeMap = [];
            foreach ($db->mySelect("SELECT c0d, code FROM asset_types WHERE is_active = 1", []) as $t) {
                $typeMap[strtoupper((string)$t['code'])] = (int)$t['c0d'];
            }

            $added = 0; $skipped = 0; $errors = [];
            foreach ($rows as $i => $r) {
                $siteCode = strtoupper(trim((string)($r['site_code'] ?? '')));
                if ($siteCode === '' || !isset($siteMap[$siteCode])) {
                    $skipped++; $errors[] = 'Row ' . ($i + 1) . ': unknown site_code "' . $siteCode . '"';
                    continue;
                }
                $siteId = $siteMap[$siteCode];

                $spec = null;
                $specCode = strtoupper(trim((string)($r['spec_code'] ?? '')));
                if ($specCode !== '' && isset($specMap[$specCode])) $spec = $specMap[$specCode];

                // Determine asset_type_id: spec > asset_type_code > asset_type_id column
                $typeId = 0;
                if ($spec) $typeId = (int)$spec['asset_type_id'];
                elseif (!empty($r['asset_type_code'])) $typeId = $typeMap[strtoupper((string)$r['asset_type_code'])] ?? 0;
                elseif (!empty($r['asset_type_id'])) $typeId = (int)$r['asset_type_id'];
                if ($typeId <= 0) {
                    $skipped++; $errors[] = 'Row ' . ($i + 1) . ': no asset_type resolvable (need spec_code, asset_type_code, or asset_type_id)';
                    continue;
                }

                $payload = [
                    'contract_id'   => $cid,
                    'site_id'       => $siteId,
                    'asset_type_id' => $typeId,
                    'asset_no'      => (string)($r['asset_no']    ?? ''),
                    'serial_no'     => (string)($r['serial_no']   ?? ''),
                    'make'          => (string)($r['make']        ?? ($spec['make']  ?? '')),
                    'model'         => (string)($r['model']       ?? ($spec['model'] ?? '')),
                    'size_value'    => $r['size_value'] ?? ($spec['size_value'] ?? ''),
                    'size_unit'     => (string)($r['size_unit']   ?? ($spec['size_unit'] ?? '')),
                    'install_date'  => (string)($r['install_date']?? ''),
                    'warranty_end'  => (string)($r['warranty_end']?? ''),
                    'hours_run'     => $r['hours_run'] ?? ($spec['default_hours_run_seed'] ?? ''),
                    'meta_json'     => (string)($r['meta_json']   ?? ($spec['meta_defaults_json'] ?? '')),
                    'is_active'     => 1,
                ];
                $newId = (int)$sc->saveAsset($payload);
                if ($newId > 0 && $spec) {
                    // Stamp spec_id after insert (saveAsset doesn't know about it).
                    $db->insertUpdate("UPDATE service_contract_assets SET spec_id = :sp WHERE c0d = :id", [':sp' => (int)$spec['c0d'], ':id' => $newId]);
                }
                if ($newId > 0) $added++;
                else { $skipped++; $errors[] = 'Row ' . ($i + 1) . ': insert failed'; }
            }
            echo json_encode(['ok' => true, 'added' => $added, 'skipped' => $skipped, 'errors' => $errors]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** POC upsert. */
    public function sitePocSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid = (int)($_POST['site_id'] ?? 0);
            if (!class_exists('ServiceContractSitePocs')) require_once ROOT . '/app/models/ServiceContractSitePocs.php';
            echo json_encode((new ServiceContractSitePocs())->save($sid, $_POST, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function sitePocRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $pid = (int)($_POST['poc_id'] ?? 0);
            if (!class_exists('ServiceContractSitePocs')) require_once ROOT . '/app/models/ServiceContractSitePocs.php';
            echo json_encode((new ServiceContractSitePocs())->remove($pid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** PM/PC stamps POC details as verified for one site (or un-stamps). */
    public function sitePocConfirmAjAction()
    {
        header('Content-Type: application/json');
        try {
            $sid    = (int)($_POST['site_id'] ?? 0);
            $unset  = !empty($_POST['unconfirm']);
            if (!class_exists('ServiceContractSitePocs')) require_once ROOT . '/app/models/ServiceContractSitePocs.php';
            $m = new ServiceContractSitePocs();
            echo json_encode($unset
                ? $m->unconfirmSite($sid)
                : $m->confirmSite($sid, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Bulk Excel import — payload is a JSON array of site rows already parsed
     * client-side by SheetJS. Server just validates + upserts. Idempotent by
     * (contract_id + site_code); rows without a site_code are always INSERTed.
     */
    public function sitesImportAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('contract_id required.');
            $rowsRaw = $_POST['rows_json'] ?? '';
            $rows = is_string($rowsRaw) ? (json_decode($rowsRaw, true) ?: []) : (is_array($rowsRaw) ? $rowsRaw : []);
            if (empty($rows)) throw new InvalidArgumentException('No rows to import.');

            if (!class_exists('ServiceContracts'))        require_once ROOT . '/app/models/ServiceContracts.php';
            if (!class_exists('ServiceContractSitePocs')) require_once ROOT . '/app/models/ServiceContractSitePocs.php';
            $sc  = new ServiceContracts();
            $pm  = new ServiceContractSitePocs();
            $db  = new DB();
            $uid = (int)($_SESSION['user_id'] ?? 0);

            $existing = $db->mySelect(
                "SELECT c0d, site_code FROM service_contract_sites WHERE contract_id = :c",
                [':c' => $cid]
            ) ?: [];
            $byCode = [];
            foreach ($existing as $e) if (!empty($e['site_code'])) $byCode[(string)$e['site_code']] = (int)$e['c0d'];

            $added = 0; $updated = 0; $skipped = 0; $errors = [];
            foreach ($rows as $i => $r) {
                $name = trim((string)($r['name'] ?? $r['site_name'] ?? ''));
                if ($name === '') { $skipped++; $errors[] = "Row " . ($i + 1) . ": missing name"; continue; }
                $code = trim((string)($r['site_code'] ?? $r['code'] ?? ''));

                $payload = [
                    'contract_id'        => $cid,
                    'site_code'          => $code,
                    'name'               => $name,
                    'physical_address'   => (string)($r['physical_address'] ?? $r['address'] ?? ''),
                    'region'             => (string)($r['region'] ?? ''),
                    'district'           => (string)($r['district'] ?? ''),
                    'gps_lat'            => (string)($r['gps_lat'] ?? ''),
                    'gps_lng'            => (string)($r['gps_lng'] ?? ''),
                    'site_contact_name'  => (string)($r['primary_poc_name']  ?? $r['site_contact_name']  ?? ''),
                    'site_contact_phone' => (string)($r['primary_poc_phone'] ?? $r['site_contact_phone'] ?? ''),
                    'access_notes'       => (string)($r['access_notes'] ?? $r['notes'] ?? ''),
                    'is_active'          => 1,
                ];
                if ($code !== '' && isset($byCode[$code])) {
                    $payload['c0d'] = $byCode[$code];
                    $sc->saveSite($payload);
                    $updated++;
                    $siteId = $byCode[$code];
                } else {
                    $siteId = (int)$sc->saveSite($payload);
                    $added++;
                }
                // If a primary POC name came in, upsert as a POC row (idempotent by name).
                $pocName = trim((string)($r['primary_poc_name'] ?? ''));
                if ($pocName !== '' && $siteId > 0) {
                    $pm->save($siteId, [
                        'poc_role'   => 'SITE_MANAGER',
                        'poc_name'   => $pocName,
                        'poc_phone'  => (string)($r['primary_poc_phone'] ?? ''),
                        'poc_email'  => (string)($r['primary_poc_email'] ?? ''),
                        'is_primary' => 1,
                    ], $uid);
                }
            }
            echo json_encode(['ok' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => $errors]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batch C — SLA save + seed-from-spec shortcut. */
    public function svcSlaSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            $id = (new ServiceContracts())->saveSla($_POST);
            echo json_encode(['ok' => true, 'id' => (int)$id]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }
    public function svcSlaSeedFromSpecAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            $tid = (int)($_POST['tender_id']   ?? 0);
            if ($cid <= 0 || $tid <= 0) throw new InvalidArgumentException('Missing contract_id or tender_id.');
            if (!class_exists('TenderServiceSpec')) require_once ROOT . '/app/models/TenderServiceSpec.php';
            if (!class_exists('ServiceContracts'))  require_once ROOT . '/app/models/ServiceContracts.php';
            $spec = (new TenderServiceSpec())->forTender($tid);
            $sc   = new ServiceContracts();
            $seeded = 0;
            foreach (['EMERGENCY' => 'sla_emergency_hrs', 'URGENT' => 'sla_urgent_hrs', 'ROUTINE' => 'sla_routine_hrs'] as $sev => $field) {
                if (isset($spec[$field]) && $spec[$field] !== null && $spec[$field] !== '') {
                    $sc->saveSla([
                        'contract_id'         => $cid,
                        'severity'            => $sev,
                        'response_target_hrs' => (int)$spec[$field],
                        'resolve_target_hrs'  => (int)$spec[$field] * 2,
                    ]);
                    $seeded++;
                }
            }
            echo json_encode(['ok' => true, 'seeded' => $seeded]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Slice 8Z-y · Services Scope Configuration endpoints ──────── */

    /** Y-A · PM confirms sites target + equipment mix from the bid. */
    public function saveScopeConfirmAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('Missing contract_id.');
            if (!class_exists('ServiceContracts')) require_once ROOT . '/app/models/ServiceContracts.php';
            (new ServiceContracts())->saveScopeConfirmation($cid, $_POST,
                (int)($_SESSION['user_id'] ?? 0),
                (string)($_SESSION['full_name'] ?? ''));
            echo json_encode(['ok' => true, 'msg' => 'Scope confirmed.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-B · Template binding upsert. */
    public function bindingSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if (!class_exists('ServiceContractTemplateBindings')) require_once ROOT . '/app/models/ServiceContractTemplateBindings.php';
            $res = (new ServiceContractTemplateBindings())->save($cid, $_POST, (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-B · Template binding remove. */
    public function bindingRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['binding_id'] ?? 0);
            if (!class_exists('ServiceContractTemplateBindings')) require_once ROOT . '/app/models/ServiceContractTemplateBindings.php';
            echo json_encode((new ServiceContractTemplateBindings())->remove($id));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-B · Auto-suggest + bulk-add templates for one equipment class. */
    public function bindingSuggestDefaultsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid   = (int)($_POST['contract_id']     ?? 0);
            $class = (string)($_POST['equipment_class'] ?? '');
            $qty   = isset($_POST['equipment_qty']) && $_POST['equipment_qty'] !== '' ? (int)$_POST['equipment_qty'] : null;
            if ($cid <= 0 || $class === '') throw new InvalidArgumentException('contract_id + equipment_class required.');
            if (!class_exists('ServiceContractTemplateBindings')) require_once ROOT . '/app/models/ServiceContractTemplateBindings.php';
            echo json_encode((new ServiceContractTemplateBindings())->suggestDefaults($cid, $class, $qty, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-C · Pack header upsert. */
    public function packSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? 0);
            if (!class_exists('ServiceContractPacks')) require_once ROOT . '/app/models/ServiceContractPacks.php';
            echo json_encode((new ServiceContractPacks())->save($cid, $_POST, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-C · Pack soft-delete. */
    public function packRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $id = (int)($_POST['pack_id'] ?? 0);
            if (!class_exists('ServiceContractPacks')) require_once ROOT . '/app/models/ServiceContractPacks.php';
            echo json_encode((new ServiceContractPacks())->remove($id));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-C · Pack line-item upsert. */
    public function packItemSaveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $pid = (int)($_POST['pack_id'] ?? 0);
            if (!class_exists('ServiceContractPacks')) require_once ROOT . '/app/models/ServiceContractPacks.php';
            echo json_encode((new ServiceContractPacks())->itemSave($pid, $_POST));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-C · Pack line-item soft-delete. */
    public function packItemRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $iid = (int)($_POST['item_id'] ?? 0);
            if (!class_exists('ServiceContractPacks')) require_once ROOT . '/app/models/ServiceContractPacks.php';
            echo json_encode((new ServiceContractPacks())->itemRemove($iid));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Y-y · Full snapshot for the Vue root's refresh() — contract, bindings, packs. */
    public function svcScopeSnapshotAjAction()
    {
        header('Content-Type: application/json');
        try {
            $cid = (int)($_POST['contract_id'] ?? $_GET['contract_id'] ?? 0);
            if ($cid <= 0) throw new InvalidArgumentException('contract_id required.');
            if (!class_exists('ServiceContracts'))                 require_once ROOT . '/app/models/ServiceContracts.php';
            if (!class_exists('ServiceContractTemplateBindings'))  require_once ROOT . '/app/models/ServiceContractTemplateBindings.php';
            if (!class_exists('ServiceContractPacks'))             require_once ROOT . '/app/models/ServiceContractPacks.php';
            $contract = (new ServiceContracts())->find($cid) ?: [];
            echo json_encode([
                'ok'       => true,
                'contract' => $contract,
                'bindings' => (new ServiceContractTemplateBindings())->listFor($cid),
                'packs'    => (new ServiceContractPacks())->listFor($cid),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z-x — Fetch full item schema for one catalog template (for the details modal). */
    public function templateItemsAjAction()
    {
        header('Content-Type: application/json');
        try {
            $tid = (int)($_POST['template_id'] ?? $_GET['template_id'] ?? 0);
            if ($tid <= 0) throw new InvalidArgumentException('Missing template_id.');
            if (!class_exists('ServiceTemplatesCatalog')) require_once ROOT . '/app/models/ServiceTemplatesCatalog.php';
            $stc = new ServiceTemplatesCatalog();
            $db  = new DB();
            $tplRs = $db->mySelect("SELECT * FROM service_templates_catalog WHERE c0d = :id", [':id' => $tid]);
            if (empty($tplRs)) throw new RuntimeException('Template not found.');
            echo json_encode([
                'ok'       => true,
                'template' => $tplRs[0],
                'items'    => $stc->itemsFor($tid),
            ]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Batch G — Operational documents CRUD + download/preview. */
    public function opsDocUploadAjAction()
    {
        header('Content-Type: application/json');
        try {
            $pid = (int)($_POST['project_id'] ?? 0);
            if ($pid <= 0) throw new InvalidArgumentException('Missing project_id.');
            if (empty($_FILES['ops_file'])) throw new InvalidArgumentException('No file received.');
            if (!class_exists('ProjectOpsDocuments')) require_once ROOT . '/app/models/ProjectOpsDocuments.php';
            $res = (new ProjectOpsDocuments())->upload($pid, $_FILES['ops_file'], [
                'subtype'        => (string)($_POST['subtype']        ?? 'OTHER'),
                'label'          => (string)($_POST['label']          ?? ''),
                'validity_until' => (string)($_POST['validity_until'] ?? ''),
                'notes'          => (string)($_POST['notes']          ?? ''),
            ], (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }
    public function opsDocRemoveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $did = (int)($_POST['doc_id'] ?? 0);
            if ($did <= 0) throw new InvalidArgumentException('Missing doc_id.');
            if (!class_exists('ProjectOpsDocuments')) require_once ROOT . '/app/models/ProjectOpsDocuments.php';
            echo json_encode((new ProjectOpsDocuments())->remove($did, (int)($_SESSION['user_id'] ?? 0)));
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }
    public function opsDocDownloadAction($docId = 0) { $this->serveOpsDoc((int)$docId, false); }
    public function opsDocPreviewAction($docId = 0)  { $this->serveOpsDoc((int)$docId, true);  }
    private function serveOpsDoc(int $docId, bool $inline): void
    {
        if (!class_exists('ProjectOpsDocuments')) require_once ROOT . '/app/models/ProjectOpsDocuments.php';
        $m = new ProjectOpsDocuments();
        $row = $m->find($docId);
        if (!$row) { header('HTTP/1.1 404 Not Found'); echo 'Document not found'; return; }
        $abs = $m->absolutePath($row);
        if (!$abs) { header('HTTP/1.1 410 Gone'); echo 'File missing on disk'; return; }
        header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . addslashes((string)$row['original_filename']) . '"');
        header('Content-Length: ' . filesize($abs));
        header('Cache-Control: private, no-cache');
        readfile($abs);
    }

    /**
     * Slice 8Y · Project handover pack export — Excel.
     *   /Projects/xlsx/{id}
     * Sheet columns: source-tender identity, contract snapshot, scope items, pricing lines, overheads, team, PM briefing.
     */
    public function xlsxAction($id = 0)
    {
        $id = (int)$id;
        if (!class_exists('ExportHelper')) require_once ROOT . '/app/helpers/ExportHelper.php';
        [$title, $sub, $columns, $rows, $totals] = $this->buildExportPayload($id);
        if ($title === null) { header('HTTP/1.1 404 Not Found'); echo 'Project not found'; return; }
        ExportHelper::xlsxDownload('project_handover_' . $id . '.xlsx', 'Handover', $columns, $rows, $totals, ['title' => $title, 'subtitle' => $sub]);
    }

    /**
     * Slice 8Y · Project handover pack export — PDF (iframe-friendly inline; ?disposition=attachment forces download).
     *   /Projects/pdf/{id}
     */
    public function pdfAction($id = 0)
    {
        $id = (int)$id;
        if (!class_exists('ExportHelper')) require_once ROOT . '/app/helpers/ExportHelper.php';
        [$title, $sub, $columns, $rows, $totals] = $this->buildExportPayload($id);
        if ($title === null) { header('HTTP/1.1 404 Not Found'); echo 'Project not found'; return; }

        $wantDownload = strtolower((string)($_GET['disposition'] ?? '')) === 'attachment';
        if ($wantDownload) ob_start();
        ExportHelper::pdfDownload('project_handover_' . $id . '.pdf', $title, $sub, $columns, $rows, $totals, 'L');
        if ($wantDownload) {
            $body = ob_get_clean();
            if (!headers_sent()) header_remove('Content-Disposition');
            header('Content-Disposition: attachment; filename="project_handover_' . $id . '.pdf"');
            echo $body;
        }
    }

    /**
     * Assemble the wide-column payload shared by xlsx + pdf exports.
     * One flat "handover pack" row-set: identity → contract → scope items → pricing lines → overheads → team → briefing.
     * Section markers use a sentinel first-column value; ExportHelper renders them plainly.
     */
    private function buildExportPayload(int $id): array
    {
        $m = new Projects();
        $p = $m->byId($id);
        if (!$p) return [null, null, [], [], []];

        $src = [];
        if (in_array($p['origin'], ['TENDER_BDM','TENDER_PUBLIC'], true)) {
            $src = (new Tenders())->sourceTenderContextForProject($id);
        }
        $srcT      = $src['tender']            ?? [];
        $srcTerms  = $src['terms']             ?? null;
        $items     = $src['items']             ?? [];
        $pricing   = $src['pricing']           ?? null;
        $lines     = $src['pricing_lines']     ?? [];
        $overheads = $src['pricing_overheads'] ?? [];

        // Wide table: 8 columns, mixing labels + values across sections.
        $columns = [
            ['label' => '#',            'width' => 6,  'align' => 'R'],
            ['label' => 'Section',      'width' => 24, 'align' => 'L'],
            ['label' => 'Description',  'width' => 46, 'align' => 'L'],
            ['label' => 'Qty',          'width' => 10, 'align' => 'R', 'type' => 'money'],
            ['label' => 'UOM',          'width' => 8,  'align' => 'L'],
            ['label' => 'Unit cost',    'width' => 14, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Unit sell',    'width' => 14, 'align' => 'R', 'type' => 'money'],
            ['label' => 'Line total',   'width' => 18, 'align' => 'R', 'type' => 'money'],
        ];

        $rows = [];
        $sec  = fn(string $label) => ['', '── ' . $label . ' ──', '', null, '', null, null, null];

        // Identity + contract
        $rows[] = $sec('Project');
        $rows[] = ['', 'Project no',       (string)$p['project_no'],                    null, '', null, null, null];
        $rows[] = ['', 'Project name',     (string)$p['project_name'],                  null, '', null, null, null];
        $rows[] = ['', 'Customer',         (string)$p['project_customer'],              null, '', null, null, null];
        $rows[] = ['', 'Type',             (string)$p['project_type'],                  null, '', null, null, null];
        $rows[] = ['', 'Origin',           (string)$p['origin'],                        null, '', null, null, null];
        $rows[] = ['', 'Currency',         (string)$p['currency'],                      null, '', null, null, null];
        $rows[] = ['', 'Contract value',   '',                                           null, '', null, null, (float)$p['contract_value']];

        if (!empty($srcT)) {
            $rows[] = $sec('Source tender');
            $rows[] = ['', 'Tender no',        (string)($srcT['tender_no']    ?? ''),    null, '', null, null, null];
            $rows[] = ['', 'Tender title',     (string)($srcT['tender_title'] ?? ''),    null, '', null, null, null];
            $rows[] = ['', 'Buyer',            (string)($srcT['buyer_name']   ?? ''),    null, '', null, null, null];
            $rows[] = ['', 'Buyer estimate',   'pre-bid',                                null, '', null, null, (float)($srcT['estimated_value']  ?? 0)];
            $rows[] = ['', 'Our bid',          'submitted',                              null, '', null, null, (float)($srcT['submission_amount']?? 0)];
        }
        if (!empty($srcTerms)) {
            $rows[] = $sec('Contract terms');
            $rows[] = ['', 'Contract no',      (string)($srcTerms['contract_no']    ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Contract date',    (string)($srcTerms['contract_date']  ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Payment basis',    (string)($srcTerms['payment_basis']  ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Payment terms',    (string)($srcTerms['payment_terms']  ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Delivery (days)',  '',                                            (float)($srcTerms['delivery_timeline_days'] ?? 0), '', null, null, null];
            $rows[] = ['', 'Advance %',        '',                                            (float)($srcTerms['advance_pct']   ?? 0), '', null, null, null];
            $rows[] = ['', 'Retention %',      '',                                            (float)($srcTerms['retention_pct'] ?? 0), '', null, null, null];
            $rows[] = ['', 'WHT %',            '',                                            (float)($srcTerms['wht_pct']       ?? 0), '', null, null, null];
        }

        // Scope items
        if (!empty($items)) {
            $rows[] = $sec('Tender scope · ' . count($items) . ' line(s)');
            foreach ($items as $it) {
                $rows[] = [
                    (int)$it['line_no'],
                    (string)($it['item_ref'] ?? ''),
                    (string)$it['description'] . (!empty($it['spec_notes']) ? ' — ' . $it['spec_notes'] : ''),
                    (float)$it['qty'],
                    (string)($it['uom'] ?? ''),
                    null, null, null,
                ];
            }
        }

        // Pricing lines
        if (!empty($lines)) {
            $rows[] = $sec('Tender pricing · ' . count($lines) . ' line(s)');
            $totCost = 0; $totSell = 0;
            foreach ($lines as $pl) {
                $totCost += (float)$pl['line_cost']; $totSell += (float)$pl['line_price'];
                $rows[] = [
                    '',
                    'line',
                    (string)$pl['description'],
                    (float)$pl['qty'],
                    (string)($pl['uom'] ?? ''),
                    (float)$pl['unit_cost'],
                    (float)$pl['unit_price'],
                    (float)$pl['line_price'],
                ];
            }
        }

        // Overheads
        if (!empty($overheads)) {
            $rows[] = $sec('Overheads · ' . count($overheads) . ' line(s)');
            foreach ($overheads as $oh) {
                $rows[] = [
                    '',
                    (string)$oh['category'],
                    (string)$oh['label'] . (!empty($oh['notes']) ? ' — ' . $oh['notes'] : ''),
                    null, '',
                    null, null, (float)$oh['amount'],
                ];
            }
        }

        // Pricing summary
        if ($pricing !== null) {
            $rows[] = $sec('Pricing summary');
            $rows[] = ['', 'Pricing mode',      (string)($pricing['pricing_mode']     ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Procurement mode',  (string)($pricing['procurement_mode'] ?? ''),  null, '', null, null, null];
            $rows[] = ['', 'Sum cost',          '',                                             null, '', null, null, (float)($pricing['sum_cost']     ?? 0)];
            $rows[] = ['', 'Sum overhead',      '',                                             null, '', null, null, (float)($pricing['sum_overhead'] ?? 0)];
            $rows[] = ['', 'Sum margin',        '',                                             null, '', null, null, (float)($pricing['sum_margin']   ?? 0)];
            $rows[] = ['', 'Sum sell price',    '',                                             null, '', null, null, (float)($pricing['sum_price']    ?? 0)];
            $rows[] = ['', 'Margin %',          '',                                             (float)($pricing['margin_pct'] ?? 0), '', null, null, null];
        }

        // Team
        $rows[] = $sec('Assigned team');
        $rows[] = ['', 'Project Manager',      (string)($p['project_manager_name'] ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Projects Coordinator', (string)($p['pc_name']              ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Accountant',           (string)($p['accountant_name']      ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Procurement',          (string)($p['procurement_name']     ?? '—'), null, '', null, null, null];

        // PM briefing
        $rows[] = $sec('PM briefing');
        $rows[] = ['', 'Reviewed at',          (string)($p['pm_confirmed_at']         ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Readiness notes',      (string)($p['pm_confirmation_notes']   ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Notes to PC',          (string)($p['pm_notes_pc']             ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Notes to Procurement', (string)($p['pm_notes_procurement']    ?? '—'), null, '', null, null, null];
        $rows[] = ['', 'Suggested extras',     (string)($p['pm_suggested_items']      ?? '—'), null, '', null, null, null];

        $title = 'Project handover · ' . (string)$p['project_no'];
        $sub   = (string)$p['project_name'] . ' · customer: ' . (string)$p['project_customer'] . ' · generated ' . date('Y-m-d H:i');
        $totals = [null, null, 'Handover pack', null, null, null, null, null];
        return [$title, $sub, $columns, $rows, $totals];
    }

    /**
     * Slice 8Y · PM assigns the Projects Coordinator on the project.
     * POST: project_id, new_pc_id, reason (optional), start_date (optional), end_date (optional).
     */
    public function assignPcAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Projects())->assignPc(
                (int)($_POST['project_id'] ?? 0),
                (int)($_POST['new_pc_id']   ?? 0),
                (string)($_POST['reason']   ?? ''),
                $this->currentUserId(),
                (string)($_SESSION['full_name']       ?? ''),
                (string)($_SESSION['user_group_name'] ?? ''),
                !empty($_POST['start_date']) ? (string)$_POST['start_date'] : null,
                !empty($_POST['end_date'])   ? (string)$_POST['end_date']   : null
            );
            echo json_encode(['ok' => true, 'msg' => 'PC assigned + project window saved.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── 8Y · Client PO documents ────────────────────────────────────────── */

    public function documentUploadAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectDocuments')) require_once ROOT . '/app/models/ProjectDocuments.php';
            $id = (new ProjectDocuments())->upload(
                (int)($_POST['project_id'] ?? 0),
                (string)($_POST['doc_kind'] ?? 'CLIENT_PO'),
                (array)($_FILES['file'] ?? []),
                (string)($_POST['notes'] ?? ''),
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'id' => $id, 'msg' => 'Uploaded.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function documentDeleteAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectDocuments')) require_once ROOT . '/app/models/ProjectDocuments.php';
            (new ProjectDocuments())->softDelete(
                (int)($_POST['doc_id'] ?? 0),
                $this->currentUserId(),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Removed.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function documentDownloadAction($id = 0)
    {
        if (!class_exists('ProjectDocuments')) require_once ROOT . '/app/models/ProjectDocuments.php';
        $d = (new ProjectDocuments())->find((int)$id);
        if (!$d) { header('HTTP/1.1 404 Not Found'); echo 'Document not found'; return; }
        $abs = ROOT . '/' . ltrim((string)$d['file_path'], '/');
        if (!is_file($abs)) { header('HTTP/1.1 404 Not Found'); echo 'File missing'; return; }
        // Default: inline (iframe preview). ?disposition=attachment → download.
        $disp = strtolower((string)($_GET['disposition'] ?? 'inline'));
        if (!in_array($disp, ['inline','attachment'], true)) $disp = 'inline';
        $mime = (string)($d['mime'] ?? 'application/octet-stream');
        header('Content-Type: ' . $mime);
        header('Content-Length: ' . filesize($abs));
        header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode((string)$d['original_name']) . '"');
        readfile($abs);
    }

    /* ── 8Y · Timelines / milestones ─────────────────────────────────────── */

    public function milestoneAddAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectMilestones')) require_once ROOT . '/app/models/ProjectMilestones.php';
            $id = (new ProjectMilestones())->add(
                (int)($_POST['project_id'] ?? 0),
                [
                    'title'         => (string)($_POST['title']         ?? ''),
                    'description'   => (string)($_POST['description']   ?? ''),
                    'target_date'   => (string)($_POST['target_date']   ?? ''),
                    'owner_slot'    => (string)($_POST['owner_slot']    ?? ''),
                    'owner_user_id' => (int)   ($_POST['owner_user_id'] ?? 0),
                    'owner_user_name' => (string)($_POST['owner_user_name'] ?? ''),
                    'status'        => (string)($_POST['status']        ?? 'NOT_STARTED'),
                ],
                $this->currentUserId(),
                (string)($_SESSION['full_name']       ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'id' => $id, 'msg' => 'Milestone added.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function milestoneUpdateAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectMilestones')) require_once ROOT . '/app/models/ProjectMilestones.php';
            (new ProjectMilestones())->update(
                (int)($_POST['id'] ?? 0),
                [
                    'title'         => (string)($_POST['title']         ?? ''),
                    'description'   => (string)($_POST['description']   ?? ''),
                    'target_date'   => (string)($_POST['target_date']   ?? ''),
                    'owner_slot'    => (string)($_POST['owner_slot']    ?? ''),
                    'owner_user_id' => (int)   ($_POST['owner_user_id'] ?? 0),
                    'owner_user_name' => (string)($_POST['owner_user_name'] ?? ''),
                    'status'        => (string)($_POST['status']        ?? ''),
                ],
                $this->currentUserId(),
                (string)($_SESSION['full_name']       ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Updated.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function milestoneDeleteAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectMilestones')) require_once ROOT . '/app/models/ProjectMilestones.php';
            (new ProjectMilestones())->softDelete(
                (int)($_POST['id'] ?? 0),
                $this->currentUserId(),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'Removed.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── 8Y · Client PO item authoring — PC-owned ─────────────────────────── */

    /**
     * One-click: clone every tender_items row into a fresh DRAFT Client PO on this project.
     * Idempotent by description — safe to re-run.
     */
    public function goodsConvertScopeAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $go   = new GoodsOnboarding();
            $pid  = (int)($_POST['project_id'] ?? 0);
            $tid  = (int)($_POST['tender_id']  ?? 0);
            $poR  = $go->getOrCreateDraftClientPo($pid, $this->currentUserId(), (string)($_SESSION['full_name'] ?? ''));
            if (!$poR['ok']) { echo json_encode($poR); return; }
            $res = $go->cloneItemsFromTender((int)$poR['po_id'], $tid, $this->currentUserId());
            echo json_encode($res + ['po_id' => (int)$poR['po_id']]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * XLSX import. Client-side (SheetJS) parses the file + POSTs a JSON array of rows.
     * Server-side we just validate + insert via bulkInsertItems.
     * POST: project_id, rows (JSON array).
     */
    public function goodsImportItemsAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $go   = new GoodsOnboarding();
            $pid  = (int)($_POST['project_id'] ?? 0);
            $poR  = $go->getOrCreateDraftClientPo($pid, $this->currentUserId(), (string)($_SESSION['full_name'] ?? ''));
            if (!$poR['ok']) { echo json_encode($poR); return; }
            $rowsRaw = (string)($_POST['rows'] ?? '[]');
            $rows = json_decode($rowsRaw, true);
            if (!is_array($rows)) throw new InvalidArgumentException('Invalid rows payload.');
            $res = $go->bulkInsertItems((int)$poR['po_id'], $rows, $this->currentUserId());
            echo json_encode($res + ['po_id' => (int)$poR['po_id']]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Slice 8Z · PC updates per-line detail (brand, alt, priority, deliver dates, remarks, sourcing ceiling).
     */
    public function goodsUpdateItemAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->updateItemDetails(
                (int)($_POST['po_id']   ?? 0),
                (int)($_POST['item_id'] ?? 0),
                [
                    'brand'            => (string)($_POST['brand']            ?? ''),
                    'priority'         => (string)($_POST['priority']         ?? 'MED'),
                    'alt_ok'           => (int)   ($_POST['alt_ok']           ?? 0),
                    'alt_note'         => (string)($_POST['alt_note']         ?? ''),
                    'deliver_by'       => (string)($_POST['deliver_by']       ?? ''),
                    'deliver_to'       => (string)($_POST['deliver_to']       ?? ''),
                    'scrub_remarks'    => (string)($_POST['scrub_remarks']    ?? ''),
                    'sourcing_ceiling' => (string)($_POST['sourcing_ceiling'] ?? ''),
                ]
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z · PC marks item CONFIRMED. */
    public function goodsConfirmItemAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->confirmItem(
                (int)($_POST['po_id']   ?? 0),
                (int)($_POST['item_id'] ?? 0),
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z · PC reverts item to PENDING. */
    public function goodsUnconfirmItemAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->unconfirmItem(
                (int)($_POST['po_id']   ?? 0),
                (int)($_POST['item_id'] ?? 0)
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z-a · PC saves the PO's delivery block (mode, address, contact, incoterm, deadline, instructions). */
    public function savePoDeliveryAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->savePoDelivery(
                (int)($_POST['po_id'] ?? 0),
                [
                    'delivery_mode'                 => (string)($_POST['delivery_mode']                 ?? 'SINGLE_LOCATION'),
                    'primary_delivery_location'     => (string)($_POST['primary_delivery_location']     ?? ''),
                    'delivery_contact_name'         => (string)($_POST['delivery_contact_name']         ?? ''),
                    'delivery_contact_phone'        => (string)($_POST['delivery_contact_phone']        ?? ''),
                    'incoterm'                      => (string)($_POST['incoterm']                      ?? ''),
                    'delivery_deadline'             => (string)($_POST['delivery_deadline']             ?? ''),
                    'delivery_special_instructions' => (string)($_POST['delivery_special_instructions'] ?? ''),
                ]
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z-a · Overheads carry-over CRUD. */
    public function overheadsSeedFromTenderAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectOverheads')) require_once ROOT . '/app/models/ProjectOverheads.php';
            $res = (new ProjectOverheads())->seedFromTender(
                (int)($_POST['project_id'] ?? 0),
                !empty($_POST['po_id']) ? (int)$_POST['po_id'] : null,
                (int)($_POST['tender_id']  ?? 0),
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function overheadAddAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectOverheads')) require_once ROOT . '/app/models/ProjectOverheads.php';
            $id = (new ProjectOverheads())->add(
                (int)($_POST['project_id'] ?? 0),
                !empty($_POST['po_id']) ? (int)$_POST['po_id'] : null,
                [
                    'category' => (string)($_POST['category'] ?? 'OTHER'),
                    'label'    => (string)($_POST['label']    ?? ''),
                    'amount'   => (float) ($_POST['amount']   ?? 0),
                    'notes'    => (string)($_POST['notes']    ?? ''),
                ],
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'id' => $id]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function overheadUpdateAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectOverheads')) require_once ROOT . '/app/models/ProjectOverheads.php';
            // 8Z-b: `amount` (tender estimate) is frozen; PC/Proc only refine `amount_actual`.
            $payload = [
                'category' => (string)($_POST['category'] ?? ''),
                'label'    => (string)($_POST['label']    ?? ''),
                'notes'    => (string)($_POST['notes']    ?? ''),
                'active'   => array_key_exists('active', $_POST) ? (int)$_POST['active'] : null,
            ];
            if (array_key_exists('amount_actual', $_POST)) $payload['amount_actual'] = $_POST['amount_actual'];
            (new ProjectOverheads())->update((int)($_POST['id'] ?? 0), $payload, $this->currentUserId());
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function overheadDeleteAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('ProjectOverheads')) require_once ROOT . '/app/models/ProjectOverheads.php';
            (new ProjectOverheads())->softDelete((int)($_POST['id'] ?? 0), $this->currentUserId());
            echo json_encode(['ok' => true]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** Slice 8Z · PC hands the Client PO over to Procurement (all items must be CONFIRMED). */
    public function goodsHandoverToProcurementAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->handoverToProcurement(
                (int)($_POST['po_id']   ?? 0),
                (string)($_POST['notes'] ?? ''),
                $this->currentUserId(),
                (string)($_SESSION['full_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Download a blank XLSX template with the exact column headers the import expects.
     */
    public function goodsItemsTemplateAction()
    {
        if (!class_exists('ExportHelper')) require_once ROOT . '/app/helpers/ExportHelper.php';
        $columns = [
            ['label' => 'description',     'width' => 40, 'align' => 'L'],
            ['label' => 'brand',           'width' => 14, 'align' => 'L'],
            ['label' => 'source_pref',     'width' => 12, 'align' => 'L'],
            ['label' => 'delivery_method', 'width' => 16, 'align' => 'L'],
            ['label' => 'qty',             'width' => 8,  'align' => 'R', 'type' => 'money'],
            ['label' => 'unit',            'width' => 8,  'align' => 'L'],
            ['label' => 'unit_price_sell', 'width' => 14, 'align' => 'R', 'type' => 'money'],
            ['label' => 'margin_pct',      'width' => 10, 'align' => 'R', 'type' => 'money'],
            ['label' => 'notes',           'width' => 30, 'align' => 'L'],
        ];
        // One example row so PC knows the shape.
        $rows = [
            ['e.g. 48-port PoE switch — 500W', 'Cisco', 'ANY', 'On-site', 8, 'ea', 3200000, 20, 'Rack-mountable · 12mo warranty'],
        ];
        ExportHelper::xlsxDownload('client_po_items_template.xlsx', 'Items', $columns, $rows, [], [
            'title' => 'Client PO items — import template',
            'subtitle' => 'Fill from row 2 down. description + qty required. source_pref: LOCAL / IMPORT / ANY.',
        ]);
    }

    /**
     * Slice 8Y · Executive / Admin reassigns the PM on a project.
     * POST: project_id, new_pm_id, reason.
     */
    public function reassignPmAjAction()
    {
        header('Content-Type: application/json');
        try {
            (new Projects())->reassignPm(
                (int)($_POST['project_id'] ?? 0),
                (int)($_POST['new_pm_id']   ?? 0),
                (string)($_POST['reason']   ?? ''),
                $this->currentUserId(),
                (string)($_SESSION['full_name']       ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode(['ok' => true, 'msg' => 'PM reassigned.']);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /* ── Goods CRM authoring (Slice 8g-GoodsCrm) ───────────────────────── */

    /** POST project_id + item fields → adds a line to the CRM-authored Client PO. */
    public function goodsAddItemAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $go   = new GoodsOnboarding();
            $pid  = (int)($_POST['project_id'] ?? 0);
            $poR  = $go->getOrCreateDraftClientPo($pid, (int)($_SESSION['user_id'] ?? 0), (string)($_SESSION['full_name'] ?? ''));
            if (!$poR['ok']) { echo json_encode($poR); return; }
            $res = $go->addItem((int)$poR['po_id'], [
                'description'     => (string)($_POST['description']     ?? ''),
                'brand'           => (string)($_POST['brand']           ?? ''),
                'source_pref'     => (string)($_POST['source_pref']     ?? 'ANY'),
                'delivery_method' => (string)($_POST['delivery_method'] ?? ''),
                'qty'             => (float) ($_POST['qty']             ?? 0),
                'unit'            => (string)($_POST['unit']            ?? ''),
                'unit_price_sell' => (float) ($_POST['unit_price_sell'] ?? 0),
                'margin_pct'      => (float) ($_POST['margin_pct']      ?? 0),
                'notes'           => (string)($_POST['notes']           ?? ''),
            ], (int)($_SESSION['user_id'] ?? 0));
            echo json_encode($res + ['po_id' => (int)$poR['po_id']]);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** POST po_id + item_id → soft-delete an item on a DRAFT Client PO. */
    public function goodsRemoveItemAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->removeItem(
                (int)($_POST['po_id']  ?? 0),
                (int)($_POST['item_id'] ?? 0)
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /** POST project_id → CRM submits the onboarding into GOODS_PROJECT_ONBOARDING chain. */
    public function goodsSubmitOnboardingAjAction()
    {
        header('Content-Type: application/json');
        try {
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $res = (new GoodsOnboarding())->submitOnboarding(
                (int)($_POST['project_id'] ?? 0),
                (int)($_SESSION['user_id']  ?? 0),
                (string)($_SESSION['full_name'] ?? ''),
                (string)($_SESSION['user_group_name'] ?? '')
            );
            echo json_encode($res);
        } catch (Throwable $e) { echo json_encode(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Cheap per-project workspace summary keyed by project_type. Gives Projects/view
     * concrete counts + action URLs so the page is actually usable to drive the flow,
     * not just a CRM-status readout.
     */
    private function buildWorkspaceSummary(array $p, DB $db): array
    {
        $pid  = (int)$p['c0d'];
        $type = (string)($p['project_type'] ?? '');

        if ($type === 'goods') {
            $poCount   = (int)($db->mySelect("SELECT COUNT(*) n FROM goods_client_pos WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            $rfqCount  = (int)($db->mySelect("SELECT COUNT(*) n FROM goods_rfqs        WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            $delCount  = (int)($db->mySelect("SELECT COUNT(*) n FROM goods_customer_deliveries WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            $salRow    = $db->mySelect("SELECT COALESCE(SUM(total),0) t, COALESCE(SUM(amount_paid),0) pd, COUNT(*) n FROM sales WHERE project_id = :p AND deleted = 0", [':p' => $pid]);

            // Slice 8h — post-CRM-onboarding CONFIRMED Client PO gates the 7-pillar scrub
            $confPo = $db->mySelect(
                "SELECT c0d FROM goods_client_pos WHERE project_id = :p AND status IN ('CONFIRMED','SOURCING') AND deleted = 0 ORDER BY c0d DESC LIMIT 1",
                [':p' => $pid]
            );
            $scrubUrl = null;
            $scrubKpi = null;
            if (!empty($confPo)) {
                $sc = $db->mySelect("SELECT c0d, status, grand_total FROM goods_scrubs WHERE client_po_id = :po AND deleted = 0 LIMIT 1", [':po' => (int)$confPo[0]['c0d']]);
                if (!empty($sc)) {
                    $scrubUrl = 'GoodsScrubPack/view/' . (int)$sc[0]['c0d'];
                    $scrubKpi = ['value' => ($sc[0]['status'] === 'COMPLETE' ? '✓ ' : '') . 'TZS ' . number_format((float)$sc[0]['grand_total']), 'url' => $scrubUrl];
                } else {
                    // No scrub yet — the open route creates one
                    $scrubUrl = 'GoodsScrubPack/open/' . (int)$confPo[0]['c0d'];
                    $scrubKpi = ['value' => 'start', 'url' => $scrubUrl];
                }
            }

            $kpis = [
                'Client POs'          => ['value' => $poCount,                                           'url' => 'GoodsClientPos?project_id=' . $pid],
                'RFQs · Sourcing'     => ['value' => $rfqCount,                                          'url' => 'GoodsRfq?project_id=' . $pid],
                'Deliveries'          => ['value' => $delCount,                                          'url' => 'GoodsCustomerDeliveries?project_id=' . $pid],
                'Invoiced (customer)' => ['value' => 'TZS ' . number_format((float)($salRow[0]['t']  ?? 0)), 'url' => 'Sales?project_id=' . $pid],
                'Received'            => ['value' => 'TZS ' . number_format((float)($salRow[0]['pd'] ?? 0)), 'url' => 'Receipts?project_id=' . $pid],
            ];
            if ($scrubKpi !== null) $kpis = ['PC Scrub · 7 pillars' => $scrubKpi] + $kpis;

            $actions = [];
            if ($scrubUrl !== null) {
                $actions[] = ['label' => 'Open PC Scrub · 7 pillars', 'url' => $scrubUrl, 'icon' => 'fas fa-clipboard-list', 'primary' => true];
            } else {
                $actions[] = ['label' => 'Draft new Client PO', 'url' => 'GoodsClientPos/add?project_id=' . $pid, 'icon' => 'fas fa-plus-circle', 'primary' => true];
            }
            $actions[] = ['label' => 'Client POs',          'url' => 'GoodsClientPos?project_id=' . $pid,     'icon' => 'fas fa-file-contract'];
            $actions[] = ['label' => 'RFQs · Sourcing',     'url' => 'GoodsRfq?project_id=' . $pid,           'icon' => 'fas fa-file-signature'];
            $actions[] = ['label' => 'Deliveries',          'url' => 'GoodsCustomerDeliveries?project_id=' . $pid, 'icon' => 'fas fa-truck'];
            $actions[] = ['label' => 'Sales invoices',      'url' => 'Sales?project_id=' . $pid,              'icon' => 'fas fa-file-invoice'];
            $actions[] = ['label' => 'Customer receipts',   'url' => 'Receipts?project_id=' . $pid,           'icon' => 'fas fa-receipt'];

            $note = $scrubUrl !== null
                ? 'Client PO is <strong>CONFIRMED</strong>. Open the <strong>PC Scrub</strong> to load estimated unit costs on materials + capture the auxiliary 7-pillar costs (manpower, transport, tools, overheads). Procurement can source LPOs once the scrub is complete.'
                : 'Terms & conditions, expected brands, delivery windows and unit-price expectations are captured on the <strong>Client PO</strong>. CRM drafts + submits it; on approval it becomes CONFIRMED and the PC Scrub unlocks.';

            return ['type' => 'goods', 'kpis' => $kpis, 'actions' => $actions, 'note' => $note];
        }

        if ($type === 'works') {
            $phCount = (int)($db->mySelect("SELECT COUNT(*) n FROM works_phases WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            $crCount = (int)($db->mySelect("SELECT COUNT(*) n FROM works_crs    WHERE project_id = :p AND deleted = 0 AND status IN ('ACTIVE','CLOSED')", [':p' => $pid])[0]['n'] ?? 0);
            $ipCount = (int)($db->mySelect("SELECT COUNT(*) n FROM works_ipcs   WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            $boqRow  = $db->mySelect("SELECT contract_value FROM works_boqs WHERE project_id = :p AND deleted = 0 LIMIT 1", [':p' => $pid]);
            return [
                'type'    => 'works',
                'kpis'    => [
                    'Contract BOQ'    => ['value' => 'TZS ' . number_format((float)($boqRow[0]['contract_value'] ?? 0)), 'url' => 'WorksBoq/view/' . $pid],
                    'Phases'          => ['value' => $phCount, 'url' => 'WorksPhases/plan/' . $pid],
                    'CRs in play'     => ['value' => $crCount, 'url' => 'WorksCrs?project_id=' . $pid],
                    'IPCs'            => ['value' => $ipCount, 'url' => 'WorksIpcs/list/' . $pid],
                ],
                'actions' => [
                    ['label' => 'Project report',    'url' => 'WorksProjectReport/view/' . $pid, 'icon' => 'fas fa-chart-line', 'primary' => true],
                    ['label' => 'Contract BOQ',      'url' => 'WorksBoq/view/' . $pid,           'icon' => 'fas fa-clipboard-list'],
                    ['label' => 'Phase planning',    'url' => 'WorksPhases/plan/' . $pid,        'icon' => 'fas fa-layer-group'],
                    ['label' => 'New CR',            'url' => 'WorksCrs/create/' . $pid,         'icon' => 'fas fa-file-invoice'],
                    ['label' => 'New IPC',           'url' => 'WorksIpcs/create/' . $pid,        'icon' => 'fas fa-file-signature'],
                    ['label' => 'Retention pool',    'url' => 'WorksRetention/pool/' . $pid,     'icon' => 'fas fa-hand-holding-usd'],
                    ['label' => 'Customer retention','url' => 'WorksCustomerRetention/pool/' . $pid, 'icon' => 'fas fa-hourglass-half'],
                ],
                'note'    => 'The BOQ was snapshotted from tender pricing at provisioning. Break execution into <strong>Phases</strong>, then draft <strong>CRs</strong> against each phase, then certify progress via <strong>IPCs</strong>.',
            ];
        }

        if ($type === 'services') {
            $reqCount = (int)($db->mySelect("SELECT COUNT(*) n FROM service_requisitions WHERE project_id = :p AND deleted = 0", [':p' => $pid])[0]['n'] ?? 0);
            return [
                'type'    => 'services',
                'kpis'    => [
                    'Requisitions' => ['value' => $reqCount, 'url' => 'ServiceRequisitions?project_id=' . $pid],
                ],
                'actions' => [
                    ['label' => 'Requisitions', 'url' => 'ServiceRequisitions?project_id=' . $pid, 'icon' => 'fas fa-hand-holding-usd', 'primary' => true],
                ],
                'note'    => 'Float / imprest funding lives on the requisitions surface. Draft a requisition to seed the on-site float.',
            ];
        }

        return ['type' => $type, 'kpis' => [], 'actions' => [], 'note' => 'No workspace helper wired for this project type yet.'];
    }
}
