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