$details]) : null; $sql = sprintf( 'INSERT INTO %s (id, quote_id, action, step_name, details, ip_address, created_at) VALUES (:id, :quote_id, :action, :step_name, :details, :ip, :created_at)', tbl_activity() ); $stmt = $db->prepare($sql); $stmt->execute([ ':id' => $id, ':quote_id' => $quote_id, ':action' => $action, ':step_name' => $step_name, ':details' => $details_json, ':ip' => $ip, ':created_at' => utc_now(), ]); } // -------------------------------------------------------------------------- // Totals calculation // -------------------------------------------------------------------------- /** * Calculate monthly and setup totals from a list of item rows. * * Monthly calculation: * - monthly items: unit_price * quantity * - yearly items: unit_price * quantity / 12 * - one_time items: 0 * * Setup total: sum of all setup_price values. * * @param array $items Array of item associative arrays. * @return array{monthly: string, setup: string} Formatted totals with 2 decimal places. */ function calculate_totals(array $items): array { $monthly = '0'; $setup = '0'; foreach ($items as $item) { $unit_price = $item['unit_price'] ?? '0'; $qty = (int)($item['quantity'] ?? 1); $setup_price = $item['setup_price'] ?? '0'; $freq = $item['billing_frequency'] ?? 'monthly'; $line_total = bcmul($unit_price, (string)$qty, 4); if ($freq === 'monthly') { $monthly = bcadd($monthly, $line_total, 4); } elseif ($freq === 'yearly') { $monthly_portion = bcdiv($line_total, '12', 4); $monthly = bcadd($monthly, $monthly_portion, 4); } // one_time items contribute 0 to monthly $setup = bcadd($setup, $setup_price, 4); } return [ 'monthly' => number_format((float)$monthly, 2, '.', ''), 'setup' => number_format((float)$setup, 2, '.', ''), ]; } /** * Recalculate and persist totals for a quote by summing its items. * * @param PDO $db Database connection. * @param string $quote_id UUID of the quote. */ function recalculate_totals(PDO $db, string $quote_id): void { $items = fetch_items_for_quote($db, $quote_id); $totals = calculate_totals($items); $sql = sprintf( 'UPDATE %s SET monthly_total = :monthly, setup_total = :setup, updated_at = :now WHERE id = :id', tbl_quotes() ); $stmt = $db->prepare($sql); $stmt->execute([ ':monthly' => $totals['monthly'], ':setup' => $totals['setup'], ':now' => utc_now(), ':id' => $quote_id, ]); } // -------------------------------------------------------------------------- // Item helpers // -------------------------------------------------------------------------- /** * Fetch all items for a given quote. * * @param PDO $db Database connection. * @param string $quote_id UUID of the quote. * @return array List of item rows. */ function fetch_items_for_quote(PDO $db, string $quote_id): array { $sql = sprintf('SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at ASC', tbl_items()); $stmt = $db->prepare($sql); $stmt->execute([':qid' => $quote_id]); return $stmt->fetchAll(); } /** * Insert a single item row. * * @param PDO $db Database connection. * @param string $quote_id UUID of the parent quote. * @param array $item Item data with keys matching the schema. * @return string The generated item UUID. */ function insert_item(PDO $db, string $quote_id, array $item): string { $id = generate_uuid(); $sql = sprintf( 'INSERT INTO %s (id, quote_id, category, product_code, product_name, description, quantity, unit_price, setup_price, billing_frequency, tier, is_recommended, created_at) VALUES (:id, :qid, :cat, :code, :name, :desc, :qty, :price, :setup, :freq, :tier, :rec, :created_at)', tbl_items() ); $stmt = $db->prepare($sql); $stmt->execute([ ':id' => $id, ':qid' => $quote_id, ':cat' => $item['category'] ?? 'other', ':code' => $item['product_code'] ?? '', ':name' => $item['product_name'] ?? '', ':desc' => $item['description'] ?? null, ':qty' => (int)($item['quantity'] ?? 1), ':price' => $item['unit_price'] ?? '0.00', ':setup' => $item['setup_price'] ?? '0.00', ':freq' => $item['billing_frequency'] ?? 'monthly', ':tier' => $item['tier'] ?? null, ':rec' => (int)($item['is_recommended'] ?? 0), ':created_at' => utc_now(), ]); return $id; } /** * Delete all items for a quote. * * @param PDO $db Database connection. * @param string $quote_id UUID of the quote. */ function delete_all_items(PDO $db, string $quote_id): void { $sql = sprintf('DELETE FROM %s WHERE quote_id = :qid', tbl_items()); $stmt = $db->prepare($sql); $stmt->execute([':qid' => $quote_id]); } // -------------------------------------------------------------------------- // Quote CRUD // -------------------------------------------------------------------------- /** * Create a new quote draft. * * Generates a UUID and access token, inserts the quote row, optionally * inserts initial items, calculates totals, and logs a "created" activity. * * @param PDO $db Database connection. * @param array $data Request body data (employee_count, items). * @param string|null $ip Client IP. * @param string|null $ua User-Agent. * @return array The created quote row (without items). */ function create_quote(PDO $db, array $data, ?string $ip = null, ?string $ua = null): array { $id = generate_uuid(); $token = generate_access_token(); $now = utc_now(); $expires = gmdate('Y-m-d H:i:s', strtotime("+".QUOTE_DRAFT_EXPIRY_DAYS." days")); $employee_count = isset($data['employee_count']) ? (int)$data['employee_count'] : null; $sql = sprintf( 'INSERT INTO %s (id, access_token, status, employee_count, ip_address, user_agent, monthly_total, setup_total, expires_at, created_at, updated_at) VALUES (:id, :token, :status, :emp, :ip, :ua, :mt, :st, :exp, :now, :now2)', tbl_quotes() ); try { $db->beginTransaction(); $stmt = $db->prepare($sql); $stmt->execute([ ':id' => $id, ':token' => $token, ':status' => 'draft', ':emp' => $employee_count, ':ip' => $ip, ':ua' => $ua, ':mt' => '0.00', ':st' => '0.00', ':exp' => $expires, ':now' => $now, ':now2' => $now, ]); // Insert initial items if provided if (!empty($data['items']) && is_array($data['items'])) { foreach ($data['items'] as $item) { insert_item($db, $id, $item); } recalculate_totals($db, $id); } // Log activity log_activity( $db, $id, 'created', "Quote draft created, employee_count={$employee_count}", null, $ip ); $db->commit(); } catch (PDOException $e) { $db->rollBack(); // If token collision, retry once if (strpos($e->getMessage(), 'access_token') !== false) { app_log('WARNING', 'Access token collision, retrying create_quote'); return create_quote($db, $data, $ip, $ua); } app_log('ERROR', 'Failed to create quote: ' . $e->getMessage()); error_response('Failed to create quote: ' . $e->getMessage(), 500); } return fetch_quote_row($db, $id); } /** * Fetch a raw quote row by ID. * * @param PDO $db Database connection. * @param string $id Quote UUID. * @return array|null Quote row or null. */ function fetch_quote_row(PDO $db, string $id): ?array { $sql = sprintf('SELECT * FROM %s WHERE id = :id', tbl_quotes()); $stmt = $db->prepare($sql); $stmt->execute([':id' => $id]); $row = $stmt->fetch(); return $row ?: null; } /** * Fetch a quote by access token, with expiration check. * * If the quote is a draft and expired, its status is updated to "expired". * * @param PDO $db Database connection. * @param string $token Access token. * @return array Quote row. */ function get_quote_by_token(PDO $db, string $token): array { $sql = sprintf('SELECT * FROM %s WHERE access_token = :token', tbl_quotes()); $stmt = $db->prepare($sql); $stmt->execute([':token' => $token]); $quote = $stmt->fetch(); if (!$quote) { error_response('Quote not found', 404); } // Check expiration for draft quotes if ( $quote['status'] === 'draft' && $quote['expires_at'] !== null && strtotime($quote['expires_at']) < time() ) { $upd = sprintf('UPDATE %s SET status = :s, updated_at = :now WHERE id = :id', tbl_quotes()); $stmt2 = $db->prepare($upd); $stmt2->execute([':s' => 'expired', ':now' => utc_now(), ':id' => $quote['id']]); $quote['status'] = 'expired'; } return $quote; } /** * Fetch a quote by its UUID (admin access). * * @param PDO $db Database connection. * @param string $id Quote UUID. * @return array Quote row. */ function get_quote_by_id(PDO $db, string $id): array { $quote = fetch_quote_row($db, $id); if (!$quote) { error_response("Quote with ID {$id} not found", 404); } return $quote; } /** * Build the full public quote response (quote + items, with computed fields). * * @param PDO $db Database connection. * @param array $quote Raw quote row. * @return array Response-ready associative array. */ function build_quote_response(PDO $db, array $quote): array { $items_raw = fetch_items_for_quote($db, $quote['id']); $items = array_map('format_item_response', $items_raw); return [ 'id' => $quote['id'], 'access_token' => $quote['access_token'], 'status' => $quote['status'], 'company_name' => $quote['company_name'], 'contact_name' => $quote['contact_name'], 'contact_email' => $quote['contact_email'], 'contact_phone' => $quote['contact_phone'], 'employee_count' => $quote['employee_count'] !== null ? (int)$quote['employee_count'] : null, 'monthly_total' => number_format((float)$quote['monthly_total'], 2, '.', ''), 'setup_total' => number_format((float)$quote['setup_total'], 2, '.', ''), 'expires_at' => format_datetime($quote['expires_at']), 'submitted_at' => format_datetime($quote['submitted_at']), 'created_at' => format_datetime($quote['created_at']), 'updated_at' => format_datetime($quote['updated_at']), 'items' => $items, ]; } /** * Build the full admin quote response (quote + items + activities + notifications). * * @param PDO $db Database connection. * @param array $quote Raw quote row. * @return array Response-ready associative array. */ function build_admin_quote_response(PDO $db, array $quote): array { $base = build_quote_response($db, $quote); // Add admin-only fields $base['ip_address'] = $quote['ip_address']; $base['user_agent'] = $quote['user_agent']; // Fetch activities $sql_act = sprintf( 'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC', tbl_activity() ); $stmt = $db->prepare($sql_act); $stmt->execute([':qid' => $quote['id']]); $activities_raw = $stmt->fetchAll(); $base['activities'] = array_map(function ($a) { return [ 'id' => $a['id'], 'quote_id' => $a['quote_id'], 'action' => $a['action'], 'step_name' => $a['step_name'], 'details' => $a['details'], 'ip_address' => $a['ip_address'], 'created_at' => format_datetime($a['created_at']), ]; }, $activities_raw); // Fetch notifications $sql_notif = sprintf( 'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC', tbl_notif() ); $stmt2 = $db->prepare($sql_notif); $stmt2->execute([':qid' => $quote['id']]); $notifs_raw = $stmt2->fetchAll(); $base['notifications'] = array_map(function ($n) { return [ 'id' => $n['id'], 'quote_id' => $n['quote_id'], 'notification_type' => $n['notification_type'], 'recipient' => $n['recipient'], 'subject' => $n['subject'], 'status' => $n['status'], 'attempts' => (int)$n['attempts'], 'last_attempt_at' => format_datetime($n['last_attempt_at']), 'sent_at' => format_datetime($n['sent_at']), 'error_message' => $n['error_message'], 'created_at' => format_datetime($n['created_at']), ]; }, $notifs_raw); return $base; } /** * Format a raw item row into the API response shape with computed fields. * * @param array $item Raw item row from database. * @return array Formatted item response. */ function format_item_response(array $item): array { $unit_price = $item['unit_price'] ?? '0.00'; $qty = (int)($item['quantity'] ?? 1); $freq = $item['billing_frequency'] ?? 'monthly'; $setup = $item['setup_price'] ?? '0.00'; $line_total = bcmul($unit_price, (string)$qty, 2); if ($freq === 'monthly') { $monthly_amount = $line_total; } elseif ($freq === 'yearly') { $monthly_amount = bcdiv($line_total, '12', 2); } else { $monthly_amount = '0.00'; } return [ 'id' => $item['id'], 'quote_id' => $item['quote_id'], 'category' => $item['category'], 'product_code' => $item['product_code'], 'product_name' => $item['product_name'], 'description' => $item['description'], 'quantity' => $qty, 'unit_price' => number_format((float)$unit_price, 2, '.', ''), 'setup_price' => number_format((float)$setup, 2, '.', ''), 'billing_frequency' => $freq, 'tier' => $item['tier'], 'is_recommended' => (bool)$item['is_recommended'], 'line_total' => number_format((float)$line_total, 2, '.', ''), 'monthly_amount' => number_format((float)$monthly_amount, 2, '.', ''), 'created_at' => format_datetime($item['created_at']), ]; } // -------------------------------------------------------------------------- // Update quote // -------------------------------------------------------------------------- /** * Update a draft quote's fields and optionally replace all items. * * Only quotes with status "draft" can be updated. If items are provided * in the data, all existing items are deleted and replaced. * * @param PDO $db Database connection. * @param string $token Access token. * @param array $data Update data. * @param string|null $ip Client IP. * @return array Updated quote row. */ function update_quote(PDO $db, string $token, array $data, ?string $ip = null): array { $quote = get_quote_by_token($db, $token); if ($quote['status'] !== 'draft') { error_response( "Cannot update quote with status '{$quote['status']}'. Only drafts can be modified.", 400 ); } try { $db->beginTransaction(); $changes = []; $updatable_fields = ['company_name', 'contact_name', 'contact_email', 'contact_phone', 'employee_count']; $sets = []; $params = [':id' => $quote['id']]; foreach ($updatable_fields as $field) { if (array_key_exists($field, $data)) { $old = $quote[$field]; $new = $data[$field]; if ($old !== $new) { $sets[] = "{$field} = :{$field}"; $params[":{$field}"] = $new; $changes[] = "{$field}: {$old} -> {$new}"; } } } if (!empty($sets)) { $sets[] = 'updated_at = :now'; $params[':now'] = utc_now(); $sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets)); $stmt = $db->prepare($sql); $stmt->execute($params); } // Replace items if provided if (array_key_exists('items', $data) && $data['items'] !== null) { delete_all_items($db, $quote['id']); $item_count = 0; if (is_array($data['items'])) { foreach ($data['items'] as $item) { insert_item($db, $quote['id'], $item); $item_count++; } } $changes[] = "items: replaced with {$item_count} items"; } // Recalculate totals recalculate_totals($db, $quote['id']); // Log activity if (!empty($changes)) { log_activity( $db, $quote['id'], 'updated', 'Quote updated: ' . implode(', ', $changes), null, $ip ); } $db->commit(); } catch (PDOException $e) { $db->rollBack(); app_log('ERROR', 'Failed to update quote: ' . $e->getMessage()); error_response('Failed to update quote: ' . $e->getMessage(), 500); } return get_quote_by_token($db, $token); } // -------------------------------------------------------------------------- // Add / remove items // -------------------------------------------------------------------------- /** * Add a single item to a draft quote. * * @param PDO $db Database connection. * @param string $token Access token. * @param array $item Item data. * @param string|null $ip Client IP. * @return array Updated quote row. */ function add_item(PDO $db, string $token, array $item, ?string $ip = null): array { $quote = get_quote_by_token($db, $token); if ($quote['status'] !== 'draft') { error_response('Cannot add items to a non-draft quote', 400); } try { $db->beginTransaction(); insert_item($db, $quote['id'], $item); recalculate_totals($db, $quote['id']); log_activity( $db, $quote['id'], 'item_added', 'Added item: ' . ($item['product_name'] ?? 'unknown'), null, $ip ); $db->commit(); } catch (PDOException $e) { $db->rollBack(); app_log('ERROR', 'Failed to add item: ' . $e->getMessage()); error_response('Failed to add item: ' . $e->getMessage(), 500); } return get_quote_by_token($db, $token); } /** * Remove a single item from a draft quote. * * @param PDO $db Database connection. * @param string $token Access token. * @param string $item_id UUID of the item to remove. * @param string|null $ip Client IP. * @return array Updated quote row. */ function remove_item(PDO $db, string $token, string $item_id, ?string $ip = null): array { $quote = get_quote_by_token($db, $token); if ($quote['status'] !== 'draft') { error_response('Cannot remove items from a non-draft quote', 400); } // Find the item $sql = sprintf( 'SELECT * FROM %s WHERE id = :iid AND quote_id = :qid', tbl_items() ); $stmt = $db->prepare($sql); $stmt->execute([':iid' => $item_id, ':qid' => $quote['id']]); $item = $stmt->fetch(); if (!$item) { error_response("Item with ID {$item_id} not found in this quote", 404); } try { $db->beginTransaction(); $del = sprintf('DELETE FROM %s WHERE id = :iid', tbl_items()); $stmt2 = $db->prepare($del); $stmt2->execute([':iid' => $item_id]); recalculate_totals($db, $quote['id']); log_activity( $db, $quote['id'], 'item_removed', 'Removed item: ' . $item['product_name'], null, $ip ); $db->commit(); } catch (PDOException $e) { $db->rollBack(); app_log('ERROR', 'Failed to remove item: ' . $e->getMessage()); error_response('Failed to remove item: ' . $e->getMessage(), 500); } return get_quote_by_token($db, $token); } // -------------------------------------------------------------------------- // Submit quote // -------------------------------------------------------------------------- /** * Submit a draft quote with contact information. * * Validates the quote is a draft and has items, updates contact fields, * sets status to "submitted", extends expiration to 90 days, and creates * a notification record. * * @param PDO $db Database connection. * @param string $token Access token. * @param array $data Submission data (company_name, contact_name, contact_email, etc.). * @param string|null $ip Client IP. * @return array The submitted quote row. */ function submit_quote(PDO $db, string $token, array $data, ?string $ip = null): array { $quote = get_quote_by_token($db, $token); if ($quote['status'] !== 'draft') { error_response( "Cannot submit quote with status '{$quote['status']}'. Only drafts can be submitted.", 400 ); } // Must have at least one item $items = fetch_items_for_quote($db, $quote['id']); if (empty($items)) { error_response( 'Cannot submit quote without any items. Please add at least one service.', 400 ); } $now = utc_now(); $expires = gmdate('Y-m-d H:i:s', strtotime("+" . QUOTE_SUBMITTED_EXPIRY_DAYS . " days")); try { $db->beginTransaction(); // Update contact info and status $sql = sprintf( 'UPDATE %s SET company_name = :company, contact_name = :name, contact_email = :email, contact_phone = :phone, status = :status, submitted_at = :submitted, expires_at = :expires, updated_at = :now WHERE id = :id', tbl_quotes() ); $stmt = $db->prepare($sql); $stmt->execute([ ':company' => trim($data['company_name'] ?? ''), ':name' => trim($data['contact_name'] ?? ''), ':email' => $data['contact_email'] ?? '', ':phone' => $data['contact_phone'] ?? null, ':status' => 'submitted', ':submitted' => $now, ':expires' => $expires, ':now' => $now, ':id' => $quote['id'], ]); // Refresh the quote row to get updated totals $refreshed = fetch_quote_row($db, $quote['id']); // Log activity $contact_name = trim($data['contact_name'] ?? ''); $contact_email = $data['contact_email'] ?? ''; $company_name = trim($data['company_name'] ?? ''); log_activity( $db, $quote['id'], 'submitted', "Quote submitted by {$contact_name} ({$contact_email}), company={$company_name}, monthly=\${$refreshed['monthly_total']}, setup=\${$refreshed['setup_total']}", null, $ip ); // Create notification record $notif_id = generate_uuid(); $sql_notif = sprintf( 'INSERT INTO %s (id, quote_id, notification_type, recipient, subject, body, status, created_at) VALUES (:id, :qid, :type, :recipient, :subject, :body, :status, :created_at)', tbl_notif() ); $stmt_n = $db->prepare($sql_notif); $stmt_n->execute([ ':id' => $notif_id, ':qid' => $quote['id'], ':type' => 'email', ':recipient' => ADMIN_NOTIFICATION_EMAIL, ':subject' => "New Quote Submission: {$company_name}", ':body' => "Quote submitted by {$contact_name}. Monthly: \${$refreshed['monthly_total']}", ':status' => 'pending', ':created_at' => $now, ]); $db->commit(); } catch (PDOException $e) { $db->rollBack(); app_log('ERROR', 'Failed to submit quote: ' . $e->getMessage()); error_response('Failed to submit quote: ' . $e->getMessage(), 500); } return get_quote_by_token($db, $token); } /** * Update the most recent notification status for a quote. * * @param PDO $db Database connection. * @param string $quote_id Quote UUID. * @param string $status New status (sent, failed). * @param string|null $error Error message if failed. */ function update_notification_status(PDO $db, string $quote_id, string $status, ?string $error = null): void { $now = utc_now(); $sql = sprintf( 'UPDATE %s SET status = :status, attempts = attempts + 1, last_attempt_at = :now, sent_at = :sent, error_message = :err WHERE quote_id = :qid ORDER BY created_at DESC LIMIT 1', tbl_notif() ); $stmt = $db->prepare($sql); $stmt->execute([ ':status' => $status, ':now' => $now, ':sent' => $status === 'sent' ? $now : null, ':err' => $error, ':qid' => $quote_id, ]); } // -------------------------------------------------------------------------- // Admin functions // -------------------------------------------------------------------------- /** * List quotes with pagination and optional filters. * * @param PDO $db Database connection. * @param int $skip Offset. * @param int $limit Max results. * @param string|null $status Filter by status. * @param string|null $search Search in company_name, contact_name, contact_email. * @return array{quotes: array, total: int} */ function list_quotes(PDO $db, int $skip = 0, int $limit = 100, ?string $status = null, ?string $search = null): array { $where = []; $params = []; if ($status !== null && $status !== '') { $where[] = 'q.status = :status'; $params[':status'] = $status; } if ($search !== null && $search !== '') { $where[] = '(q.company_name LIKE :search OR q.contact_name LIKE :search2 OR q.contact_email LIKE :search3)'; $term = '%' . $search . '%'; $params[':search'] = $term; $params[':search2'] = $term; $params[':search3'] = $term; } $where_sql = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; // Get total count $count_sql = sprintf('SELECT COUNT(*) FROM %s q %s', tbl_quotes(), $where_sql); $stmt = $db->prepare($count_sql); $stmt->execute($params); $total = (int)$stmt->fetchColumn(); // Get paginated results with item count $list_sql = sprintf( 'SELECT q.*, (SELECT COUNT(*) FROM %s qi WHERE qi.quote_id = q.id) AS item_count FROM %s q %s ORDER BY q.created_at DESC LIMIT :lim OFFSET :off', tbl_items(), tbl_quotes(), $where_sql ); $stmt2 = $db->prepare($list_sql); foreach ($params as $k => $v) { $stmt2->bindValue($k, $v); } $stmt2->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt2->bindValue(':off', $skip, PDO::PARAM_INT); $stmt2->execute(); $rows = $stmt2->fetchAll(); $quotes = array_map(function ($row) { return [ 'id' => $row['id'], 'access_token' => $row['access_token'], 'status' => $row['status'], 'company_name' => $row['company_name'], 'contact_name' => $row['contact_name'], 'contact_email' => $row['contact_email'], 'employee_count' => $row['employee_count'] !== null ? (int)$row['employee_count'] : null, 'monthly_total' => number_format((float)$row['monthly_total'], 2, '.', ''), 'setup_total' => number_format((float)$row['setup_total'], 2, '.', ''), 'item_count' => (int)$row['item_count'], 'submitted_at' => format_datetime($row['submitted_at']), 'created_at' => format_datetime($row['created_at']), ]; }, $rows); return ['quotes' => $quotes, 'total' => $total]; } /** * Get dashboard statistics for quotes. * * @param PDO $db Database connection. * @return array Statistics matching QuoteStatsResponse schema. */ function get_stats(PDO $db): array { $tbl = tbl_quotes(); // Total quotes $stmt = $db->query("SELECT COUNT(*) FROM {$tbl}"); $total_quotes = (int)$stmt->fetchColumn(); // Quotes by status $stmt2 = $db->query("SELECT status, COUNT(*) as cnt FROM {$tbl} GROUP BY status"); $status_rows = $stmt2->fetchAll(); $quotes_by_status = []; foreach ($status_rows as $r) { $quotes_by_status[$r['status']] = (int)$r['cnt']; } // Total values for submitted quotes $placeholders = implode(',', array_fill(0, count(SUBMITTED_STATUSES), '?')); $val_sql = "SELECT COALESCE(SUM(monthly_total), 0), COALESCE(SUM(setup_total), 0) FROM {$tbl} WHERE status IN ({$placeholders})"; $stmt3 = $db->prepare($val_sql); $stmt3->execute(SUBMITTED_STATUSES); $vals = $stmt3->fetch(PDO::FETCH_NUM); $total_monthly_value = (float)$vals[0]; $total_setup_value = (float)$vals[1]; // Quotes this month $month_start = gmdate('Y-m-01 00:00:00'); $stmt4 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE created_at >= ?"); $stmt4->execute([$month_start]); $quotes_this_month = (int)$stmt4->fetchColumn(); // Submitted this month $stmt5 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE submitted_at >= ? AND submitted_at IS NOT NULL"); $stmt5->execute([$month_start]); $quotes_submitted_this_month = (int)$stmt5->fetchColumn(); // Calculations $submitted_count = 0; foreach (SUBMITTED_STATUSES as $s) { $submitted_count += ($quotes_by_status[$s] ?? 0); } $average_monthly_value = $submitted_count > 0 ? round($total_monthly_value / $submitted_count, 2) : 0.00; $draft_count = $quotes_by_status['draft'] ?? 0; $total_started = $draft_count + $submitted_count; $conversion_rate = $total_started > 0 ? round(($submitted_count / $total_started) * 100, 2) : 0.00; return [ 'total_quotes' => $total_quotes, 'quotes_by_status' => $quotes_by_status, 'total_monthly_value' => number_format($total_monthly_value, 2, '.', ''), 'total_setup_value' => number_format($total_setup_value, 2, '.', ''), 'quotes_this_month' => $quotes_this_month, 'quotes_submitted_this_month' => $quotes_submitted_this_month, 'average_monthly_value' => number_format($average_monthly_value, 2, '.', ''), 'conversion_rate' => number_format($conversion_rate, 2, '.', ''), ]; } /** * Admin update of a quote's status and/or expiration. * * @param PDO $db Database connection. * @param string $quote_id Quote UUID. * @param array $data Update data (status, expires_at). * @param string $admin_user Admin username for audit trail. * @return array Updated quote row. */ function admin_update_quote(PDO $db, string $quote_id, array $data, string $admin_user = 'admin'): array { $quote = get_quote_by_id($db, $quote_id); $changes = []; $sets = []; $params = [':id' => $quote_id]; if (isset($data['status']) && $data['status'] !== $quote['status']) { if (!in_array($data['status'], VALID_STATUSES, true)) { error_response("Invalid status: {$data['status']}", 400); } $old_status = $quote['status']; $sets[] = 'status = :status'; $params[':status'] = $data['status']; $changes[] = "status: {$old_status} -> {$data['status']}"; } if (isset($data['expires_at'])) { $sets[] = 'expires_at = :expires'; $params[':expires'] = $data['expires_at']; $changes[] = "expires_at: {$data['expires_at']}"; } if (!empty($sets)) { $sets[] = 'updated_at = :now'; $params[':now'] = utc_now(); $sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets)); $stmt = $db->prepare($sql); $stmt->execute($params); log_activity( $db, $quote_id, 'admin_update', "Admin update by {$admin_user}: " . implode(', ', $changes) ); } return get_quote_by_id($db, $quote_id); }