# Component giao diện

> Áp dụng cho `api_version: "2026-01"`.

Extension Worker UI (block, action `modal`/`fullscreen`, trang `render: "worker"`) chỉ dựng giao diện bằng các element `pos-*` dưới đây. POS vẽ mỗi element bằng component thật của POS, nên giao diện luôn đồng bộ với POS.

Trang iframe không dùng danh sách này; dùng HTML/React tự do, có thể kèm `@pancake-pos/ui` để giao diện giống POS.

## Quy tắc chung

- **Chỉ dùng element trong danh sách.** Thẻ HTML thường (`div`, `span`, `img`, `table`, `script`…) và element lạ không được vẽ; extension báo lỗi trong nhật ký của app.
- **Text** chỉ hiển thị khi nằm trong `pos-text`, `pos-heading`, `pos-badge`, `pos-link`, `pos-button`, `pos-banner`.
- **Không có** `style`, `className`, HTML thô. Màu sắc, khoảng cách, font theo POS.
- **URL** (`href`, `src`) chỉ chấp nhận `https://`.
- **Sự kiện** truyền giá trị qua `event.detail`. Trong JSX dùng thuộc tính `onClick`, `onChange`… như component thông thường. Với JS thuần, tên sự kiện là phần sau `on` viết thường: `onPageChange` → `addEventListener('pagechange', …)`.
- **Giới hạn** 2.000 element mỗi extension; `pos-table` tối đa 500 dòng mỗi lần render (dùng phân trang).
- Thuộc tính dạng mảng/đối tượng (`options`, `columns`, `rows`, `pagination`) truyền trực tiếp bằng JSX, không cần JSON.stringify.

```tsx
<pos-stack gap="small">
  <pos-text-field label="Mã phiếu" value={code} onChange={(e) => setCode(e.detail)} />
  <pos-button variant="primary" loading={saving} onClick={save}>Lưu</pos-button>
</pos-stack>
```

Không dùng framework thì dựng bằng DOM API của runtime (xem [Môi trường worker](/developers/apps/runtime-api#moi-truong-worker)):

```js
let code = '';

const field = document.createElement('pos-text-field');
field.label = 'Mã phiếu';
field.addEventListener('change', (e) => { code = e.detail; });

const button = document.createElement('pos-button');
button.variant = 'primary';
button.textContent = 'Lưu';
button.addEventListener('click', () => save(code));

const stack = document.createElement('pos-stack');
stack.gap = 'small';
stack.append(field, button);
document.body.append(stack);
```

## Màn hình chuẩn

POS đặt element theo layout chung của các trang POS; app chỉ chọn đúng element và thứ tự, không chỉnh màu hay khoảng cách.

- **Trang `app.page`:** `pos-page` là khung trang của POS. `heading` và `description` nằm ở dòng tiêu đề trên cùng, slot `actions` ở bên phải cạnh nút chính (`pos.page.setPrimaryAction`); nội dung xếp dọc trên nền xám.
- **Khối:** `pos-section` đặt thẳng trong `pos-page` là thẻ trắng; `pos-section` lồng trong `pos-section` là nhóm phẳng; trong block và modal, `pos-section` là thẻ trắng viền mảnh. `pos-table`, `pos-chart` đặt thẳng trong `pos-page` tự nằm trong thẻ trắng.
- **Số liệu:** `pos-stat` trong `pos-grid` 3–5 cột; không ghép `pos-section` với `pos-heading` để làm ô số liệu.
- **Lọc:** gom ô lọc vào một `pos-filter-bar` ở đầu trang hoặc đầu khối; khoảng ngày dùng `pos-date-range`. Lọc theo trạng thái dùng `pos-tabs` có `badge`: chip có số đếm như thanh trạng thái của các trang danh sách POS.
- **Nút:** nút công cụ (tải lại, lọc, cài đặt) là nút chỉ có icon kèm `tooltip`; nhiều thao tác phụ thì gom vào `pos-menu-button`. Trạng thái trong bảng dùng cột `format: 'badge'`; chi tiết một dòng mở trong `pos-drawer` hoặc `pos-modal` với `pos-description-list`, `pos-timeline`.
- **Không** lặp tiêu đề trang trong nội dung, không dùng emoji hay ký tự trang trí trong nhãn (dùng `tone` của `pos-stat`, `pos-badge`), không lồng `pos-section` quá hai cấp, không bọc từng element trong một `pos-section` riêng.
- Bỏ khỏi biểu đồ và bảng các dòng toàn số 0 hoặc không có dữ liệu; hiện tên thay vì mã (tra tên qua Open API).

### Trang báo cáo

```tsx
<pos-page description="Đối chiếu thời gian giao thực tế với SLA cam kết của ĐVVC" width="full">
  <pos-button slot="actions" icon="download" onClick={exportCsv}>Xuất</pos-button>
  <pos-filter-bar>
    <pos-date-range label="Ngày tạo đơn" value={range} onChange={(e) => e.detail && setRange(e.detail)} />
    <pos-button icon="refresh" tooltip="Tải lại" onClick={load} />
  </pos-filter-bar>
  <pos-grid columns={4}>
    <pos-stat label="Tỷ lệ đúng hạn" value={58.3} format="percent" help="12 đơn đã giao" />
    <pos-stat label="Đúng hạn" value={7} tone="success" selected={state === 'on_time'} onClick={() => setState('on_time')} />
    <pos-stat label="Giao trễ" value={5} tone="critical" selected={state === 'late'} onClick={() => setState('late')} />
    <pos-stat label="Phí ship" value={2450000} format="money" />
  </pos-grid>
  <pos-section heading="Theo ĐVVC">
    <pos-chart type="bar" xKey="carrier" data={byCarrier} series={[{ key: 'rate', label: 'Tỷ lệ đúng hạn (%)' }]} onSelect={(e) => setCarrier(e.detail.x)} />
  </pos-section>
</pos-page>
```

### Trang danh sách

```tsx
<pos-page width="full">
  <pos-button slot="actions" icon="upload" onClick={importFile}>Nhập</pos-button>
  <pos-menu-button slot="actions" items={[{ id: 'csv', label: 'Xuất CSV' }]} onSelect={exportAs} />
  <pos-section>
    <pos-filter-bar>
      <pos-text-field label="Tìm mã đơn" value={search} onChange={(e) => setSearch(e.detail)} />
      <pos-select label="Kho" options={warehouses} value={warehouse} onChange={(e) => setWarehouse(e.detail)} />
    </pos-filter-bar>
    <pos-tabs tabs={[{ id: 'all', label: 'Tất cả', badge: 169 }, { id: 'late', label: 'Giao trễ', badge: 12 }]} selected={status} onChange={(e) => setStatus(e.detail)}>
      <pos-table
        columns={[
          { key: 'code', title: 'Mã đơn', format: 'link' },
          { key: 'customer', title: 'Khách hàng', subtitle: 'phone' },
          { key: 'state', title: 'SLA', format: 'badge', tones: { 'Trễ': 'critical', 'Đúng hạn': 'success' } },
          { key: 'fee', title: 'Phí ship', format: 'money', align: 'right', sortable: true },
        ]}
        rows={rows}
        sort={sort}
        pagination={{ page, pageSize: 30, total }}
        onSortChange={(e) => setSort(e.detail)}
        onPageChange={(e) => setPage(e.detail.page)}
        onCellClick={(e) => setDetail(e.detail.row)}
      />
    </pos-tabs>
  </pos-section>
</pos-page>
```

```tsx
<pos-drawer open={!!detail} heading={detail?.code} onClose={() => setDetail(null)}>
  <pos-description-list items={[{ label: 'ĐVVC', value: detail?.carrier }, { label: 'Phí ship', value: detail?.fee, format: 'money' }]} />
  <pos-timeline items={detail?.events ?? []} />
</pos-drawer>
```

Nút chính của trang (ví dụ "Đồng bộ") đặt bằng `pos.page.setPrimaryAction`, luôn nằm cuối dòng tiêu đề. Xuất file (CSV, Excel) bằng [`pos.download`](/developers/apps/runtime-api#pos-download).

## Bố cục

### pos-stack

Xếp các phần tử con theo hàng dọc hoặc ngang.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `direction` | `'vertical' \| 'horizontal'` | `'vertical'` | Hướng xếp |
| `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Khoảng cách giữa phần tử |
| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'stretch'` | Căn theo trục phụ |
| `justify` | `'start' \| 'center' \| 'end' \| 'space-between'` | `'start'` | Căn theo trục chính |
| `wrap` | `boolean` | `false` | Cho xuống dòng (khi `horizontal`) |

### pos-grid

Lưới cột đều nhau; ô hẹp hơn 180 px thì tự xuống hàng (màn hình hẹp còn một cột).

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `columns` | `1–6` | `2` | Số cột |
| `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Khoảng cách |

### pos-section

Khối có tiêu đề. Đặt thẳng trong `pos-page` là thẻ trắng; lồng trong `pos-section` khác là nhóm phẳng; trong block và modal là thẻ trắng viền mảnh (xem [Màn hình chuẩn](#man-hinh-chuan)).

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `heading` | `string` | Tiêu đề |
| `description` | `string` | Mô tả ngắn dưới tiêu đề |

Slot `actions`: `pos-button`, `pos-link`, `pos-menu-button` hiển thị ở góc phải tiêu đề.

```tsx
<pos-section heading="Công nợ">
  <pos-button slot="actions" variant="tertiary" onClick={reload}>Tải lại</pos-button>
  <pos-text>...</pos-text>
</pos-section>
```

### pos-divider

Đường kẻ ngang. Không có thuộc tính.

## Hiển thị

### pos-text

| Thuộc tính | Kiểu | Mặc định |
|---|---|---|
| `tone` | `'default' \| 'subdued' \| 'success' \| 'warning' \| 'critical'` | `'default'` |
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` |
| `weight` | `'regular' \| 'semibold' \| 'bold'` | `'regular'` |

### pos-heading

| Thuộc tính | Kiểu | Mặc định |
|---|---|---|
| `level` | `1–4` | `3` |

Cỡ chữ: `1` 20 px (bằng tiêu đề trang), `2` 18 px, `3` 16 px (bằng tiêu đề khối), `4` 14 px.

### pos-badge

Nhãn trạng thái ngắn.

| Thuộc tính | Kiểu | Mặc định |
|---|---|---|
| `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` |

### pos-money

Hiển thị số tiền theo định dạng và đơn vị tiền tệ của shop.

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `value` | `number` | Số tiền |
| `currency` | `string` | Mã tiền tệ ISO 4217; mặc định là tiền tệ của shop |
| `tone` | như `pos-text` | Màu chữ |

### pos-link

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `href` | `string` | URL `https://`, luôn mở tab mới |

Sự kiện `onClick`: dùng khi link thực hiện thao tác trong app thay vì mở URL (không đặt `href`).

### pos-image

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `src` | `string` | | URL `https://` thuộc domain trong `network.allowed_origins`, hoặc `public/<tên file>` trỏ tới file trong thư mục `public/` của dự án |
| `alt` | `string` | | Văn bản thay thế |
| `width`, `height` | `number` | | Kích thước (px) |
| `fit` | `'cover' \| 'contain'` | `'cover'` | Cách co ảnh |

POS tải ảnh qua máy chủ trung gian của sandbox: chỉ nhận định dạng ảnh, tối đa 5 MB. Ảnh từ domain chưa khai không hiển thị (chế độ phát triển có cảnh báo).

### pos-description-list

Cặp nhãn – giá trị như khối thông tin đơn của POS (nhãn xám bên trái, giá trị bên phải); dùng trong modal, panel, khối chi tiết.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `items` | `Item[]` | `[]` | Tối đa 50 dòng |
| `columns` | `1–4` | `1` | Số cột; ô hẹp hơn 220 px thì xuống hàng |

```ts
type Item = {
  label: string;
  value: string | number | null;     // null hoặc rỗng hiện "—"
  format?: 'text' | 'number' | 'money' | 'percent' | 'date' | 'datetime' | 'badge';
  tone?: 'default' | 'info' | 'success' | 'warning' | 'critical';  // màu khi format là badge
};
```

```tsx
<pos-description-list columns={2} items={[
  { label: 'ĐVVC', value: 'GHN' },
  { label: 'Phí ship', value: 25000, format: 'money' },
  { label: 'SLA', value: 'Trễ 2 ngày', format: 'badge', tone: 'critical' },
]} />
```

### pos-timeline

Dòng thời gian: hành trình đơn, lịch sử thay đổi.

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `items` | `{ title: string; time?: string; description?: string; tone?: Tone }[]` | Tối đa 100 mốc; `time` dạng ISO 8601 được định dạng theo POS, chữ khác giữ nguyên; `tone` là màu chấm |

## Nhập liệu

Thuộc tính chung của mọi ô nhập:

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `label` | `string` | Nhãn |
| `value` | tuỳ component | Giá trị hiện tại (component có kiểm soát) |
| `disabled` | `boolean` | Khoá ô |
| `required` | `boolean` | Hiện dấu bắt buộc (không tự kiểm tra) |
| `error` | `string` | Thông báo lỗi dưới ô |
| `help` | `string` | Gợi ý dưới ô |

Sự kiện chung: `onChange` (`event.detail` là giá trị mới), `onBlur`.

POS hiện ngay chữ người dùng gõ rồi mới gửi `onChange` cho app, nên ô không giật dù app đang bận. Nếu app đặt `value` khác giá trị người dùng vừa nhập (ví dụ chuẩn hoá chữ hoa hoặc xóa ô), POS hiển thị giá trị của app. Nên đặt `value` ngay trong `onChange`; đổi `value` sau một lệnh `await` có thể ghi đè chữ người dùng đang gõ.

### pos-text-field

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `value` | `string` | |
| `placeholder` | `string` | |
| `multiline` | `boolean \| number` | Ô nhiều dòng; số là số dòng hiển thị |
| `maxLength` | `number` | Giới hạn ký tự |

### pos-number-field

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `value` | `number \| null` | |
| `min`, `max`, `step` | `number` | |
| `suffix` | `string` | Đơn vị hiển thị sau số, ví dụ `"%"` |

### pos-select

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `options` | `{ label: string; value: string; disabled?: boolean }[]` | Danh sách lựa chọn |
| `value` | `string \| string[]` | Mảng khi `multiple` |
| `multiple` | `boolean` | Chọn nhiều |
| `placeholder` | `string` | |
| `searchable` | `boolean` | Cho gõ tìm trong danh sách |

### pos-checkbox

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `checked` | `boolean` | |
| `label` | `string` | |

`onChange`: `event.detail` là `boolean`.

### pos-date-picker

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `value` | `string \| null` | Ngày dạng `YYYY-MM-DD` |
| `min`, `max` | `string` | Giới hạn ngày chọn, dạng `YYYY-MM-DD` |

### pos-date-range

Khoảng ngày như bộ chọn thời gian của trang Thống kê, có sẵn lựa chọn nhanh: Hôm nay, Hôm qua, 7 ngày qua, 30 ngày qua, Đầu tuần đến nay, Đầu tháng đến nay, Tháng trước.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `value` | `{ from: string; to: string } \| null` | | Ngày dạng `YYYY-MM-DD` |
| `min`, `max` | `string` | | Giới hạn ngày chọn được |
| `presets` | `boolean` | `true` | Hiện lựa chọn nhanh |

Cùng các thuộc tính chung của ô nhập (`label`, `disabled`, `required`, `error`, `help`). Sự kiện `onChange`: `event.detail` là `{ from, to }` hoặc `null` khi xoá.

```tsx
<pos-date-range label="Ngày tạo đơn" value={range} onChange={(e) => e.detail && setRange(e.detail)} />
```

## Phản hồi

### pos-banner

Thông báo nổi bật trong extension.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `tone` | `'info' \| 'success' \| 'warning' \| 'critical'` | `'info'` | |
| `heading` | `string` | | Tiêu đề |
| `dismissible` | `boolean` | `false` | Hiện nút đóng |

Sự kiện `onDismiss`.

### pos-spinner

| Thuộc tính | Kiểu | Mặc định |
|---|---|---|
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` |

### pos-progress

Thanh tiến độ cho tỷ lệ (đúng hạn, hoàn thành chỉ tiêu).

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `value` | `number` | `0` | Phần trăm 0–100 |
| `label` | `string` | | Nhãn bên trái, phần trăm bên phải |
| `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` | Màu thanh |
| `showValue` | `boolean` | `true` | Hiện số phần trăm |

### pos-empty

Trạng thái trống như các trang POS: hình minh hoạ nhỏ, tiêu đề, mô tả và nút hành động.

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `heading` | `string` | Tiêu đề |
| `description` | `string` | Mô tả |

Slot `actions` cho `pos-button`, `pos-link`.

```tsx
<pos-empty heading="Chưa có đơn trễ" description="Đổi khoảng ngày để xem thêm">
  <pos-button slot="actions" onClick={reset}>Đặt lại bộ lọc</pos-button>
</pos-empty>
```

## Dữ liệu và thao tác

### pos-button

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'danger'` | `'secondary'` | Kiểu nút |
| `size` | `'small' \| 'medium'` | `'medium'` | |
| `loading` | `boolean` | `false` | Hiện trạng thái đang xử lý, chặn bấm |
| `disabled` | `boolean` | `false` | |
| `icon` | xem dưới | | Icon trước chữ; nút không có chữ là nút chỉ có icon |
| `tooltip` | `string` | | Chú thích khi rê chuột; bắt buộc với nút chỉ có icon (cũng là tên cho trình đọc màn hình) |

Sự kiện `onClick`.

Icon (bộ icon của POS): `plus`, `edit`, `delete`, `download`, `upload`, `refresh`, `filter`, `search`, `settings`, `print`, `copy`, `link`, `external`, `view`, `send`, `check`, `close`, `more`, `calendar`, `info`, `warning`.

```tsx
<pos-button icon="download" onClick={exportCsv}>Xuất</pos-button>
<pos-button icon="refresh" tooltip="Tải lại" onClick={reload} />
```

### pos-menu-button

Nút mở danh sách thao tác. Không có `label` thì là nút "⋯".

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `items` | `{ id: string; label: string; danger?: boolean; disabled?: boolean }[]` | `[]` | Tối đa 30 mục |
| `label` | `string` | | Chữ trên nút |
| `icon` | như `pos-button` | `more` khi không có `label` | |
| `variant` | `'primary' \| 'secondary' \| 'tertiary'` | `'secondary'` | |
| `size` | `'small' \| 'medium'` | `'medium'` | |
| `disabled` | `boolean` | `false` | |
| `tooltip` | `string` | | Chú thích khi rê chuột |

Sự kiện `onSelect`: `event.detail` là `id` của mục được bấm.

```tsx
<pos-menu-button slot="actions" items={[{ id: 'csv', label: 'Xuất CSV' }, { id: 'delete', label: 'Xoá', danger: true }]} onSelect={(e) => run(e.detail)} />
```

### pos-table

Bảng dữ liệu như bảng danh sách của POS. Ô chỉ chứa giá trị (chuỗi, số); cách hiển thị (định dạng, badge, link, dòng phụ) khai ở cột.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `columns` | `Column[]` | | Định nghĩa cột |
| `rows` | `object[]` | `[]` | Dữ liệu, tối đa 500 dòng |
| `rowKey` | `string` | `'id'` | Trường dùng làm khoá dòng |
| `loading` | `boolean` | `false` | |
| `pagination` | `{ page: number; pageSize: number; total: number } \| null` | `null` | Phân trang phía app |
| `emptyText` | `string` | | Nội dung khi không có dòng |
| `sort` | `{ key: string; order: 'asc' \| 'desc' } \| null` | `null` | Cột đang sắp xếp (app tự sắp dữ liệu hoặc gọi lại API) |
| `summary` | `object \| null` | `null` | Dòng tổng cuối bảng, giá trị theo `key` của cột, định dạng theo cột |
| `selectable` | `boolean` | `false` | Ô chọn đầu mỗi dòng |
| `selected` | `(string \| number)[]` | `[]` | Khoá các dòng đang chọn |

```ts
type Column = {
  key: string;                       // tên trường trong row
  title: string;
  align?: 'left' | 'center' | 'right';
  width?: number;                    // px
  format?: 'text' | 'number' | 'money' | 'percent' | 'date' | 'datetime' | 'badge' | 'link';
  tones?: Record<string, 'default' | 'info' | 'success' | 'warning' | 'critical'>;  // màu badge theo giá trị ô
  subtitle?: string;                 // trường hiện thành dòng phụ màu xám (ví dụ mã SKU dưới tên)
  sortable?: boolean;
  fixed?: 'left' | 'right';          // cố định cột khi cuộn ngang
  ellipsis?: boolean;                // cắt chữ dài trên một dòng
};
```

- `percent`: số là phần trăm (12.5 → 12,5%). `badge`: giá trị ô thành nhãn màu theo `tones`. `link`: chữ dạng link, bấm phát `onCellClick`.

Sự kiện:

- `onPageChange`: `event.detail` là `{ page, pageSize }`.
- `onRowClick`: `event.detail` là row được bấm.
- `onSortChange`: `event.detail` là `{ key, order }`, hoặc `null` khi bỏ sắp xếp.
- `onSelectionChange`: `event.detail` là mảng khoá các dòng đang chọn (giữ cả dòng ở trang khác).
- `onCellClick`: `event.detail` là `{ key, row }` khi bấm ô `format: 'link'`.

```tsx
<pos-table
  columns={[
    { key: 'system_id', title: 'Mã đơn' },
    { key: 'total_price', title: 'Tổng tiền', format: 'money', align: 'right' },
  ]}
  rows={orders}
  rowKey="system_id"
  pagination={{ page, pageSize: 20, total }}
  onPageChange={(e) => setPage(e.detail.page)}
/>
```

```tsx
<pos-table
  columns={[
    { key: 'name', title: 'Sản phẩm', subtitle: 'sku', sortable: true, fixed: 'left' },
    { key: 'state', title: 'SLA', format: 'badge', tones: { 'Trễ': 'critical', 'Đúng hạn': 'success' } },
    { key: 'code', title: 'Mã đơn', format: 'link' },
    { key: 'rate', title: 'Tỷ lệ', format: 'percent', align: 'right' },
  ]}
  rows={rows}
  sort={sort}
  summary={{ name: 'Tổng', rate: 62.5 }}
  selectable
  selected={selected}
  onSortChange={(e) => setSort(e.detail)}
  onSelectionChange={(e) => setSelected(e.detail)}
  onCellClick={(e) => openOrder(e.detail.row)}
/>
```

### pos-modal

Hộp thoại mở từ bên trong extension (ví dụ từ một nút trong block).

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `open` | `boolean` | Trạng thái mở |
| `heading` | `string` | Tiêu đề |

Slot `footer` cho các nút cuối hộp thoại. Sự kiện `onClose` khi người dùng bấm đóng hoặc ra ngoài.

```tsx
<pos-modal open={open} heading="Xác nhận" onClose={() => setOpen(false)}>
  <pos-text>Đẩy đơn sang ERP?</pos-text>
  <pos-button slot="footer" onClick={() => setOpen(false)}>Huỷ</pos-button>
  <pos-button slot="footer" variant="primary" onClick={push}>Đẩy</pos-button>
</pos-modal>
```

Action `presentation: "modal"` đã có sẵn modal của POS; không cần bọc thêm `pos-modal`.

### pos-drawer

Panel trượt từ cạnh phải, như "Cài đặt" của trang Thống kê: xem chi tiết một dòng mà không rời danh sách.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `open` | `boolean` | | Trạng thái mở |
| `heading` | `string` | | Tiêu đề |
| `width` | `'small' \| 'medium' \| 'large'` | `'medium'` | 400, 560 hoặc 800 px |

Slot `footer` cho các nút cuối panel. Sự kiện `onClose` khi người dùng bấm đóng hoặc ra ngoài.

## Trang Worker UI

Các element dưới đây dùng để dựng trang `render: "worker"` và màn báo cáo (xem [Extension point › app.page](/developers/apps/targets#app-page)); dùng được cả trong block và modal.

### pos-page

Khung trang. Trên trang `app.page`, `heading` và `description` hiện ở dòng tiêu đề của POS (không có `heading` thì dùng tiêu đề đặt bằng `pos.page.setTitle` hoặc `title` của extension), slot `actions` nằm bên phải cạnh nút chính, nội dung xếp dọc trên nền xám. Trong block và modal, `pos-page` là khối có tiêu đề.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `heading` | `string` | | Tiêu đề trang |
| `description` | `string` | | Mô tả dưới tiêu đề |
| `width` | `'narrow' \| 'default' \| 'full'` | `'default'` | Độ rộng nội dung: 720 px, tối đa 1.800 px hoặc toàn màn hình |

Slot `actions` cho `pos-button`, `pos-link`, `pos-menu-button`; trên trang `app.page` nằm trước nút chính.

### pos-tabs

Thanh chọn dạng chip có số đếm (`badge`), như thanh trạng thái của các trang danh sách POS. App giữ tab đang chọn và vẽ nội dung của tab đó làm element con.

| Thuộc tính | Kiểu | Mô tả |
|---|---|---|
| `tabs` | `{ id: string; label: string; badge?: number; disabled?: boolean }[]` | Tối đa 20 tab; `badge` là số hiện cạnh nhãn |
| `selected` | `string` | `id` của tab đang chọn |

Sự kiện `onChange`: `event.detail` là `id` của tab được chọn. POS đổi tab ngay khi bấm, không chờ app.

### pos-filter-bar

Hàng bộ lọc phía trên bảng hoặc biểu đồ. Element con: `pos-text-field`, `pos-number-field`, `pos-select`, `pos-checkbox`, `pos-date-picker`, `pos-date-range`, `pos-button`, `pos-menu-button`. Ô nhập vẽ gọn một hàng, `label` thành chữ gợi ý khi không có `placeholder`; hết chỗ thì xuống dòng.

```tsx
<pos-filter-bar>
  <pos-text-field label="Tìm mã đơn" value={search} onChange={(e) => setSearch(e.detail)} />
  <pos-select label="Nguồn đơn" options={sources} value={source} onChange={(e) => setSource(e.detail)} />
  <pos-date-picker label="Từ ngày" value={from} onChange={(e) => setFrom(e.detail)} />
  <pos-button onClick={reset}>Đặt lại</pos-button>
</pos-filter-bar>
```

### pos-chart

Biểu đồ do POS vẽ; app chỉ truyền dữ liệu. Màu theo bảng màu của POS.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `type` | `'line' \| 'bar' \| 'area' \| 'pie'` | `'line'` | Loại biểu đồ |
| `data` | `object[]` | `[]` | Mỗi điểm một object, giá trị chuỗi hoặc số; tối đa 500 điểm, 21 trường |
| `xKey` | `string` | | Trường làm trục ngang (tên lát với `pie`) |
| `series` | `{ key: string; label: string; format?: 'number' \| 'money' \| 'percent'; type?: 'bar' \| 'line' }[]` | `[]` | Tối đa 10 chuỗi số liệu; `pie` dùng chuỗi đầu tiên. `type` từng chuỗi (biểu đồ `bar`, `line`) để vẽ cột kết hợp đường |
| `selected` | `string \| number \| null` | | Giá trị trục ngang đang chọn: cột, lát khác mờ đi; biểu đồ đường, vùng có vạch đánh dấu |
| `stacked` | `boolean` | `false` | Chồng cột/vùng |
| `horizontal` | `boolean` | `false` | Cột nằm ngang (chỉ `bar`): hợp khi nhãn dài hoặc nhiều nhóm; chiều cao tự tăng theo số nhóm |
| `height` | `number` | `300` | 120–600 px |
| `loading` | `boolean` | `false` | |
| `emptyText` | `string` | | Nội dung khi không có dữ liệu |

- Sự kiện `onSelect`: có nghe thì bấm được vào cột, lát hoặc điểm; `event.detail` là `{ x, series }`: `x` là giá trị `xKey` được bấm, `series` là `key` của cột được bấm (`null` với `line`, `area`, `pie`).
- `format: 'percent'`: giá trị là phần trăm; mọi chuỗi đều là phần trăm thì trục số ghi thêm "%". Các chuỗi dùng chung một trục số, nên chỉ kết hợp những chuỗi cùng cỡ giá trị (số đơn với số đơn trễ, không phải số đơn với doanh thu).
- Nhãn trục ngang dài được cắt cho vừa chỗ, rê chuột để xem đủ; nhiều nhóm đến mức mỗi nhóm quá hẹp thì nhãn xoay nghiêng. Nên bỏ các nhóm toàn số 0 và gom nhóm nhỏ vào "Khác" trước khi truyền `data`.

```tsx
<pos-chart
  type="bar"
  xKey="day"
  data={[{ day: '01/10', revenue: 12500000, cost: 8000000 }, { day: '02/10', revenue: 9800000, cost: 7100000 }]}
  series={[{ key: 'revenue', label: 'Doanh thu', format: 'money' }, { key: 'cost', label: 'Giá vốn', format: 'money' }]}
/>
```

### pos-stat

Ô số liệu cho màn báo cáo: nhãn, giá trị lớn, dòng phụ và mức thay đổi so với kỳ trước. Đặt nhiều ô trong `pos-grid`. Trên nền trang là thẻ trắng; trong khối trắng (`pos-section`, modal, block) là ô nền xám nhạt.

| Thuộc tính | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `label` | `string` | | Tên chỉ số |
| `value` | `number \| string \| null` | | Số được định dạng theo `format`; chuỗi hiện nguyên văn; `null` hiện `—` |
| `format` | `'number' \| 'money' \| 'percent'` | `'number'` | `percent`: `value` là phần trăm (58.3 → 58,3%) |
| `currency` | `string` | tiền tệ của shop | Mã ISO 4217, dùng với `money` |
| `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` | Màu của giá trị |
| `help` | `string` | | Dòng phụ dưới giá trị, ví dụ "12 đơn đã giao" |
| `change` | `number` | | Mức thay đổi theo %, hiện kèm mũi tên; tăng là tốt (xanh), giảm là xấu (đỏ) |
| `changeLabel` | `string` | | Ghi chú cạnh mức thay đổi, ví dụ "so với tuần trước" |
| `inverse` | `boolean` | `false` | Đảo màu của `change` cho chỉ số càng thấp càng tốt (số đơn trễ, chi phí) |
| `selected` | `boolean` | `false` | Viền nổi, dùng khi thẻ đang làm bộ lọc |
| `loading` | `boolean` | `false` | Hiện khung chờ thay cho giá trị |

Sự kiện `onClick`: có nghe thì thẻ bấm được (cả bằng phím Enter), ví dụ bấm "Giao trễ" để lọc bảng bên dưới.

```tsx
<pos-grid columns={4}>
  <pos-stat label="Tỷ lệ đúng hạn" value={58.3} format="percent" help="12 đơn đã giao" change={4.2} changeLabel="so với tuần trước" />
  <pos-stat label="Giao trễ" value={5} tone="critical" change={12.5} inverse selected={filter === 'late'} onClick={() => setFilter('late')} />
  <pos-stat label="Phí hoàn" value={2450000} format="money" />
</pos-grid>
```
