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={
-
+ <>
+
+
+ >
}
/>