# 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);
```

## 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; trên màn hình hẹp tự chuyển về 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

Khung có tiêu đề, dùng làm vỏ ngoài của block.

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

Slot `actions`: nút 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` |

### 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).

## 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` |

## 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'` |

## 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` | |

Sự kiện `onClick`.

### pos-table

Bảng dữ liệu. Ô chỉ chứa giá trị (chuỗi, số); POS định dạng theo `format` của 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 |

```ts
type Column = {
  key: string;                       // tên trường trong row
  title: string;
  align?: 'left' | 'center' | 'right';
  width?: number;                    // px
  format?: 'text' | 'number' | 'money' | 'date' | 'datetime';
};
```

Sự kiện:

- `onPageChange`: `event.detail` là `{ page, pageSize }`.
- `onRowClick`: `event.detail` là row được bấm.

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

### 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`.

## Dự kiến bổ sung

`pos-page`, `pos-tabs`, `pos-filter-bar`, `pos-chart` (POS vẽ biểu đồ, app chỉ truyền dữ liệu), phục vụ trang Worker UI full màn hình.
