Komponen / Tampilan data
DataTable
Tabel ERP untuk daftar dokumen (PO, invoice) dan tabel item dokumen: angka rata kanan, urut, seleksi baris, sel editable, pohon, baris total, keadaan kosong & memuat, dan paginasi.
Daftar dokumen
Daftar PO customer dengan urut terkendali, seleksi baris + bar aksi massal, tautan di No. PO, status StateLabel Small, aksi baris, dan DataTable.Pagination.
2 PO dipilih · Rp 173.000.000
| Kategori | Status | Aksi | ||||
|---|---|---|---|---|---|---|
| PO-2026-0148 | CV Sinar Logistik | Lisensi + jasa | 285.000.000 | Menunggu approval margin | ||
| PO-2026-0147 | PT Arunika Data | Lisensi | 96.500.000 | Diproses | ||
| PO-2026-0146 | PT Mitra Energi Persada | Jasa | 45.000.000 | Dikembalikan | ||
| PO-2026-0145 | PT Kawan Retail Indonesia | Lisensi + jasa | 412.750.000 | PO terbit | ||
| PO-2026-0144 | PT Samudra Farma | Lisensi | 128.000.000 | Menunggu BAST/DO | ||
| PO-2026-0143 | PT Graha Konstruksi Utama | Lisensi + jasa | 210.000.000 | Siap ditagih | ||
| PO-2026-0142 | PT Pelanggan Contoh A | Lisensi + jasa | 150.000.000 | Ditagih | ||
| PO-2026-0141 | PT Andalan Multifinance | Lisensi | 72.300.000 | Ditagih |
import { useMemo, useState } from 'react';
import {
Button,
DataTable,
IconButton,
StateLabel,
formatMoney,
type DataTableColumn,
type DataTableSort,
type StateLabelTone,
} from '@divistant/circca-react';
import { DotsThree } from '@phosphor-icons/react';
// Daftar dokumen — Figma BizOps for IT Services › Template · Daftar (29:982).
// No. PO = tautan (jalan keyboard ke detail), nilai rata kanan, status StateLabel Small, aksi baris IconButton Ghost.
type Po = { no: string; customer: string; kategori: string; nilai: number; status: string; tone: StateLabelTone };
const SEMUA: Po[] = [
{ no: 'PO-2026-0148', customer: 'CV Sinar Logistik', kategori: 'Lisensi + jasa', nilai: 285_000_000, status: 'Menunggu approval margin', tone: 'warning' },
{ no: 'PO-2026-0147', customer: 'PT Arunika Data', kategori: 'Lisensi', nilai: 96_500_000, status: 'Diproses', tone: 'info' },
{ no: 'PO-2026-0146', customer: 'PT Mitra Energi Persada', kategori: 'Jasa', nilai: 45_000_000, status: 'Dikembalikan', tone: 'warning' },
{ no: 'PO-2026-0145', customer: 'PT Kawan Retail Indonesia', kategori: 'Lisensi + jasa', nilai: 412_750_000, status: 'PO terbit', tone: 'accent' },
{ no: 'PO-2026-0144', customer: 'PT Samudra Farma', kategori: 'Lisensi', nilai: 128_000_000, status: 'Menunggu BAST/DO', tone: 'warning' },
{ no: 'PO-2026-0143', customer: 'PT Graha Konstruksi Utama', kategori: 'Lisensi + jasa', nilai: 210_000_000, status: 'Siap ditagih', tone: 'highlight' },
{ no: 'PO-2026-0142', customer: 'PT Pelanggan Contoh A', kategori: 'Lisensi + jasa', nilai: 150_000_000, status: 'Ditagih', tone: 'success' },
{ no: 'PO-2026-0141', customer: 'PT Andalan Multifinance', kategori: 'Lisensi', nilai: 72_300_000, status: 'Ditagih', tone: 'success' },
];
const columns: DataTableColumn<Po>[] = [
{
key: 'no',
header: 'No. PO',
rowHeader: true,
sortable: true,
width: 140,
cell: r => (
<a href={`#${r.no}`} className="text-accent-fg hover:underline">
{r.no}
</a>
),
},
{ key: 'customer', header: 'Customer', sortable: true, width: 220 },
{ key: 'kategori', header: 'Kategori', width: 120 },
{ key: 'nilai', header: 'Nilai (Rp)', type: 'number', sortable: true, width: 132 },
{
key: 'status',
header: 'Status',
width: 200,
cell: r => (
<StateLabel size="sm" tone={r.tone}>
{r.status}
</StateLabel>
),
},
{
key: 'aksi',
header: 'Aksi',
type: 'actions',
cell: r => <IconButton variant="ghost" size="sm" icon={DotsThree} label={`Aksi lain ${r.no}`} />,
},
];
export default function Example() {
const [sort, setSort] = useState<DataTableSort | null>({ key: 'no', direction: 'desc' });
const [selected, setSelected] = useState<string[]>(['PO-2026-0146', 'PO-2026-0144']);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// Pengurutan dilakukan pemanggil (di aplikasi nyata: parameter ke server).
const rows = useMemo(() => {
if (!sort) return SEMUA;
const key = sort.key as keyof Po;
const dir = sort.direction === 'asc' ? 1 : -1;
return [...SEMUA].sort((a, b) => (a[key] > b[key] ? dir : a[key] < b[key] ? -dir : 0));
}, [sort]);
const totalDipilih = SEMUA.filter(r => selected.includes(r.no)).reduce((s, r) => s + r.nilai, 0);
return (
<div className="grid w-full gap-2">
{selected.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-xl bg-accent-subtle px-3 py-1.5" aria-live="polite">
<p className="flex-1 type-body-default-semibold text-default">
{selected.length} PO dipilih · {formatMoney(totalDipilih)}
</p>
<Button size="sm">Ekspor</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected([])}>
Batalkan pilihan
</Button>
</div>
)}
<DataTable
framed
caption="PO customer"
captionHidden
minWidth={880}
columns={columns}
rows={rows}
getRowId={r => r.no}
getRowLabel={r => r.no}
sort={sort}
onSortChange={setSort}
selected={selected}
onSelectedChange={setSelected}
footer={
<DataTable.Pagination
page={page}
pageSize={pageSize}
total={142}
onPageChange={setPage}
onPageSizeChange={size => {
setPageSize(size);
setPage(1);
}}
/>
}
/>
</div>
);
}Tabel item dengan baris total
Item di halaman detail PO. Kolom angka rata kanan; baris total di <tfoot> dengan latar bg/subtle dan Semibold.
| Item / jasa | Kategori | Qty | Harga satuan (Rp) | Jumlah (Rp) |
|---|---|---|---|---|
| Lisensi Microsoft 365 Business Standard (12 bulan) | Lisensi | 50 | 2.350.000 | 117.500.000 |
| Implementasi & migrasi email | Jasa | 1 | 18.500.000 | 18.500.000 |
| Pelatihan admin (2 sesi) | Jasa | 2 | 4.250.000 | 8.500.000 |
| Total sebelum PPN | Rp 144.500.000 | |||
import { DataTable, formatMoney, formatNumber, type DataTableColumn } from '@divistant/circca-react';
// Tabel item di halaman detail PO — Figma BizOps for IT Services › Template · Detail (30:883).
// Kolom angka rata kanan + tabular-nums; baris total (DataTable/TotalRow) di <tfoot>.
type Item = { id: string; nama: string; kategori: string; qty: number; harga: number };
const ITEM: Item[] = [
{ id: '1', nama: 'Lisensi Microsoft 365 Business Standard (12 bulan)', kategori: 'Lisensi', qty: 50, harga: 2_350_000 },
{ id: '2', nama: 'Implementasi & migrasi email', kategori: 'Jasa', qty: 1, harga: 18_500_000 },
{ id: '3', nama: 'Pelatihan admin (2 sesi)', kategori: 'Jasa', qty: 2, harga: 4_250_000 },
];
const columns: DataTableColumn<Item>[] = [
{ key: 'nama', header: 'Item / jasa', width: 280 },
{ key: 'kategori', header: 'Kategori', width: 96 },
{ key: 'qty', header: 'Qty', type: 'number', width: 64 },
{ key: 'harga', header: 'Harga satuan (Rp)', type: 'number', width: 148, cell: r => formatNumber(r.harga) },
{ key: 'jumlah', header: 'Jumlah (Rp)', type: 'number', width: 148, cell: r => formatNumber(r.qty * r.harga) },
];
export default function Example() {
const total = ITEM.reduce((s, r) => s + r.qty * r.harga, 0);
return (
<DataTable
className="w-full"
caption="Item PO-2026-0145 · PT Kawan Retail Indonesia"
minWidth={680}
columns={columns}
rows={ITEM}
getRowId={r => r.id}
totalsLabel="Total sebelum PPN"
totals={{ jumlah: formatMoney(total) }}
/>
);
}Sel editable
Tabel item di form: sel `editable` berisi Input, Select, dan AmountInput ukuran Small, baris "Tambah item", aksi hapus per baris.
| Item / jasa | Kategori | Qty | Harga satuan (Rp) | Jumlah (Rp) | Hapus |
|---|---|---|---|---|---|
| 45.000.000 | |||||
| 34.000.000 | |||||
| Total | Rp 79.000.000 | ||||
import { useState } from 'react';
import {
AmountInput,
DataTable,
IconButton,
Input,
Select,
formatMoney,
formatNumber,
type DataTableColumn,
} from '@divistant/circca-react';
import { Trash } from '@phosphor-icons/react';
// Tabel item yang bisa diedit di form PO — Figma BizOps for IT Services › Template · Form (32:1370).
// Sel editable (type 'editable', padding 2) berisi kontrol Small 24: Input, AmountInput, Select.
// Baris "Tambah item" = DataTable/AddRow. Setiap kontrol punya aria-label yang menyebut barisnya.
type Item = { id: string; nama: string; kategori: string; qty: number | null; harga: number | null };
let seq = 3;
export default function Example() {
const [items, setItems] = useState<Item[]>([
{ id: '1', nama: 'Implementasi monitoring energi', kategori: 'jasa', qty: 1, harga: 45_000_000 },
{ id: '2', nama: 'Lisensi Grafana Enterprise (12 bulan)', kategori: 'lisensi', qty: 5, harga: 6_800_000 },
]);
const ubah = (id: string, patch: Partial<Item>) => setItems(list => list.map(r => (r.id === id ? { ...r, ...patch } : r)));
const nomor = (r: Item) => `item ${items.indexOf(r) + 1}`;
const columns: DataTableColumn<Item>[] = [
{
key: 'nama',
header: 'Item / jasa',
type: 'editable',
width: 240,
cell: r => <Input size="sm" aria-label={`Nama ${nomor(r)}`} value={r.nama} onChange={e => ubah(r.id, { nama: e.target.value })} />,
},
{
key: 'kategori',
header: 'Kategori',
type: 'editable',
width: 120,
cell: r => (
<Select
size="sm"
aria-label={`Kategori ${nomor(r)}`}
value={r.kategori}
onValueChange={v => ubah(r.id, { kategori: v })}
options={[
{ value: 'jasa', label: 'Jasa' },
{ value: 'lisensi', label: 'Lisensi' },
]}
/>
),
},
{
key: 'qty',
header: 'Qty',
type: 'editable',
align: 'end',
width: 72,
cell: r => (
<Input
size="sm"
inputMode="numeric"
aria-label={`Qty ${nomor(r)}`}
inputClassName="text-right tabular-nums"
value={r.qty ?? ''}
onChange={e => ubah(r.id, { qty: e.target.value === '' ? null : Number(e.target.value.replace(/\D/g, '')) })}
/>
),
},
{
key: 'harga',
header: 'Harga satuan (Rp)',
type: 'editable',
align: 'end',
width: 160,
cell: r => <AmountInput size="sm" aria-label={`Harga satuan ${nomor(r)}`} value={r.harga} onValueChange={v => ubah(r.id, { harga: v })} />,
},
{
key: 'jumlah',
header: 'Jumlah (Rp)',
type: 'number',
width: 132,
cell: r => formatNumber((r.qty ?? 0) * (r.harga ?? 0)),
},
{
key: 'hapus',
header: 'Hapus',
type: 'actions',
cell: r => (
<IconButton
variant="ghost"
size="sm"
icon={Trash}
label={`Hapus ${nomor(r)}`}
onClick={() => setItems(list => list.filter(x => x.id !== r.id))}
/>
),
},
];
const total = items.reduce((s, r) => s + (r.qty ?? 0) * (r.harga ?? 0), 0);
return (
<DataTable
className="w-full"
caption="Item PO customer"
captionHidden
minWidth={760}
columns={columns}
rows={items}
getRowId={r => r.id}
onAddRow={() => setItems(list => [...list, { id: String(seq++), nama: '', kategori: 'jasa', qty: 1, harga: null }])}
totals={{ jumlah: formatMoney(total) }}
/>
);
}Pohon (tree)
Paket lisensi → produk → add-on dengan tombol buka/tutup di kolom pertama. Density Compact (28).
| Produk | Qty | Nilai (Rp) |
|---|---|---|
| Paket Microsoft 365 — CV Sinar Logistik | 60 | 148.200.000 |
| Business Standard | 50 | 117.500.000 |
| Microsoft 365 E3 | 10 | 20.900.000 |
| Paket Adobe — PT Arunika Data | 12 | 52.400.000 |
import { DataTable, formatNumber, type DataTableColumn } from '@divistant/circca-react';
// Pohon (Tree L0/L1/L2): paket lisensi → produk → add-on. Indentasi 8/24/40, chevron 12 di kolom pertama.
// Density Compact (28) untuk tabel padat di panel.
type Baris = { id: string; nama: string; qty: number; nilai: number; anak?: Baris[] };
const DATA: Baris[] = [
{
id: 'm365',
nama: 'Paket Microsoft 365 — CV Sinar Logistik',
qty: 60,
nilai: 148_200_000,
anak: [
{
id: 'm365-bs',
nama: 'Business Standard',
qty: 50,
nilai: 117_500_000,
anak: [{ id: 'm365-bs-copilot', nama: 'Add-on Copilot', qty: 10, nilai: 9_800_000 }],
},
{ id: 'm365-e3', nama: 'Microsoft 365 E3', qty: 10, nilai: 20_900_000 },
],
},
{
id: 'adobe',
nama: 'Paket Adobe — PT Arunika Data',
qty: 12,
nilai: 52_400_000,
anak: [{ id: 'adobe-cc', nama: 'Creative Cloud All Apps', qty: 12, nilai: 52_400_000 }],
},
];
const columns: DataTableColumn<Baris>[] = [
{ key: 'nama', header: 'Produk', width: 300 },
{ key: 'qty', header: 'Qty', type: 'number', width: 64 },
{ key: 'nilai', header: 'Nilai (Rp)', type: 'number', width: 132, cell: r => formatNumber(r.nilai) },
];
export default function Example() {
return (
<DataTable
framed
className="w-full"
caption="Rincian lisensi per paket"
captionHidden
density="compact"
minWidth={520}
columns={columns}
rows={DATA}
getRowId={r => r.id}
getRowLabel={r => r.nama}
getSubRows={r => r.anak}
defaultExpanded={['m365']}
/>
);
}Memuat dan kosong
Baris Skeleton mengikuti kolom saat memuat; EmptyState di dalam tabel saat tidak ada hasil.
| No. invoice | Customer | Nilai (Rp) |
|---|
import { useState } from 'react';
import { Button, DataTable, EmptyState, SegmentedControl, type DataTableColumn } from '@divistant/circca-react';
import { FunnelSimple } from '@phosphor-icons/react';
// Memuat: baris Skeleton mengikuti kolom (angka rata kanan) dan tabel ber-aria-busy.
// Kosong: EmptyState di dalam tabel — jelaskan kenapa kosong dan beri jalan keluar.
type Invoice = { no: string; customer: string; nilai: number };
const columns: DataTableColumn<Invoice>[] = [
{ key: 'no', header: 'No. invoice', rowHeader: true, width: 140 },
{ key: 'customer', header: 'Customer', width: 220 },
{ key: 'nilai', header: 'Nilai (Rp)', type: 'number', width: 132 },
];
export default function Example() {
const [keadaan, setKeadaan] = useState('memuat');
return (
<div className="grid w-full gap-3">
<SegmentedControl
aria-label="Keadaan tabel"
size="sm"
value={keadaan}
onValueChange={setKeadaan}
items={[
{ value: 'memuat', label: 'Memuat' },
{ value: 'kosong', label: 'Kosong' },
]}
className="justify-self-start"
/>
<DataTable
framed
caption="Invoice jatuh tempo"
captionHidden
selected={[]}
onSelectedChange={() => {}}
columns={columns}
rows={[]}
getRowId={r => r.no}
loading={keadaan === 'memuat'}
loadingRows={4}
emptyState={
<EmptyState
size="compact"
icon={FunnelSimple}
title="Tidak ada invoice untuk ‘PT Arunika Data’"
description="Coba ubah periode atau hapus filter customer."
actions={<Button size="sm">Hapus filter</Button>}
/>
}
/>
</div>
);
}