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. * Record view data source.
@ -104,6 +104,138 @@ export async function fetchAllRecordRows(templateUid, query = {}) {
return { config, rows } 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. */ /** 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']) 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', 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', 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', 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 }) { 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 { Button, Card, Icon, PageHeader } from '../../components/ui'
import RecordView from '../../components/activity/RecordView' import RecordView from '../../components/activity/RecordView'
import useRecordView from '../../hooks/useRecordView' 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 { assetRowId, withAssetListDisplay } from '../../api/assets'
import { useAuth } from '../../store/AuthContext'
import { todayISO } from '../../utils/format'
import AddAssetModal from './AddAssetModal' import AddAssetModal from './AddAssetModal'
const VIEW = RECORD_VIEWS.assets const VIEW = RECORD_VIEWS.assets
@ -20,9 +22,38 @@ export default function AssetList() {
sortBy: 'idgen_2', sortBy: 'idgen_2',
sortDir: 'desc', sortDir: 'desc',
}) })
const { logout, user } = useAuth()
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [exporting, setExporting] = useState(false)
const [notice, setNotice] = useState(null) 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. */ /** `values` is keyed by Zino field id — see ASSET_FIELDS in api/assets.js. */
function handleCreated(values) { function handleCreated(values) {
setNotice({ setNotice({
@ -39,9 +70,14 @@ export default function AssetList() {
title="Assets" title="Assets"
subtitle="Every device in the inventory, read live from the asset workflow." subtitle="Every device in the inventory, read live from the asset workflow."
actions={ actions={
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Downloading…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}> <Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Asset Add Asset
</Button> </Button>
</>
} }
/> />

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Button, Card, Icon, PageHeader, SelectInput } from '../../components/ui' import { Button, Card, Icon, PageHeader, SelectInput } from '../../components/ui'
import RecordView from '../../components/activity/RecordView' import RecordView from '../../components/activity/RecordView'
import useRecordView from '../../hooks/useRecordView' import useRecordView from '../../hooks/useRecordView'
import { RECORD_VIEWS } from '../../api/recordView' import { EMPLOYEE_EXPORT_COLUMNS, RECORD_VIEWS, downloadRecordView } from '../../api/recordView'
import { import {
EMPLOYEE_RICH_COLUMNS, EMPLOYEE_RICH_COLUMNS,
EMPLOYMENT_FILTERS, EMPLOYMENT_FILTERS,
@ -12,13 +12,14 @@ import {
} from '../../api/employees' } from '../../api/employees'
import { withRichColumns } from '../../api/richColumns' import { withRichColumns } from '../../api/richColumns'
import { useAuth } from '../../store/AuthContext' import { useAuth } from '../../store/AuthContext'
import { todayISO } from '../../utils/format'
import AddEmployeeModal from './AddEmployeeModal' import AddEmployeeModal from './AddEmployeeModal'
const VIEW = RECORD_VIEWS.employees const VIEW = RECORD_VIEWS.employees
export default function EmployeeList() { export default function EmployeeList() {
const navigate = useNavigate() const navigate = useNavigate()
const { logout } = useAuth() const { logout, user } = useAuth()
// The table misses Update fields Zino doesn't map and knows nothing of the // 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. // workflow state, so this fetcher overlays both and filters Active/Inactive.
// Related columns (name and code, email and phone…) are grouped into one. // 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 [adding, setAdding] = useState(false)
const [opening, setOpening] = useState(false) const [opening, setOpening] = useState(false)
const [exporting, setExporting] = useState(false)
const [notice, setNotice] = useState(null) 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. */ /** `values` is keyed by Zino field id — see EMPLOYEE_FIELDS in api/employees.js. */
function handleCreated(values) { function handleCreated(values) {
const name = [values.field_1, values.field_34].filter(Boolean).join(' ').trim() const name = [values.field_1, values.field_34].filter(Boolean).join(' ').trim()
@ -78,9 +105,14 @@ export default function EmployeeList() {
title="Employees" title="Employees"
subtitle="All staff on record, read live from the employee workflow." subtitle="All staff on record, read live from the employee workflow."
actions={ actions={
<>
<Button icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export'}
</Button>
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}> <Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
Add Employee Add Employee
</Button> </Button>
</>
} }
/> />