t('grant.title'), 'year' => $year, 'annual' => GrantService::ensureYear($year), 'stats' => GrantService::statistics($year), 'details' => GrantService::detailedStatistics($year), 'comparison' => GrantService::comparison($year), 'applications' => $applications, 'checklist' => GrantService::checklist($year), 'documents' => GrantService::documents($year), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); } public static function saveAnnual(): void { self::admin(); $year = self::year(); GrantService::ensureYear($year); $ints = ['members_count', 'volunteers_count', 'employees_count']; $nums = [ 'employees_fte', 'treasury_amount', 'actual_expenses', 'actual_income', 'forecast_expenses', 'forecast_income', 'voluntary_contributions', ]; $texts = [ 'annual_report', 'completed_actions', 'planned_actions', 'beneficiaries', 'officers_notes', 'in_kind_support', ]; $values = [ ':year' => $year, ':last_ag_date' => self::date($_POST['last_ag_date'] ?? null), ':uid' => Auth::id(), ]; foreach ($ints as $f) { $values[':' . $f] = max(0, (int) ($_POST[$f] ?? 0)); } foreach ($nums as $f) { $values[':' . $f] = max(0, self::amount($_POST[$f] ?? 0)); } foreach ($texts as $f) { $values[':' . $f] = trim((string) ($_POST[$f] ?? '')); } $sets = []; foreach (array_merge($ints, $nums, $texts, ['last_ag_date']) as $f) { $sets[] = "$f=:$f"; } $sql = 'UPDATE grant_years SET ' . implode(',', $sets) . ',updated_by=:uid,updated_at=datetime(\'now\') WHERE year=:year'; DB::pdo()->prepare($sql)->execute($values); AuditService::log('grant_year_saved', '/admin/grants/save', 'Bilan annuel ' . $year . ' mis à jour'); header('Location: /admin/grants?year=' . $year . '&saved=1'); } public static function saveApplication(): void { self::admin(); $year = self::year(); GrantService::ensureYear($year); $id = (int) ($_POST['application_id'] ?? 0); $existing = []; if ($id) { $q = DB::pdo()->prepare('SELECT * FROM grant_applications WHERE id=? AND year=?'); $q->execute([$id, $year]); $existing = $q->fetch() ?: []; } $name = trim((string) ($_POST['organization_name'] ?? ($existing['organization_name'] ?? ''))); if ($name === '') { header('Location: /admin/grants?year=' . $year . '&error=organisme'); return; } $status = (string) ($_POST['status'] ?? ($existing['status'] ?? 'draft')); if (!isset(GrantService::STATUSES[$status])) { $status = 'draft'; } $awardedRaw = $_POST['awarded_amount'] ?? ($existing['awarded_amount'] ?? ''); $params = [ ':year' => $year, ':name' => $name, ':deadline' => self::date($_POST['deadline'] ?? ($existing['deadline'] ?? null)), ':requested' => self::amount($_POST['requested_amount'] ?? ($existing['requested_amount'] ?? 0)), ':previous' => self::amount($_POST['previous_amount'] ?? ($existing['previous_amount'] ?? 0)), ':awarded' => trim((string) $awardedRaw) === '' ? null : self::amount($awardedRaw), ':status' => $status, ':notes' => trim((string) ($_POST['notes'] ?? ($existing['notes'] ?? ''))), ':uid' => Auth::id(), ]; if ($id) { $params[':id'] = $id; DB::pdo() ->prepare( 'UPDATE grant_applications SET organization_name=:name,deadline=:deadline,requested_amount=:requested,previous_amount=:previous,awarded_amount=:awarded,status=:status,notes=:notes,updated_by=:uid,updated_at=datetime(\'now\') WHERE id=:id AND year=:year', ) ->execute($params); } else { DB::pdo() ->prepare( 'INSERT INTO grant_applications(year,organization_name,deadline,requested_amount,previous_amount,awarded_amount,status,notes,created_by,updated_by) VALUES(:year,:name,:deadline,:requested,:previous,:awarded,:status,:notes,:uid,:uid)', ) ->execute($params); } header('Location: /admin/grants?year=' . $year . '&saved=1'); } public static function upload(): void { self::admin(); $year = self::year(); GrantService::ensureYear($year); $f = $_FILES['document'] ?? null; if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20_000_000) { header('Location: /admin/grants?year=' . $year . '&error=document'); return; } $mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']) ?: 'application/octet-stream'; $allowed = ['application/pdf' => 'pdf', 'application/vnd.oasis.opendocument.text' => 'odt']; if (!isset($allowed[$mime])) { header('Location: /admin/grants?year=' . $year . '&error=format'); return; } $dir = dirname(__DIR__, 2) . '/data/grants'; if (!is_dir($dir)) { mkdir($dir, 0770, true); } $stored = bin2hex(random_bytes(18)) . '.' . $allowed[$mime]; if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) { throw new RuntimeException(t('error.document_storage')); } $category = (string) ($_POST['category'] ?? 'other'); if (!isset(GrantService::CATEGORIES[$category])) { $category = 'other'; } $global = isset($_POST['global_document']); $applicationId = (int) ($_POST['application_id'] ?? 0); if ($applicationId) { $check = DB::pdo()->prepare('SELECT 1 FROM grant_applications WHERE id=? AND year=?'); $check->execute([$applicationId, $year]); if (!$check->fetchColumn()) { $applicationId = 0; } } if ($category === 'official_form' && !$applicationId) { @unlink($dir . '/' . $stored); header('Location: /admin/grants?year=' . $year . '&error=organisme'); return; } DB::pdo() ->prepare( 'INSERT INTO grant_documents(year,application_id,category,title,stored_name,original_name,mime_type,size_bytes,valid_until,notes,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)', ) ->execute([ $global ? null : $year, $applicationId ?: null, $category, trim((string) ($_POST['title'] ?? '')) ?: pathinfo((string) $f['name'], PATHINFO_FILENAME), $stored, basename((string) $f['name']), $mime, (int) $f['size'], self::date($_POST['valid_until'] ?? null), trim((string) ($_POST['notes'] ?? '')), Auth::id(), ]); header('Location: /admin/grants?year=' . $year . '&saved=1'); } public static function download(): void { self::admin(); $s = DB::pdo()->prepare('SELECT * FROM grant_documents WHERE id=?'); $s->execute([(int) ($_GET['id'] ?? 0)]); $d = $s->fetch(); $path = dirname(__DIR__, 2) . '/data/grants/' . ($d['stored_name'] ?? ''); if (!$d || !is_file($path)) { http_response_code(404); echo h(t('grant.document_not_found')); return; } PrivacyService::logAccess('grant_document', (int) $d['id'], null, (string) $d['original_name']); header('Content-Type: ' . $d['mime_type']); header('Content-Disposition: inline; filename="' . rawurlencode($d['original_name']) . '"'); header('Content-Length: ' . filesize($path)); header('Cache-Control: private,no-store'); readfile($path); } public static function print(): void { self::admin(); $year = self::year(); $annual = GrantService::ensureYear($year); $stats = GrantService::statistics($year); $details = GrantService::detailedStatistics($year); $comparison = GrantService::comparison($year); $applications = GrantService::applications($year); $documents = GrantService::documents($year); $generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')); header('Content-Type: text/html; charset=UTF-8'); require __DIR__ . '/../Views/admin_grants_print.php'; } private static function date(mixed $v): ?string { $v = trim((string) $v); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; } private static function amount(mixed $v): float { return round((float) str_replace([' ', ' ', ','], ['', '', '.'], (string) $v), 2); } }