Compare commits

..

9 Commits

Author SHA1 Message Date
vamsi.balaga
5208e29905 fix: drop the 'to' label before Warranty End date in asset lists
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-05 16:40:18 +05:30
vamsi.balaga
25a635cda3 fix: send employee lookups as { id, first_name } and add Monthly allowance field
The runtime validates employee lookups against the lookup's storage
columns, so { id, name } fails with "incomplete selection" — field_51
and the other lookups go back to { id, first_name }. Also adds INIT's
new Monthly allowence field (field_73) and moves the placeholder repair
fields to field_80…field_83.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-05 16:34:25 +05:30
vamsi.balaga
086c397f78 feat: replace page buttons with a Rows selector on record views
Instead of Previous/Next paging, a Rows selector (5-100) sets how many
records the server returns, and the pager shows "Showing X of Y records".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 11:21:07 +05:30
vamsi.balaga
bd141e3585 feat: show the search box on every record view list
The backend searches its columns even though it marks every field
is_search: false, so the box was never shown. Render it always and keep
the field-name placeholder for views that do flag searchable fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 11:19:07 +05:30
vamsi.balaga
b8f50efaf0 fix: tidy the Employees list columns and export
- Show "Joined <date>" as the main text in the Date column, with the
  employee type below it (was the other way around)
- Hide the raw status column from the list; the Employment badge
  already shows Active / Inactive
- Drop the duplicate status column from the employee Excel export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-02 16:34:11 +05:30
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
vamsi.balaga
c4ccdbf7d3 fix: pre-fill the Edit Asset form with the asset's values
- Look up each Edit value under every key the details page reads, and
  fill the asset's Add Asset values from the INIT record view (asset
  table, de18263e-…), matched by Asset ID, serial number or identifier.
  Edited values still win.
- Match select values by value or label in any case or spacing, also
  against the builder's options; read dates and formatted costs into
  what the inputs hold; pick Asset Owner by name when there's no id.
- Take activity select options from the builder; show which asset each
  assignment and return row is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:44:53 +05:30
vamsi.balaga
c0749f50e4 fix: show Update values on the employee details page
The Update activity maps its fields into the employees table, but the
detail view and workflow record view return only the INIT values, so the
details page showed old data after an Update. Read the employee's table
row and use its values for the Update fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:38:01 +05:30
10 changed files with 307 additions and 67 deletions

View File

@ -144,6 +144,8 @@ export const ASSET_FIELDS = [
},
{ id: 'field_59', label: 'Asset Brand', type: 'text' },
{ id: 'field_61', label: 'Asset Location', type: 'text' },
// `label` keeps the builder's spelling; `formLabel` is what the app shows.
{ id: 'field_73', label: 'Monthly allowence', formLabel: 'Monthly allowance', type: 'number' },
]
/** INIT's photo field, whose uploads the runtime always accepts. */
@ -190,8 +192,7 @@ export const fetchAssetActivityFieldDefs = (activityId) =>
/**
* "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee
* data". The API sample shows only { id }; it is sent as { id, first_name }
* because the runtime checks lookups against their storage columns.
* data". Sent as { id, first_name } — see employeeLookup.
*/
export const ASSET_OWNER_FIELD = 'field_51'
@ -264,11 +265,9 @@ export const ASSET_ASSIGN_FIELDS = [
]
/**
* "Employee data": a lookup into the employee table. The runtime checks it
* against the lookup's storage columns (a Retired attempt failed with
* "missing required storage column 'first_name'"), so it carries
* { id, first_name }, not the { id, name } the API sample shows. It is picked
* from a list of active employees, so it is sent alongside the form data.
* "Employee data": a lookup into the employee table, sent as { id, first_name }
* like every employee lookup (see employeeLookup). It is picked from a list
* of active employees, so it is sent alongside the form data.
*/
export const ASSIGN_EMPLOYEE_FIELD = 'field_22'
@ -439,8 +438,17 @@ export const ASSET_LOST_FIELDS = [
/** "Last owner": a lookup into the employee table, like "Employee data". */
export const LOST_EMPLOYEE_FIELD = 'field_35'
/** An employee lookup value, in the shape the runtime validates. */
const employeeLookup = (employee) => ({ id: employee.id, first_name: employee.first_name })
/**
* An employee lookup value, in the shape the runtime validates: { id,
* first_name }, the lookup's storage columns. The employee table has no "name"
* column, so { id, name } fails with "… has an incomplete selection"
* (invalid_type), and omitting first_name fails with "missing required
* storage column 'first_name'".
*/
const employeeLookup = (employee) => ({
id: employee.id,
first_name: employee.first_name || employee.name,
})
/**
* Run a later activity (ASSIGNED, RETURNED, …) on an existing asset instance.
@ -584,21 +592,23 @@ const EDITED_FIELDS = [
* with the asset's instance_id.
*
* POST {APP_API_BASE_URL}/app/{APP_ID}/activity
* { activity_id, workflow_uuid, instance_id, data: { field_73 … field_76 } }
* { activity_id, workflow_uuid, instance_id, data: { field_80 … field_83 } }
* { activity_id, workflow_uuid, instance_id, data: { field_77 … field_79 } }
*
* PLACEHOLDER ids: add an "Under Repair" state and these two activities, with
* these fields, in the Zino builder, deploy, and put their ids here. Until
* then, running them fails.
* then, running them fails. (The send fields were field_73 … field_76, but the
* builder has since given field_73 to INIT's "Monthly allowence", so they
* moved to field_80 …; match whatever ids the builder assigns when adding.)
*/
export const ASSET_REPAIR_ACTIVITY_ID = 'TODO-send-for-repair-activity-id'
/** Send for Repair fields. */
export const ASSET_REPAIR_FIELDS = [
{ id: 'field_73', label: 'Repair date', type: 'date' },
{ id: 'field_74', label: 'Issue details', type: 'longtext' },
{ id: 'field_75', label: 'Repair vendor', type: 'text' },
{ id: 'field_76', label: 'Estimated cost', type: 'number' },
{ id: 'field_80', label: 'Repair date', type: 'date' },
{ id: 'field_81', label: 'Issue details', type: 'longtext' },
{ id: 'field_82', label: 'Repair vendor', type: 'text' },
{ id: 'field_83', label: 'Estimated cost', type: 'number' },
]
export const ASSET_REPAIRED_ACTIVITY_ID = 'TODO-repair-completed-activity-id'
@ -1209,7 +1219,7 @@ export const ASSET_RICH_COLUMNS = [
title: ['Warranty Status'],
subtitle: [
{ label: 'Warranty Start date', prefix: 'From' },
{ label: 'Warranty End date', prefix: 'to' },
'Warranty End date',
],
},
]
@ -1670,13 +1680,13 @@ export function assetEvents(config, row) {
)
}
if (present(val('field_73'))) {
if (present(val('field_80'))) {
add(
HISTORY_ACTION.REPAIR,
val('field_73'),
val('field_80'),
'',
joinParts(val('field_75'), present(val('field_76')) && `Estimate ${val('field_76')}`),
val('field_74'),
joinParts(val('field_82'), present(val('field_83')) && `Estimate ${val('field_83')}`),
val('field_81'),
)
}

View File

@ -357,10 +357,10 @@ export const EMPLOYEE_RICH_COLUMNS = [
{ key: 'contact', label: 'Contact', title: ['email'], subtitle: ['phone'] },
{ key: 'role', label: 'Role', title: ['designation'], subtitle: ['department'] },
{
key: 'type',
label: 'Type',
title: ['Employee type'],
subtitle: [{ label: 'joining_date', prefix: 'Joined' }],
key: 'date',
label: 'Date',
title: [{ label: 'joining_date', prefix: 'Joined' }],
subtitle: ['Employee type'],
},
]
@ -390,7 +390,11 @@ export async function fetchEmployeeListPage(templateUid, query = {}) {
.map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' }))
.filter((row) => filter === 'all' || row.employment.toLowerCase() === filter)
const fields = config?.fields ?? []
// The view's own "status" column isn't wanted in the list — the derived
// Employment badge below already shows each row's Active / Inactive state.
const fields = (config?.fields ?? []).filter(
(c) => ![c.field_key, c.output_label].some((l) => normLabel(l) === 'status'),
)
const totalPages = Math.max(1, Math.ceil(data.length / limit))
return {
config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] },

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,137 @@ 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',
'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

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import {
AssetStateBadge,
Avatar,
Button,
EmployeeStatusBadge,
EmptyState,
HistoryActionBadge,
@ -17,11 +16,16 @@ import { FileLinks, imageSources } from './FieldValue'
/**
* Renders a record view straight from the backend response: the columns are
* whatever `config.fields` returns, in that order, with the backend's own
* labels. Paging, sorting and search are handled by the server.
* labels. Sorting and search are handled by the server; instead of page
* buttons, a "Rows" selector sets how many records the server returns
* (search_query.limit), so picking 5 shows 5 rows and picking 100 shows 100.
* `toolbar` adds controls (e.g. a filter) next to the search box. A field with
* `sortable: false` can't be sorted. A rich column (see api/richColumns.js)
* shows several fields in one cell and sorts by its `sort_key`.
*/
/** How many rows the "Rows" selector can ask the server for. */
const ROW_OPTIONS = [5, 10, 25, 50, 100]
export default function RecordView({
fields,
rows,
@ -29,7 +33,7 @@ export default function RecordView({
loading,
error,
query,
setPage,
setLimit,
setSearch,
setSort,
rowKey = 'instance_id',
@ -46,6 +50,8 @@ export default function RecordView({
return () => clearTimeout(id)
}, [term, query.search, setSearch])
// Names the placeholder only: the backend searches its own columns whatever
// is_search says (it returns false on every field), so the box always shows.
// A rich column's fields stay searchable.
const searchable = fields
.flatMap((f) => (f.data_type === 'rich' ? [...f.title, ...f.subtitle] : [f]))
@ -59,20 +65,20 @@ export default function RecordView({
setSort(sortKey(field), dir)
}
const page = pagination?.page ?? 1
const totalPages = pagination?.total_pages ?? 1
const total = pagination?.total_count ?? rows.length
return (
<>
<Toolbar count={total} countLabel="records">
{searchable.length > 0 && (
<SearchInput
value={term}
onChange={setTerm}
placeholder={`Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`}
/>
)}
<SearchInput
value={term}
onChange={setTerm}
placeholder={
searchable.length > 0
? `Search by ${searchable.map((f) => displayLabel(f.output_label)).join(', ')}…`
: 'Search…'
}
/>
{toolbar}
{loading && <span className="toolbar__count">Loading…</span>}
</Toolbar>
@ -139,26 +145,26 @@ export default function RecordView({
</table>
</div>
{totalPages > 1 && (
{total > 0 && (
<div className="pager">
<Button
size="sm"
icon="chevronLeft"
disabled={page <= 1 || loading}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<label className="pager__label">
Rows{' '}
<select
className="pager__select"
value={query.limit}
disabled={loading}
onChange={(e) => setLimit(Number(e.target.value))}
>
{ROW_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<span className="pager__label">
Page {page} of {totalPages}
Showing {Math.min(rows.length, total)} of {total} records
</span>
<Button
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</>

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

@ -71,6 +71,7 @@ export default function useRecordView(
error,
query,
setPage: (page) => setQuery((q) => ({ ...q, page })),
setLimit: (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
setSearch: (search) => setQuery((q) => ({ ...q, search, page: 1 })),
setSort: (sortBy, sortDir) => setQuery((q) => ({ ...q, sortBy, sortDir, page: 1 })),
setFilter: (filter) => setQuery((q) => ({ ...q, filter, page: 1 })),

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 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>
</>
}
/>

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 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>
</>
}
/>

View File

@ -1197,6 +1197,17 @@ button {
color: var(--text-muted);
}
.pager__select {
margin-left: 4px;
padding: 4px 8px;
font-size: 12.5px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.cell-clamp {
max-width: 220px;
}