[ 'client_name' => ['label' => 'Client Name', 'weight' => 2], 'client_type' => ['label' => 'Industry'], 'client_referral' => ['label' => 'Referral'], 'client_website' => ['label' => 'Website', 'weight' => 2], 'location_name' => ['label' => 'Primary Location Name'], 'location_phone' => ['label' => 'Location Phone', 'format' => 'phone'], 'location_address' => ['label' => 'Location Address', 'weight' => 2], 'location_city' => ['label' => 'City'], 'location_state' => ['label' => 'State'], 'location_zip' => ['label' => 'Postal Code'], 'location_country' => ['label' => 'Country'], 'contact_name' => ['label' => 'Primary Contact Name'], 'contact_title' => ['label' => 'Title'], 'contact_phone' => ['label' => 'Contact Phone', 'format' => 'phone'], 'contact_extension' => ['label' => 'Extension'], 'contact_mobile' => ['label' => 'Contact Mobile', 'format' => 'phone'], 'contact_email' => ['label' => 'Contact Email', 'weight' => 2], 'client_rate' => ['label' => 'Hourly Rate', 'format' => 'money'], 'client_currency_code' => ['label' => 'Currency'], 'client_net_terms' => ['label' => 'Payment Terms', 'format' => 'number'], 'client_tax_id_number' => ['label' => 'Tax ID'], 'client_abbreviation' => ['label' => 'Abbreviation'], ], 'contacts' => [ 'contact_name' => ['label' => 'Name', 'weight' => 2], 'contact_title' => ['label' => 'Title'], 'contact_department' => ['label' => 'Department'], 'contact_email' => ['label' => 'Email', 'weight' => 2], 'contact_phone' => ['label' => 'Phone', 'format' => 'phone'], 'contact_extension' => ['label' => 'Ext'], 'contact_mobile' => ['label' => 'Mobile', 'format' => 'phone'], 'location_name' => ['label' => 'Location'], ], 'locations' => [ 'location_name' => ['label' => 'Name', 'weight' => 2], 'location_description' => ['label' => 'Description', 'weight' => 3], 'location_address' => ['label' => 'Address', 'weight' => 2], 'location_city' => ['label' => 'City'], 'location_state' => ['label' => 'State'], 'location_zip' => ['label' => 'Postal Code'], 'location_phone' => ['label' => 'Phone', 'format' => 'phone'], 'location_hours' => ['label' => 'Hours'], ], 'vendors' => [ 'vendor_name' => ['label' => 'Name', 'weight' => 2], 'vendor_description' => ['label' => 'Description', 'weight' => 3], 'vendor_contact_name' => ['label' => 'Contact Name'], 'vendor_phone' => ['label' => 'Phone', 'format' => 'phone'], 'vendor_website' => ['label' => 'Website', 'weight' => 2], 'vendor_account_number' => ['label' => 'Account Number'], 'vendor_notes' => ['label' => 'Notes', 'weight' => 3], ], 'assets' => [ 'asset_name' => ['label' => 'Name', 'weight' => 2], 'asset_description' => ['label' => 'Description', 'weight' => 3], 'asset_type' => ['label' => 'Type'], 'asset_make' => ['label' => 'Make'], 'asset_model' => ['label' => 'Model'], 'asset_serial' => ['label' => 'Serial Number'], 'asset_os' => ['label' => 'Operating System', 'weight' => 2], 'asset_purchase_date' => ['label' => 'Purchase Date'], 'asset_warranty_expire' => ['label' => 'Warranty Expire'], 'asset_install_date' => ['label' => 'Install Date'], 'contact_name' => ['label' => 'Assigned To'], 'location_name' => ['label' => 'Location'], 'asset_physical_location' => ['label' => 'Physical Location'], 'asset_notes' => ['label' => 'Notes', 'weight' => 3], // Available from the handler's join but not exported historically 'client_name' => ['label' => 'Client', 'default' => false], 'interface_ip' => ['label' => 'Primary IP', 'default' => false], 'interface_mac' => ['label' => 'Primary MAC', 'default' => false], ], 'asset_interfaces' => [ 'interface_name' => ['label' => 'Name'], 'interface_description' => ['label' => 'Description', 'weight' => 3], 'interface_type' => ['label' => 'Type'], 'interface_mac' => ['label' => 'MAC'], 'interface_ip' => ['label' => 'IP'], 'interface_nat_ip' => ['label' => 'NAT IP'], 'interface_ipv6' => ['label' => 'IPv6', 'weight' => 2], 'network_name' => ['label' => 'Network'], ], 'networks' => [ 'network_name' => ['label' => 'Name', 'weight' => 2], 'network_description' => ['label' => 'Description', 'weight' => 3], 'network_vlan' => ['label' => 'VLAN'], 'network' => ['label' => 'Network (CIDR)'], 'network_gateway' => ['label' => 'Gateway'], 'network_dhcp_range' => ['label' => 'IP Range'], 'network_primary_dns' => ['label' => 'Primary DNS'], 'network_secondary_dns' => ['label' => 'Secondary DNS'], ], 'certificates' => [ 'certificate_name' => ['label' => 'Name', 'weight' => 2], 'certificate_description' => ['label' => 'Description', 'weight' => 3], 'certificate_domain' => ['label' => 'Domain', 'weight' => 2], 'certificate_issued_by' => ['label' => 'Issuer', 'weight' => 2], 'certificate_expire' => ['label' => 'Expiration Date'], ], 'domains' => [ 'domain_name' => ['label' => 'Domain', 'weight' => 2], 'domain_description' => ['label' => 'Description', 'weight' => 3], // The columns hold vendor IDs; the handler aliases the joined names 'domain_registrar' => ['label' => 'Registrar', 'field' => 'domain_registrar_name'], 'domain_webhost' => ['label' => 'Web Host', 'field' => 'domain_webhost_name'], 'domain_expire' => ['label' => 'Expiration Date'], ], // Defaults match what this export has always emitted, secrets included. // The handler decrypts into credential_username / credential_password. 'credentials' => [ 'credential_name' => ['label' => 'Name', 'weight' => 2], 'credential_description' => ['label' => 'Description', 'weight' => 3], 'credential_username' => ['label' => 'Username'], 'credential_password' => ['label' => 'Password'], 'credential_otp_secret' => ['label' => 'TOTP'], 'credential_uri' => ['label' => 'URI', 'weight' => 2], ], // assigned_to_assets / assigned_to_contacts are built by the handler 'software' => [ 'software_name' => ['label' => 'Name', 'weight' => 2], 'software_version' => ['label' => 'Version'], 'software_description' => ['label' => 'Description', 'weight' => 3], 'software_type' => ['label' => 'Type'], 'software_license_type' => ['label' => 'License Type'], 'software_seats' => ['label' => 'Seats', 'format' => 'number'], 'software_key' => ['label' => 'Key', 'weight' => 2], 'assigned_to_assets' => ['label' => 'Assets', 'weight' => 2], 'assigned_to_contacts' => ['label' => 'Contacts', 'weight' => 2], 'software_purchase' => ['label' => 'Purchased'], 'software_expire' => ['label' => 'Expires'], 'software_notes' => ['label' => 'Notes', 'weight' => 3], ], // ticket_number_display is prefixed by the handler 'tickets' => [ 'ticket_number_display' => ['label' => 'Ticket Number'], 'ticket_priority' => ['label' => 'Priority'], 'ticket_status_name' => ['label' => 'Status'], 'ticket_subject' => ['label' => 'Subject', 'weight' => 3], 'ticket_created_at' => ['label' => 'Date Opened'], 'ticket_resolved_at' => ['label' => 'Date Resolved'], 'ticket_closed_at' => ['label' => 'Date Closed'], 'client_name' => ['label' => 'Client', 'default' => false], 'contact_name' => ['label' => 'Contact', 'default' => false], 'ticket_assigned_to' => ['label' => 'Assigned To', 'default' => false], 'ticket_category_name' => ['label' => 'Category', 'default' => false], 'ticket_billable' => ['label' => 'Billable', 'default' => false], ], // invoice_number_display is prefix . number, built by the handler 'invoices' => [ 'invoice_number_display' => ['label' => 'Invoice Number'], 'invoice_scope' => ['label' => 'Scope', 'weight' => 3], 'invoice_amount' => ['label' => 'Amount', 'format' => 'money'], 'invoice_date' => ['label' => 'Issued Date'], 'invoice_due' => ['label' => 'Due Date'], 'invoice_status' => ['label' => 'Status'], 'client_name' => ['label' => 'Client', 'weight' => 2], 'invoice_currency_code' => ['label' => 'Currency', 'default' => false], 'amount_paid' => ['label' => 'Paid', 'format' => 'money', 'default' => false], 'invoice_balance' => ['label' => 'Balance', 'format' => 'money', 'default' => false], ], 'quotes' => [ 'quote_number_display' => ['label' => 'Quote Number'], 'quote_scope' => ['label' => 'Scope', 'weight' => 3], 'quote_amount' => ['label' => 'Amount', 'format' => 'money'], 'quote_date' => ['label' => 'Date'], 'quote_status' => ['label' => 'Status'], 'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false], ], 'recurring_invoices' => [ 'recurring_invoice_number_display' => ['label' => 'Recurring Number'], 'recurring_invoice_scope' => ['label' => 'Scope', 'weight' => 3], 'recurring_invoice_amount' => ['label' => 'Amount', 'format' => 'money'], 'recurring_invoice_frequency_display' => ['label' => 'Frequency'], 'recurring_invoice_created_at' => ['label' => 'Date Created'], 'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false], ], 'products' => [ 'product_name' => ['label' => 'Product', 'weight' => 2], 'product_description' => ['label' => 'Description', 'weight' => 3], 'product_price' => ['label' => 'Price', 'format' => 'money'], 'product_currency_code' => ['label' => 'Currency'], 'category_name' => ['label' => 'Category'], 'tax_name' => ['label' => 'Tax'], ], 'expenses' => [ 'expense_date' => ['label' => 'Date'], 'expense_amount' => ['label' => 'Amount', 'format' => 'money'], 'vendor_name' => ['label' => 'Vendor', 'weight' => 2], 'expense_description' => ['label' => 'Description', 'weight' => 3], 'category_name' => ['label' => 'Category'], 'account_name' => ['label' => 'Account'], 'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false], 'expense_reference' => ['label' => 'Reference', 'default' => false], ], 'income' => [ 'income_date' => ['label' => 'Date'], 'income_type' => ['label' => 'Type'], 'income_source' => ['label' => 'Source'], 'income_description' => ['label' => 'Description', 'weight' => 3], 'income_client' => ['label' => 'Client', 'weight' => 2], 'income_amount' => ['label' => 'Amount', 'format' => 'money'], 'income_currency_code' => ['label' => 'Currency'], 'income_method' => ['label' => 'Payment Method'], 'income_reference' => ['label' => 'Reference'], 'income_account' => ['label' => 'Account'], ], 'transactions' => [ 'transaction_date' => ['label' => 'Date'], 'transaction_type' => ['label' => 'Type'], 'transaction_description' => ['label' => 'Description', 'weight' => 3], 'transaction_other_account' => ['label' => 'Transfer Account'], 'transaction_reference' => ['label' => 'Reference'], 'transaction_category' => ['label' => 'Category'], 'transaction_payment_method' => ['label' => 'Payment Method'], 'transaction_amount' => ['label' => 'Amount', 'format' => 'money'], 'transaction_balance' => ['label' => 'Balance', 'format' => 'money'], ], 'trips' => [ 'trip_date' => ['label' => 'Date'], 'trip_purpose' => ['label' => 'Purpose', 'weight' => 3], 'trip_source' => ['label' => 'Source', 'weight' => 2], 'trip_destination' => ['label' => 'Destination', 'weight' => 2], 'trip_miles' => ['label' => 'Miles', 'format' => 'number'], 'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false], ], // user_status_display is the status word, built by the handler 'users' => [ 'user_name' => ['label' => 'Name', 'weight' => 2], 'user_email' => ['label' => 'Email', 'weight' => 2], 'role_name' => ['label' => 'Role'], 'user_status_display' => ['label' => 'Status'], 'user_created_at' => ['label' => 'Creation Date'], ], ]; return $registry[$export_type] ?? []; } /* * Which columns the modal ticked, whitelisted against the registry. * Registry order always wins, so the file layout is stable no matter what order * the checkboxes came back in. Nothing ticked (or no picker on the form) falls * back to the export's defaults. */ function resolveExportColumns($export_type) { $available = getExportColumns($export_type); if (empty($available)) { return []; } $requested = $_POST['columns'] ?? []; if (!is_array($requested)) { $requested = []; } $selected = []; foreach ($available as $key => $column) { if (in_array($key, $requested, true)) { $selected[$key] = $column; } } if (empty($selected)) { foreach ($available as $key => $column) { if ($column['default'] ?? true) { $selected[$key] = $column; } } } return $selected; } /* * The export buttons post their format as the value of the trigger, so * $_POST['export_assets'] is the string 'csv' or 'pdf'. Anything else is CSV. */ function resolveExportFormat($format) { return ($format === 'pdf') ? 'pdf' : 'csv'; } /* * PDF is capped - see EXPORT_PDF_MAX_ROWS. Call this after the row count is known * and before beginExport(); it redirects rather than returning on refusal. */ function guardExportPdfRowCount($format, $num_rows) { if (resolveExportFormat($format) === 'pdf' && $num_rows > EXPORT_PDF_MAX_ROWS) { flashAlert("That's " . number_format($num_rows) . " rows - too many for a PDF. Narrow the filters or export to CSV instead.", 'error'); redirect(); } } /* * Presentation only. CSV keeps numbers raw so spreadsheets and importers still * see a number; the PDF is for reading, so it gets thousands separators. */ function formatExportValue($value, $format, $output) { // An empty field is empty, whatever its format - a blank amount is not 0.00 if ($value === null || $value === '') { return ''; } if ($format === 'phone') { return formatPhoneNumber($value); } if ($output === 'pdf' && $format === 'money') { return number_format(floatval($value), 2); } if ($output === 'pdf' && $format === 'number') { return rtrim(rtrim(number_format(floatval($value), 2), '0'), '.'); } return $value; } /* * Renders the column picker into an export modal. Drop it in the modal body: * * * * Posts back as columns[]. Silently renders nothing for an export type that has * no registry entry yet, so a half-converted modal still works. */ function renderExportColumnPicker($export_type) { $available = getExportColumns($export_type); if (empty($available)) { return; } // Modals get appended to the DOM, so the container id has to be unique per instance static $instance = 0; $instance++; $picker_id = 'exportColumns' . $instance; ?>
$column) { ?>
" * * Empty params are dropped and array params (tags[], status[]) survive. The return * value is escaped for use in an HTML attribute. */ function buildExportModalUrl($modal_path, $names, $extra = []) { $params = []; foreach ($names as $name) { if (!isset($_GET[$name]) || $_GET[$name] === '' || $_GET[$name] === []) { continue; } $params[$name] = $_GET[$name]; } // Filters the page derived rather than read straight off the query string - // filter_header.php's canned date ranges being the main one foreach ($extra as $name => $value) { if ($value !== '' && $value !== null && $value !== []) { $params[$name] = $value; } } if (empty($params)) { return escapeHtml($modal_path); } return escapeHtml($modal_path . '?' . http_build_query($params)); } /* * filter_header.php falls back to 1970-01-01 / 2099-12-31 when no date range is * chosen, so a naive summary would claim a filter that isn't really there. * Returns '' for the all-time case, otherwise a readable range. */ function formatExportDateRange($date_from, $date_to) { $all_time_from = ($date_from === '' || $date_from === null || $date_from === '1970-01-01' || $date_from === '0000-00-00'); $all_time_to = ($date_to === '' || $date_to === null || $date_to === '2099-12-31' || $date_to === '9999-00-00'); if ($all_time_from && $all_time_to) { return ''; } return $date_from . ' to ' . $date_to; } /* * Human-readable one-liner of the same thing, for the PDF subtitle. */ function summarizeExportFilters($filters) { $parts = []; foreach ($filters as $label => $value) { if ($value !== '' && $value !== null) { $parts[] = $label . ': ' . $value; } } return empty($parts) ? '' : 'Filters - ' . implode(' | ', $parts); } /* * The tabbed shell every export modal uses - Filters on the first pane, Columns on the * second, same pill nav as the add-client modal. Ids are per-instance because modals are * appended to the DOM rather than living in the page. * * Sits between the modal header and the form: * * *
* * * exportTabsColumns() closes the filters pane, renders the picker in the second pane and * closes the tab content, so the modal body needs nothing else. */ function exportTabsId() { static $instance = 0; static $current = ''; if (func_num_args() > 0 && func_get_arg(0) === 'next') { $instance++; $current = 'exportTab' . $instance; } return $current; } function exportTabsNav($filters_label = 'Filters') { $id = exportTabsId('next'); ?>
$format, 'columns' => $columns, 'title' => $title, 'subtitle' => $subtitle, 'filename' => sanitizeFilename($filename_base . '-' . date('Y-m-d_H-i-s') . '.' . $format), 'rows' => 0, 'fp' => null, 'body' => '', 'widths' => [], 'missing' => [], ]; if ($format === 'csv') { $export['fp'] = fopen('php://memory', 'w'); fputcsv($export['fp'], array_column($columns, 'label'), ',', '"', '\\'); } else { // TCPDF sizes a table from the cells it sees, so every row needs the widths - // putting them on the header alone leaves the body offset from its headings $total_weight = 0; foreach ($columns as $column) { $total_weight += $column['weight'] ?? 1; } foreach ($columns as $column_key => $column) { $export['widths'][$column_key] = round((($column['weight'] ?? 1) / $total_weight) * 100, 2); } } return $export; } /* * One row. $row is a plain associative array - normally straight out of * mysqli_fetch_assoc(), with any handler-computed keys already set on it. */ function addExportRow(&$export, $row) { $export['rows']++; $values = []; foreach ($export['columns'] as $column_key => $column) { $field = $column['field'] ?? $column_key; // A selected column whose field the query never returned would otherwise be a // silently blank column all the way down - note it so finishExport can shout if (!array_key_exists($field, $row)) { $export['missing'][$field] = true; } $values[$column_key] = formatExportValue($row[$field] ?? '', $column['format'] ?? '', $export['format']); } if ($export['format'] === 'csv') { fputcsv($export['fp'], array_map('escapeCsvFormula', $values), ',', '"', '\\'); return; } // PDF - buffer the row, the document is assembled in finishExport() $stripe = ($export['rows'] % 2 === 0) ? ' bgcolor="#f4f4f4"' : ''; $cells = ''; foreach ($export['columns'] as $column_key => $column) { $align = in_array($column['format'] ?? '', ['money', 'number'], true) ? 'right' : 'left'; $width = $export['widths'][$column_key] ?? 0; // Blank cells read as a mistake in a printed table - an explicit dash doesn't $cell = ($values[$column_key] === '') ? '-' : escapeHtml($values[$column_key]); $cells .= '' . $cell . ''; } $export['body'] .= '' . $cells . ''; } /* * A column whose field the query never selected produces a blank column rather than an * error, which is exactly the kind of thing that ships unnoticed. Nothing is shown to * the user - the file is still valid - but it lands in the PHP error log where a * conversion mistake will be spotted. */ function reportMissingExportFields(&$export) { if (!empty($export['missing'])) { error_log('ITFlow export: no such field in result set for column(s): ' . implode(', ', array_keys($export['missing']))); } } /* * Streams the finished file. Sends its own headers, so nothing may be echoed * before it; the caller still owns the logAudit() call and the exit. */ function finishExport(&$export) { global $session_company_name; reportMissingExportFields($export); if ($export['format'] === 'csv') { fseek($export['fp'], 0); header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="' . $export['filename'] . '";'); fpassthru($export['fp']); fclose($export['fp']); return $export['rows']; } require_once __DIR__ . '/../libs/TCPDF/tcpdf.php'; $column_count = count($export['columns']); // Anything past a handful of columns needs the long edge $orientation = $column_count > 5 ? 'L' : 'P'; // Shrink type as columns pile up rather than letting TCPDF wrap every cell if ($column_count <= 6) { $font_size = 8; } elseif ($column_count <= 9) { $font_size = 7; } elseif ($column_count <= 12) { $font_size = 6; } else { $font_size = 5; } $pdf = new TCPDF($orientation, 'mm', 'A4', true, 'UTF-8', false); $pdf->SetCreator(PDF_CREATOR); $pdf->SetAuthor($session_company_name); $pdf->SetTitle($export['title']); $pdf->SetPrintHeader(false); $pdf->SetMargins(8, 8, 8); $pdf->SetAutoPageBreak(true, 12); $pdf->AddPage(); $pdf->SetFont('freeserif', '', $font_size); // Same widths the body rows used - see beginExport() $head = ''; foreach ($export['columns'] as $column_key => $column) { $width = $export['widths'][$column_key] ?? 0; $head .= '' . escapeHtml($column['label']) . ''; } $html = '

' . escapeHtml($export['title']) . '

' . escapeHtml($session_company_name) . '

'; if (!empty($export['subtitle'])) { $html .= '

' . escapeHtml($export['subtitle']) . '

'; } $html .= '

' . $export['rows'] . ' record(s) - generated ' . date('Y-m-d H:i') . '


' . $head . '' . $export['body'] . '
'; $pdf->writeHTML($html, true, false, true, false, ''); $pdf->Output($export['filename'], 'D'); return $export['rows']; }