Compare commits

...

2 Commits

Author SHA1 Message Date
vamsi.balaga
5dead10c22 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>
2026-10-29 16:25:25 +05:30
vamsi.balaga
97dc3b6286 fix: show Asset Brand and Location first on the Add Asset form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:31:39 +05:30
5 changed files with 222 additions and 13 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

@ -12,10 +12,18 @@ import {
import { emptyValues, validateActivity } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext'
// Asset Brand and Asset Location come first, as on the Edit form; the rest
// keep the builder's order.
const FIRST_FIELDS = ['field_59', 'field_61']
const FORM_FIELDS = [
...FIRST_FIELDS.map((id) => ASSET_FIELDS.find((f) => f.id === id)),
...ASSET_FIELDS.filter((f) => !FIRST_FIELDS.includes(f.id)),
]
// Asset Owner sits after Condition, as in the builder.
const OWNER_AFTER = ASSET_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = ASSET_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = ASSET_FIELDS.slice(OWNER_AFTER)
const OWNER_AFTER = FORM_FIELDS.findIndex((f) => f.id === 'field_47') + 1
const FIELDS_BEFORE_OWNER = FORM_FIELDS.slice(0, OWNER_AFTER)
const FIELDS_AFTER_OWNER = FORM_FIELDS.slice(OWNER_AFTER)
const upload = (fieldId, file) => uploadAssetFile(ASSET_INIT_ACTIVITY_ID, fieldId, file)

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