Pola
Form dokumen
Membuat dan mengajukan dokumen: letak aksi, bagian bernomor, ringkasan nilai, kelengkapan, dan mode baca setelah diajukan.
Contoh
Lengkapi ketiga syarat di kolom kanan, lalu tekan Ajukan ke Finance untuk melihat mode baca.
Order & pengadaan › PO Customer
PO-2026-0146
DraftPT Mitra Energi Persada · Rp 45.000.000 · Pemilik Anisa Nugraha
1 · Customer & dokumen
Sesuai nomor di dokumen PO customer.
2 · Nilai
empat puluh lima juta rupiah
3 · Penagihan
Sekaligus = satu invoice setelah serah terima. Berulang = kontrak berperiode.
4 · Lampiran & catatan
(opsional)
0/500
import { useState, type ReactNode } from 'react';
import {
AmountInput,
Button,
Checkbox,
FormField,
Input,
Radio,
RadioGroup,
Select,
StateLabel,
Textarea,
formatMoney,
terbilang,
} from '@divistant/circca-react';
import { CheckCircle, Circle } from '@phosphor-icons/react';
// Pola form dokumen — Figma BizOps for IT Services › 07 Template › Form PO.
// Aksi di kanan atas, bagian bernomor, ringkasan + kelengkapan di kanan, mode baca setelah diajukan.
const CUSTOMER = [{ value: 'mep', label: 'PT Mitra Energi Persada' }];
const PEMILIK = [{ value: 'an', label: 'Anisa Nugraha' }];
const TEMPLATE_TERMIN = [
{ value: '50-50', label: '50% saat kick-off · 50% setelah BAST' },
{ value: '30-70', label: '30% saat PO terbit · 70% setelah BAST' },
];
function Bagian({ nomor, judul, children }: { nomor: number; judul: string; children: ReactNode }) {
return (
<section className="grid gap-4 rounded-2xl border-[0.5px] border-default bg-default p-5">
<h4 className="type-headline-default text-default">
{nomor} · {judul}
</h4>
{children}
</section>
);
}
export default function Example() {
const [diajukan, setDiajukan] = useState(false);
const [noPo, setNoPo] = useState('PO/MEP/X/2026/0042');
const [nilai, setNilai] = useState<number | null>(45_000_000);
const [cara, setCara] = useState('termin');
const [ttd, setTtd] = useState(false);
const dpp = Math.round(((nilai ?? 0) * 11) / 12);
const ppn = Math.round(dpp * 0.12);
const syarat = [
{ label: 'Customer, nomor & nilai kontrak', ok: Boolean(noPo.trim() && nilai) },
{ label: 'Cara penagihan dipilih', ok: Boolean(cara) },
{ label: 'PO customer bertanda tangan', ok: ttd },
];
const terpenuhi = syarat.filter(s => s.ok).length;
const lengkap = terpenuhi === syarat.length;
return (
<div className="@container grid gap-5">
{/* Kepala dokumen: jejak, nomor + status, meta, aksi di kanan atas */}
<header className="flex flex-wrap items-start gap-x-6 gap-y-3">
<div className="grid min-w-0 flex-1 gap-1">
<p className="type-callout-default text-muted">Order & pengadaan › PO Customer</p>
<div className="flex flex-wrap items-center gap-2">
<h3 className="type-heading-large text-default">PO-2026-0146</h3>
{diajukan ? <StateLabel tone="info">Diajukan ke Finance</StateLabel> : <StateLabel tone="neutral">Draft</StateLabel>}
</div>
<p className="type-callout-default text-muted">
PT Mitra Energi Persada · {formatMoney(nilai ?? 0)} · Pemilik Anisa Nugraha
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{diajukan ? (
<Button onClick={() => setDiajukan(false)}>Tarik kembali</Button>
) : (
<>
<Button variant="ghost">Batal</Button>
<Button>Simpan draf</Button>
<Button variant="primary" disabled={!lengkap} onClick={() => setDiajukan(true)}>
Ajukan ke Finance
</Button>
</>
)}
</div>
</header>
<div className="grid items-start gap-5 @3xl:grid-cols-[minmax(0,1fr)_260px]">
<div className="grid gap-4">
<Bagian nomor={1} judul="Customer & dokumen">
<div className="grid gap-4 @lg:grid-cols-2">
<FormField label="Customer" required readOnly={diajukan}>
<Select options={CUSTOMER} defaultValue="mep" />
</FormField>
<FormField label="No. PO customer" required readOnly={diajukan} helperText="Sesuai nomor di dokumen PO customer.">
<Input value={noPo} onChange={e => setNoPo(e.target.value)} />
</FormField>
<FormField label="Pemilik" readOnly={diajukan}>
<Select options={PEMILIK} defaultValue="an" />
</FormField>
</div>
</Bagian>
<Bagian nomor={2} judul="Nilai">
<FormField
label="Nilai kontrak (sebelum PPN)"
required
readOnly={diajukan}
helperText={nilai ? `${terbilang(nilai)} rupiah` : 'Isi nilai sesuai PO customer.'}
>
<AmountInput value={nilai} onValueChange={setNilai} />
</FormField>
</Bagian>
<Bagian nomor={3} judul="Penagihan">
<FormField label="Cara penagihan" required readOnly={diajukan} helperText="Sekaligus = satu invoice setelah serah terima. Berulang = kontrak berperiode.">
<RadioGroup value={cara} onValueChange={setCara} orientation="horizontal">
<Radio value="termin">Termin</Radio>
<Radio value="sekaligus">Sekaligus</Radio>
<Radio value="berulang">Berulang</Radio>
</RadioGroup>
</FormField>
{cara === 'termin' && (
<FormField label="Template termin" readOnly={diajukan}>
<Select options={TEMPLATE_TERMIN} defaultValue="50-50" />
</FormField>
)}
</Bagian>
<Bagian nomor={4} judul="Lampiran & catatan">
<Checkbox checked={ttd} onCheckedChange={setTtd} readOnly={diajukan}>
PO customer yang dilampirkan sudah bertanda tangan
</Checkbox>
<FormField label="Catatan untuk Finance" optional readOnly={diajukan}>
<Textarea maxLength={500} placeholder="Mis. alasan harga khusus atau tanggal kick-off" />
</FormField>
</Bagian>
</div>
{/* Kolom kanan: ringkasan nilai + kelengkapan (menjelaskan kenapa Ajukan belum aktif) */}
<aside className="grid gap-4 @3xl:sticky @3xl:top-4">
<dl className="grid gap-1.5 type-callout-default">
{[
['Subtotal', nilai ?? 0],
['DPP nilai lain (11/12)', dpp],
['PPN 12%', ppn],
].map(([label, value]) => (
<div key={label} className="flex justify-between gap-3 text-muted">
<dt>{label}</dt>
<dd className="tabular-nums text-default">{formatMoney(value as number)}</dd>
</div>
))}
<div className="mt-1 flex justify-between gap-3 border-t-[0.5px] border-default pt-2 type-body-default-semibold">
<dt>Total</dt>
<dd className="tabular-nums">{formatMoney((nilai ?? 0) + ppn)}</dd>
</div>
</dl>
<section className="grid gap-3 rounded-2xl border-[0.5px] border-default bg-default p-4">
<div className="grid gap-0.5">
<h4 className="type-headline-default text-default">Kelengkapan sebelum diajukan</h4>
<p className="type-footnote-default text-muted">
{terpenuhi} dari {syarat.length} terpenuhi
</p>
</div>
<ul className="grid gap-2">
{syarat.map(s => (
<li key={s.label} className="flex items-start gap-2 type-callout-default">
{s.ok ? (
<CheckCircle size={16} weight="fill" className="mt-px shrink-0 text-accent-fg" aria-label="Terpenuhi" />
) : (
<Circle size={16} className="mt-px shrink-0 text-subtle" aria-label="Belum" />
)}
<span className={s.ok ? 'text-muted' : 'text-default'}>{s.label}</span>
</li>
))}
</ul>
</section>
</aside>
</div>
</div>
);
}Aturan
- 1Aksi di kanan atasForm halaman penuh menaruh aksi di kepala dokumen, kanan atas: Batal (Ghost) · Simpan draf (Secondary) · aksi utama (Primary) paling kanan. Form pendek di dalam sheet atau dialog menaruh tombol di kanan bawah.
- 2Satu aksi utama yang menyebut hasilnya"Ajukan ke Finance", "Terbitkan BAST" — bukan "Submit" atau "OK". Hanya satu Primary per layar.
- 3Kepala dokumen konsistenJejak modul, nomor dokumen + label status di satu baris, lalu baris meta: customer · nilai · pemilik. Sama di mode isi dan mode baca.
- 4Bagian bernomorKelompokkan isian menurut urutan kerja ("1 · Customer & dokumen", "2 · Item", "3 · Penagihan"). Pasangan isian pendek boleh dua kolom; isian panjang selebar bagian.
- 5Ringkasan & kelengkapan di kananTotal nilai (subtotal, DPP, PPN, total) dan daftar syarat sebelum diajukan. Bila aksi utama belum aktif, daftar ini yang menjelaskan alasannya.
- 6Setelah diajukan: mode bacaIsian menjadi readOnly — teks tetap kontras penuh dan bisa disalin. Jangan memakai disabled untuk dokumen yang sudah diajukan.
Wajib & opsional
Tandai yang lebih jarang. Pada form dokumen bisnis kebanyakan isian wajib, jadi beri tanda (opsional) pada yang tidak wajib dan tanda * pada yang wajib hanya bila form didominasi isian opsional. Jangan memakai keduanya sekaligus dalam satu form.