Skip to main content

12 — Employees: the drawers, dialogs, and exports

Read this first: lesson 11 decoded the grid; this lesson decodes everything stacked underneath it. The bottom of Employees.tsx is eight closed overlays waiting for a click — and reading them reveals two ideas the whole app leans on: a drawer that deliberately re-fetches a row the grid already has, and one column array that ships both a CSV and a PDF.

Time: about 35 minutes. Assumes lesson 11.

Eight satellites, one page​

Employees.tsx imports eight components from components/employees/. Each one is a specialist; the page just wires state to them.

ComponentRole
StatusBadge14-line status → color map (REGULAR green, PROBATIONARY yellow, INACTIVE red)
EmployeeCardCard-view tile for the lesson 10 view toggle
EmployeeFormDrawerCreate/edit form drawer
EmployeeDetailDrawerRead-only full record, opened by View or double-click
StatusConfirmDialog"Are you sure?" gate before deactivate/reactivate
EmployeeExportModalFull-DTO CSV export with a page/all scope choice
EmployeeBulkImportModalCSV import — lesson 13 owns it
AlphalistPreviewDialogBIR alphalist preview with PDF and .DAT downloads

EmployeeBulkImportModal gets one sentence here: it is one of the two import modals the next lesson compares. EmployeeFormDrawer gets two: it is a react-hook-form + Zod drawer whose employeeId: null means create mode, and lesson 21 decodes it field by field.

A drawer with no open state​

Look at how the page renders the detail drawer:

<EmployeeDetailDrawer employeeId={viewingId} onClose={() => { setViewingId(null); }} />

There is no open prop. Inside, the drawer derives it:

export const EmployeeDetailDrawer = ({ employeeId, onClose }: EmployeeDetailDrawerProps) => {
const { data: employee, isLoading } = useEmployee(employeeId);

return (
<Drawer.Root open={employeeId !== null} onOpenChange={({ open }) => { if (!open) onClose(); }} size="lg">

The rule: one state variable, not two. A separate open boolean plus an employeeId can disagree — open with no id, or an id with the drawer shut. employeeId: number | null cannot. null is closed; a number is open on that employee.

Now the strange part. The drawer calls useEmployee(employeeId) — a fresh network request:

export const useEmployee = (id: number | null) =>
useQuery({
queryKey: ['employees', id],
queryFn: () => getEmployee(id as number),
enabled: id !== null,
});

Predict: the user clicked a row. The grid already holds that row's data — the page even passes whole rows to other overlays. Why does this drawer go back to the server for a record it was just handed? Write your answer down before reading on.

The props interface answers in one line:

interface EmployeeDetailDrawerProps {
/** The grid row only carries the projected columns, so the full record is fetched here. */
employeeId: number | null;
onClose: () => void;
}

This is lesson 07's projection contract collecting its rent. The grid fetches with fields= — only the columns currently visible travel over the wire, and by default that is 8 of the 25. The clicked EmployeeRowDto has no username, no nested position object, and none of the hidden columns unless the user happened to switch them on. The drawer renders six sections of full record, so it fetches the full EmployeeDto. As a bonus, enabled: id !== null means the query fires exactly when the drawer opens and never otherwise, and the ['employees', id] cache key means reopening the same employee is instant.

Contrast StatusConfirmDialog, which takes the row directly — because it declares that a projection is enough:

/** Only the identifying fields, so a projected grid row can be passed straight in. */
type StatusConfirmTarget = Pick<EmployeeDto, 'employeeNumber' | 'firstName' | 'lastName' | 'status'>;

The rule: an overlay that needs the whole record takes an id and fetches; an overlay that needs a name and a status takes the row. The type tells you which.

Row actions computed from the row​

The kebab menu on each row is data, not JSX:

const rowActions = useMemo<GridRowAction<EmployeeRowDto>[]>(() => [
{ value: 'view', label: 'View', icon: <LuEye /> },
{ value: 'history', label: 'Activity History', icon: <LuHistory /> },
{ value: 'edit', label: 'Edit', icon: <LuPencil />, hidden: () => !canEdit, separator: true },
{
value: 'toggle-status',
label: row => row.status === 'INACTIVE' ? 'Reactivate' : 'Deactivate',
icon: row => row.status === 'INACTIVE' ? <LuUserCheck /> : <LuUserX />,
color: row => row.status === 'INACTIVE' ? 'green.600' : 'red.600',
hidden: () => !canToggleStatus,
},
// Archive and Restore are mutually exclusive — each hides itself when it doesn't apply.
{ value: 'archive', label: 'Archive', icon: <LuArchive />, hidden: row => !canToggleStatus || row.isArchived, separator: true },
{ value: 'restore', label: 'Restore', icon: <LuArchiveRestore />, hidden: row => !canToggleStatus || !row.isArchived },
], [canEdit, canToggleStatus]);

Three things carry the design. label, icon, and color accept functions of the row, so one entry renders as green "Reactivate" or red "Deactivate" without two menu items fighting over visibility. hidden gates on permissions from lesson 03 — no canToggleStatus, no status or archive actions at all. And Archive/Restore each hide on the row's isArchived flag, so exactly one of the pair ever appears.

Every mutation that lands funnels through one refresh function, and its comment explains why it is not narrower:

/** Archive/restore and status changes all move rows between tabs, so every tab's cache and
* every tab count goes stale — not just the active one's. */
const refreshEverything = () => {
refreshAllTabs();
void queryClient.invalidateQueries({ queryKey: [GRID_STORAGE_KEY, 'count'] });
};

Deactivating an employee removes them from the Regular tab and adds them to Inactive and changes two tab counts. Refreshing only the visible grid would leave the other tabs lying until the next full reload.

Bulk actions: a bar you can read in one breath​

The selection bar is a shared component, and it is short enough to quote whole:

import type { ReactNode } from 'react';
import { Badge, Button, HStack } from '@chakra-ui/react';

export interface BulkActionBarProps {
count: number;
onClear: () => void;
children?: ReactNode;
}

/**
* Generalizes the inline selection-count bar already used in Employees.tsx/Timesheets.tsx/
* LeaveRequests.tsx/OvertimeRequests.tsx into one shared component instead of a 5th-9th copy.
* Renders nothing when `count` is 0.
*/
export const BulkActionBar = ({ count, onClear, children }: BulkActionBarProps) => {
if (count === 0) return null;
return (
<HStack gap={3} p={3} bg="blue.subtle" borderRadius="md" wrap="wrap">
<Badge colorPalette="blue">{count} selected</Badge>
{children}
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
</HStack>
);
};

The doc comment is the justification: this bar existed as four inline copies before someone was about to write the fifth. The bar owns the count, the badge, and Clear; each page brings its own buttons as children. Employees brings three — a Set Status menu that fans one mutation across every selected row, Archive Selected, and this:

<Button size="sm" variant="outline" onClick={() => { activeApi()?.exportDataAsCsv({ onlySelected: true }); }}>
<LuDownload /> Export Selected
</Button>

exportDataAsCsv({ onlySelected: true }) is AG Grid's built-in client-side export: it writes exactly the selected rows, with exactly the columns the grid currently holds. No network request — which is precisely right for "these 6 rows I hand-picked", and precisely wrong for everything else. Hold that thought.

Exports that match what you are looking at​

The toolbar's CSV and PDF buttons cannot use the grid's client-side export. The grid holds one page of rows in a projection of visible columns; an export should be the whole filtered dataset in every column. So both buttons go back to the server:

/** Both exporters scope to the active tab + its current column filters, like the grid does,
* and always request every column: the CSV writes them all, and the PDF then narrows to the
* visible ones without needing a second round-trip. */
const fetchExportRows = async () => {
const filterModel = (activeApi()?.getFilterModel() ?? {}) as Record<string, unknown>;
return listEmployeeRows(buildParamsForTab(activeTab, EXPORT_FIELDS)(filterModel, [], 0, 10000));
};

Read what it does: grab the live filter model from the active tab's grid, rebuild the same query params the grid itself would send, and refetch up to 10,000 rows with EXPORT_FIELDS — a constant listing all 25 projectable columns, annotated for exports — which must not be narrowed by the View menu. The export is scoped like the screen (same tab, same filters) but widened to the full record. Filters you set are honored; columns you hid are not lost.

The shared column array carries its own policy in a comment:

// Hidden grid columns are still exported — an export is the whole record, not the view.
// Money stays unformatted and dates stay ISO so the file re-imports and sorts cleanly.

The CSV writes all of those columns. The PDF does not, and the reason is quoted straight from the handler:

const result = await fetchExportRows();
// Unlike the CSV, the PDF prints only the columns currently on screen. All 25 columns on
// one A4 page shreds every header to one letter per line and stretches a single employee
// across a whole page — a printout has a hard width budget that a spreadsheet does not.
const displayed = new Set(
(activeApi()?.getAllDisplayedColumns() ?? []).map(column => column.getColId()),
);
const pdfColumns = displayed.size
? exportColumns.filter(column => displayed.has(column.field))
: exportColumns;

A spreadsheet scrolls sideways; paper does not. So the PDF asks the grid which columns are displayed and filters the same exportColumns array down to them. One fetch, one array, two shapes of output — which only works because the two exporters agreed on a contract:

/** Same shape as CsvColumn in ./csv, so a page can hand the SAME column array to both exporters. */
export interface PdfColumn<T> {
header: string;
get: (row: T) => string;
}

The rule: PdfColumn is shape-identical to CsvColumn on purpose. A page defines { field, header, get } once and feeds both downloadCsv and downloadTablePdf. And pdf.ts records why it is not built like the payslip PDF:

/*
* Deliberately NOT the approach used by payslipPdf.ts — that rasterizes a DOM node to a canvas
* image, which suits a single fixed-layout document but produces unsearchable, poorly-paginating
* output for an arbitrary-length grid export. autoTable emits selectable text and handles page
* breaks and repeated headers itself.
*/

Two more exporters live behind the toolbar's table-actions menu, one paragraph each way. EmployeeExportModal is the "Export Full Details" path: it offers a Current Page scope (the grid's client-side CSV again) or All Employees, which refetches whole EmployeeDtos — the page passes it a getFilters callback with the note No fields: the full-details export needs whole EmployeeDtos, not grid columns. AlphalistPreviewDialog is the compliance path: it renders the BIR alphalist for a chosen year as an on-screen template, then downloads it as a PDF (via the rasterizing renderNodeToPdfBlob — a fixed-layout document, exactly the case pdf.ts said that approach suits) or as the .DAT file BIR's validation module ingests.

Last satellite: ActivityHistoryDrawer, the per-record audit trail. It is not employees-specific — it lives in ui/ag-grid/ and takes a resourcePath like /api/employees/10005. Its doc comment explains the whole mechanism: it is backed by the existing global audit log, and because the server search is a substring LIKE, it fetches by resourcePath then filters client-side to exact-segment matches (so "/26" never matches "/260"). No new backend endpoint; one drawer works for any resource.

What this page cannot do​

Close the tab and everything above evaporates. viewingId, the open drawer, the active tab, the filter model — all React state, plus a slice of localStorage for view options and saved views. There is no ?employee=10005 in the address bar, no way to send a colleague a link to "the Inactive tab filtered to Engineering", and a refresh drops you back at the default view. The analytics pages hit this exact wall and solved it by moving state into the URL — lesson 19 shows how, and why this page has not followed yet.

Where this shows up in MotorPH​

Recap​

  • An overlay controlled by id | null cannot desync — null is closed, a number is open, and there is no second open boolean to disagree with it.
  • The detail drawer re-fetches because the grid row is a projection — only visible columns travel with the grid's fields=, so any view that renders the full record must fetch the full record.
  • Server exports read the live filter model and refetch with EXPORT_FIELDS — the file matches what the user is looking at, scoped by their filters but never narrowed by their View menu; onlySelected client export is only for hand-picked rows.
  • One column array ships both files because PdfColumn mirrors CsvColumn by design — the CSV writes all columns, the PDF filters the same array to displayed ones, since paper has a width budget a spreadsheet does not.
  • Mutations that move rows between tabs must refresh every tab and every count — invalidating only the visible grid leaves the other tabs stale.

Next: 13 — Two import modals, and which page to copy.