From 5dead10c22df6a374228fe7d98b747c1b0dc27e0 Mon Sep 17 00:00:00 2001 From: "vamsi.balaga" Date: Thu, 29 Oct 2026 16:25:25 +0530 Subject: [PATCH] feat: add Export button to the Assets and Employees lists Downloads every row (all pages) through Zino's record view export endpoint, using the same columns the Zino screens export. If the server emails the file instead of returning it, a notice says where it will go. Co-Authored-By: Claude Opus 5.5 --- src/api/recordView.js | 134 ++++++++++++++++++++++++++- src/components/ui/Icon.jsx | 1 + src/pages/assets/AssetList.jsx | 44 ++++++++- src/pages/employees/EmployeeList.jsx | 42 ++++++++- 4 files changed, 211 insertions(+), 10 deletions(-) diff --git a/src/api/recordView.js b/src/api/recordView.js index 063e472..e712471 100644 --- a/src/api/recordView.js +++ b/src/api/recordView.js @@ -1,4 +1,4 @@ -import { APP_API_BASE_URL, apiFetch } from './client' +import { APP_API_BASE_URL, ApiError, apiFetch, getStoredToken } from './client' /** * Record view data source. @@ -104,6 +104,138 @@ export async function fetchAllRecordRows(templateUid, query = {}) { return { config, rows } } +/** + * Columns the Zino "Asset" screen exports from the asset table view + * (RECORD_VIEWS.assetTable). Names are the backend's own, typos included + * (warrenty_end_date, warrenty_status). + */ +export const ASSET_EXPORT_COLUMNS = [ + 'monthly_allowance', + 'asset_classification', + 'warranty_start_date', + 'asset_identifier', + 'created_at', + 'updated_at', + 'asset_details', + 'model', + 'serial_number', + 'purchase_date', + 'purchase_cost', + 'asset_state', + 'warrenty_end_date', + 'hard_drive', + 'asset_owner', + 'image', + 'warrenty_status', + 'asset_brand', + 'asset_location', + 'cpu', +] + +/** Columns the Zino "Employee" screen exports from RECORD_VIEWS.employees. */ +export const EMPLOYEE_EXPORT_COLUMNS = [ + 'id', + 'employee_code', + 'first_name', + 'last_name', + 'email', + 'phone', + 'department', + 'designation', + 'joining_date', + 'status', + 'created_at', + 'updated_at', +] + +/** + * Download every row of a record view as a file, the way the Zino screen's + * export button does: + * + * POST {RUNTIME}/app/{APP_ID}/view/recordview/export + * { rv_template_uid, columns: [{ name, value_path }], preset_alias, params, search, filters } + * + * The file is saved as `fileName` unless the server names it + * (Content-Disposition). A JSON reply carrying a link is followed instead; one + * without a link means the file will be emailed. + * @returns {Promise<{emailed: boolean, data?: object}>} + */ +export async function downloadRecordView( + templateUid, + columns, + { fileName = 'export.xlsx', search = '', filters = [], params = {}, presetAlias = '' } = {}, +) { + const token = getStoredToken() + let response + try { + response = await fetch(`${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/recordview/export`, { + method: 'POST', + headers: { + accept: '*/*', + 'content-type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ + rv_template_uid: templateUid, + columns: columns.map((name) => ({ name, value_path: name })), + preset_alias: presetAlias, + params, + search, + filters, + }), + }) + } catch (err) { + throw new ApiError('Unable to reach the server. Check your connection and try again.', { + status: 0, + data: err, + }) + } + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new ApiError(text.trim() || `Export failed (${response.status})`, { + status: response.status, + data: text, + }) + } + + const type = response.headers.get('content-type') || '' + if (type.includes('application/json')) { + const data = await response.json() + const url = data?.url || data?.download_url || data?.data?.url || data?.data?.download_url + // No link: the export runs in the background and Zino emails the file to + // the signed-in user (the owner of the token). + if (!url) return { emailed: true, data } + saveFile(url, data?.file_name || fileName) + return { emailed: false } + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + saveFile(url, fileNameFrom(response.headers.get('content-disposition')) || fileName) + // Give the browser a moment to start the download before freeing the blob. + setTimeout(() => URL.revokeObjectURL(url), 1000) + return { emailed: false } +} + +/** 'attachment; filename="assets.xlsx"' -> 'assets.xlsx'. */ +function fileNameFrom(disposition) { + if (!disposition) return '' + const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(disposition) + if (utf8) return decodeURIComponent(utf8[1]) + const plain = /filename="?([^";]+)"?/i.exec(disposition) + return plain ? plain[1].trim() : '' +} + +function saveFile(url, fileName) { + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() +} + /** Field types the backend reports that should render as a date. */ const DATE_TYPES = new Set(['date', 'timestamp without time zone', 'timestamp with time zone']) diff --git a/src/components/ui/Icon.jsx b/src/components/ui/Icon.jsx index 7361793..0bdc29d 100644 --- a/src/components/ui/Icon.jsx +++ b/src/components/ui/Icon.jsx @@ -24,6 +24,7 @@ const PATHS = { repair: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z', laptop: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11H3V5zM2 20h20', logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', + download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', } export default function Icon({ name, size = 16, strokeWidth = 1.8, ...rest }) { diff --git a/src/pages/assets/AssetList.jsx b/src/pages/assets/AssetList.jsx index 62702f0..6d68f6c 100644 --- a/src/pages/assets/AssetList.jsx +++ b/src/pages/assets/AssetList.jsx @@ -3,8 +3,10 @@ import { useNavigate } from 'react-router-dom' import { Button, Card, Icon, PageHeader } from '../../components/ui' import RecordView from '../../components/activity/RecordView' import useRecordView from '../../hooks/useRecordView' -import { RECORD_VIEWS } from '../../api/recordView' +import { ASSET_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView' import { assetRowId, withAssetListDisplay } from '../../api/assets' +import { useAuth } from '../../store/AuthContext' +import { todayISO } from '../../utils/format' import AddAssetModal from './AddAssetModal' const VIEW = RECORD_VIEWS.assets @@ -20,9 +22,38 @@ export default function AssetList() { sortBy: 'idgen_2', sortDir: 'desc', }) + const { logout, user } = useAuth() const [adding, setAdding] = useState(false) + const [exporting, setExporting] = useState(false) const [notice, setNotice] = useState(null) + /** Downloads every asset (all pages) from the asset table view. */ + async function handleExport() { + setExporting(true) + try { + const { emailed } = await downloadRecordView( + RECORD_VIEWS.assetTable.templateUid, + ASSET_EXPORT_COLUMNS, + { fileName: `assets-${todayISO()}.xlsx` }, + ) + if (emailed) { + setNotice({ + tone: 'success', + text: `Export started. The file will be emailed to ${user?.email || 'you'}.`, + }) + } + } catch (err) { + if (err?.status === 401) { + logout() + navigate('/login', { replace: true }) + return + } + setNotice({ tone: 'danger', text: err?.message || 'Could not download the assets.' }) + } finally { + setExporting(false) + } + } + /** `values` is keyed by Zino field id — see ASSET_FIELDS in api/assets.js. */ function handleCreated(values) { setNotice({ @@ -39,9 +70,14 @@ export default function AssetList() { title="Assets" subtitle="Every device in the inventory, read live from the asset workflow." actions={ - + <> + + + } /> diff --git a/src/pages/employees/EmployeeList.jsx b/src/pages/employees/EmployeeList.jsx index 9ff4bee..abcb2d1 100644 --- a/src/pages/employees/EmployeeList.jsx +++ b/src/pages/employees/EmployeeList.jsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { Button, Card, Icon, PageHeader, SelectInput } from '../../components/ui' import RecordView from '../../components/activity/RecordView' import useRecordView from '../../hooks/useRecordView' -import { RECORD_VIEWS } from '../../api/recordView' +import { EMPLOYEE_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView' import { EMPLOYEE_RICH_COLUMNS, EMPLOYMENT_FILTERS, @@ -12,13 +12,14 @@ import { } from '../../api/employees' import { withRichColumns } from '../../api/richColumns' import { useAuth } from '../../store/AuthContext' +import { todayISO } from '../../utils/format' import AddEmployeeModal from './AddEmployeeModal' const VIEW = RECORD_VIEWS.employees export default function EmployeeList() { const navigate = useNavigate() - const { logout } = useAuth() + const { logout, user } = useAuth() // The table misses Update fields Zino doesn't map and knows nothing of the // workflow state, so this fetcher overlays both and filters Active/Inactive. // Related columns (name and code, email and phone…) are grouped into one. @@ -29,8 +30,34 @@ export default function EmployeeList() { }) const [adding, setAdding] = useState(false) const [opening, setOpening] = useState(false) + const [exporting, setExporting] = useState(false) const [notice, setNotice] = useState(null) + /** Exports every employee (all pages) from the employee view. */ + async function handleExport() { + setExporting(true) + try { + const { emailed } = await downloadRecordView(VIEW.templateUid, EMPLOYEE_EXPORT_COLUMNS, { + fileName: `employees-${todayISO()}.xlsx`, + }) + if (emailed) { + setNotice({ + tone: 'success', + text: `Export started. The file will be emailed to ${user?.email || 'you'}.`, + }) + } + } catch (err) { + if (err?.status === 401) { + logout() + navigate('/login', { replace: true }) + return + } + setNotice({ tone: 'danger', text: err?.message || 'Could not download the employees.' }) + } finally { + setExporting(false) + } + } + /** `values` is keyed by Zino field id — see EMPLOYEE_FIELDS in api/employees.js. */ function handleCreated(values) { const name = [values.field_1, values.field_34].filter(Boolean).join(' ').trim() @@ -78,9 +105,14 @@ export default function EmployeeList() { title="Employees" subtitle="All staff on record, read live from the employee workflow." actions={ - + <> + + + } />