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:
parent
97dc3b6286
commit
5dead10c22
@ -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'])
|
||||
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user