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 <noreply@anthropic.com>
This commit is contained in:
vamsi.balaga 2026-10-29 16:25:25 +05:30
parent 97dc3b6286
commit 5dead10c22
4 changed files with 211 additions and 10 deletions

View File

@ -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'])

View File

@ -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 }) {

View File

@ -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={
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset
</Button>
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Downloading…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset
</Button>
</>
}
/>

View File

@ -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={
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Employee
</Button>
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Employee
</Button>
</>
}
/>