-
+require_once '../../../includes/modal_header.php';
+
+enforceUserPermission('module_sales');
+
+// Pre-filled from the recurring invoices page's current filters. Everything here is editable -
+// the export runs whatever this form posts, not whatever the page happened to show.
+$client_id = intval($_GET['client_id'] ?? 0);
+if ($client_id) {
+ enforceClientAccess();
+}
+
+// Search Filter
+$q_filter = $_GET['q'] ?? '';
+
+// Status Filter
+$status_filter = $_GET['status'] ?? '';
+
+// Date Filter - the all-time sentinels from filter_header.php leave the fields blank
+$date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : '';
+$date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : '';
+
+ob_start();
+
+?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/agent/modals/ticket/ticket_export.php b/agent/modals/ticket/ticket_export.php
index 14148105..2493028c 100644
--- a/agent/modals/ticket/ticket_export.php
+++ b/agent/modals/ticket/ticket_export.php
@@ -2,27 +2,214 @@
require_once '../../../includes/modal_header.php';
+enforceUserPermission('module_support');
+
+// Pre-filled from the tickets page's current filters. Everything here is editable -
+// the export runs whatever this form posts, not whatever the page happened to show.
$client_id = intval($_GET['client_id'] ?? 0);
+if ($client_id) {
+ enforceClientAccess();
+}
+
+// Search Filter
+$q_filter = $_GET['q'] ?? '';
+
+// Status Filter - the page uses an ID set, or the Open / Closed shorthand
+$status_filter = (isset($_GET['status']) && is_array($_GET['status'])) ? array_map('intval', $_GET['status']) : [];
+$status_shorthand = (isset($_GET['status']) && !is_array($_GET['status'])) ? $_GET['status'] : '';
+
+// Client Filter
+$client_filter = intval($_GET['client'] ?? 0);
+
+// Category Filter
+$category_filter = intval($_GET['category'] ?? 0);
+
+// Assigned To Filter
+$assigned_filter = intval($_GET['assigned'] ?? 0);
+
+// SLA Filter
+$sla_filter = $_GET['sla'] ?? '';
+
+// Date Filter - the all-time sentinels from filter_header.php leave the fields blank
+$date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : '';
+$date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : '';
ob_start();
?>
+
+
+
diff --git a/agent/modals/transaction/transaction_export.php b/agent/modals/transaction/transaction_export.php
index aba81fb4..1fd5ec55 100644
--- a/agent/modals/transaction/transaction_export.php
+++ b/agent/modals/transaction/transaction_export.php
@@ -76,16 +76,20 @@ ob_start();
?>
+
+
diff --git a/agent/modals/trip/trip_export.php b/agent/modals/trip/trip_export.php
index 50ca572d..54467491 100644
--- a/agent/modals/trip/trip_export.php
+++ b/agent/modals/trip/trip_export.php
@@ -2,47 +2,78 @@
require_once '../../../includes/modal_header.php';
+enforceUserPermission('module_financial');
+
+// Pre-filled from the trips page's current filters. Everything here is editable -
+// the export runs whatever this form posts, not whatever the page happened to show.
$client_id = intval($_GET['client_id'] ?? 0);
+if ($client_id) {
+ enforceClientAccess();
+}
+
+// Search Filter
+$q_filter = $_GET['q'] ?? '';
+
+// Date Filter - the all-time sentinels from filter_header.php leave the fields blank
+$date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : '';
+$date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : '';
ob_start();
?>
+
+
+
diff --git a/agent/modals/vendor/vendor_export.php b/agent/modals/vendor/vendor_export.php
index 05fda2e8..76189827 100644
--- a/agent/modals/vendor/vendor_export.php
+++ b/agent/modals/vendor/vendor_export.php
@@ -2,28 +2,70 @@
require_once '../../../includes/modal_header.php';
+enforceUserPermission('module_client');
+
+// Pre-filled from the vendors page's current filters. Everything here is editable -
+// the export runs whatever this form posts, not whatever the page happened to show.
$client_id = intval($_GET['client_id'] ?? 0);
+if ($client_id) {
+ enforceClientAccess();
+}
+
+// Search Filter
+$q_filter = $_GET['q'] ?? '';
+
+// Archived Filter
+$archived_filter = (isset($_GET['archived']) && $_GET['archived'] == 1) ? 1 : 0;
ob_start();
?>
+
+
+
diff --git a/agent/networks.php b/agent/networks.php
index f9df1854..68d8dec5 100644
--- a/agent/networks.php
+++ b/agent/networks.php
@@ -83,7 +83,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
diff --git a/agent/software.php b/agent/software.php
index 8199c7e6..35c23096 100644
--- a/agent/software.php
+++ b/agent/software.php
@@ -94,7 +94,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
0) { ?>
+ data-modal-url="= buildExportModalUrl('modals/software/software_export.php', ['client_id', 'client', 'expire_days', 'archived', 'q']) ?>">
Export
diff --git a/agent/tickets.php b/agent/tickets.php
index 51a93a73..413a7466 100644
--- a/agent/tickets.php
+++ b/agent/tickets.php
@@ -203,7 +203,7 @@ $sql_categories_filter = mysqli_query(
diff --git a/agent/trips.php b/agent/trips.php
index b5c26695..cd0458a3 100644
--- a/agent/trips.php
+++ b/agent/trips.php
@@ -42,7 +42,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
diff --git a/agent/vendors.php b/agent/vendors.php
index 4d045718..b8f2bd32 100644
--- a/agent/vendors.php
+++ b/agent/vendors.php
@@ -50,7 +50,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
0) { ?>
+ data-modal-url="= buildExportModalUrl('modals/vendor/vendor_export.php', ['client_id', 'archived', 'q']) ?>">
Export
diff --git a/functions.php b/functions.php
index 43eeb44f..4730cb4c 100644
--- a/functions.php
+++ b/functions.php
@@ -23,3 +23,4 @@ require_once __DIR__ . '/functions/app.php';
require_once __DIR__ . '/functions/db.php';
require_once __DIR__ . '/functions/payments.php';
require_once __DIR__ . '/functions/sla.php';
+require_once __DIR__ . '/functions/export.php';
diff --git a/functions/export.php b/functions/export.php
new file mode 100644
index 00000000..617c59c0
--- /dev/null
+++ b/functions/export.php
@@ -0,0 +1,799 @@
+ [
+ '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;
+
+ ?>
+
+
+
+
+
+ "
+ *
+ * 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:
+ *
+ *
+ *