# Ví dụ

> Tên trường theo [Open API](/developers/api).

App mẫu **"Đồng bộ ERP"** gồm:

1. Block trong form đơn hiện trạng thái đồng bộ ERP của đơn.
2. Action headless "Đẩy sang ERP".
3. Action modal "Phiếu bảo hành" có form nhập.
4. Hook chặn lưu đơn trên 20 triệu khi thiếu số điện thoại.
5. Trang iframe "Đối soát ERP" liệt kê đơn.
6. Action hàng loạt đẩy các phiếu chuyển kho đang chọn sang ERP, cho thấy resource khác dùng y hệt.

ERP trong ví dụ là `https://erp.example.com`, do shop tự quản lý và đã bật CORS cho domain sandbox của app.

## Manifest

```json
{
  "handle": "erp-sync",
  "name": "Đồng bộ ERP",
  "version": "1.0.0",
  "api_version": "2026-01",
  "access": { "methods": ["GET", "PUT"] },
  "network": { "allowed_origins": ["https://erp.example.com"] },
  "extensions": [
    { "target": "nav.item", "id": "nav", "label": "Đối soát ERP", "page": "reconcile" },
    { "target": "app.page", "id": "reconcile", "title": "Đối soát ERP", "render": "iframe", "entry": "page/index.html" },
    {
      "target": "order.details.block",
      "id": "erp-status",
      "module": "extensions/erp-status.tsx",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.details.action",
      "id": "push-erp",
      "label": "Đẩy sang ERP",
      "presentation": "headless",
      "module": "extensions/push-erp.ts",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.details.action",
      "id": "warranty",
      "label": "Phiếu bảo hành",
      "presentation": "modal",
      "module": "extensions/warranty.tsx",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.before_submit",
      "id": "require-phone",
      "module": "extensions/require-phone.ts",
      "timeout_ms": 1000,
      "on_error": "allow"
    },
    {
      "target": "warehouse_transfer.index.selection-action",
      "id": "push-transfers",
      "label": "Đẩy sang ERP",
      "presentation": "headless",
      "module": "extensions/push-transfers.ts"
    }
  ]
}
```

## 1. Block trạng thái ERP

`extensions/erp-status.tsx`

```tsx
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';

type Status = { synced: boolean; erp_code?: string; synced_at?: string };

function ErpStatus({ systemId }: { systemId: number }) {
  const [status, setStatus] = useState<Status | null>(null);
  const [error, setError] = useState<string | null>(null);

  const load = async () => {
    setError(null);
    try {
      const res = await fetch(`https://erp.example.com/api/orders/${systemId}`);
      if (!res.ok) throw new Error(`ERP trả về ${res.status}`);
      setStatus(await res.json());
    } catch (e) {
      setError(e.message);
    }
  };

  useEffect(() => { load(); }, [systemId]);

  return (
    <pos-section heading="ERP">
      <pos-button slot="actions" variant="tertiary" onClick={load}>Tải lại</pos-button>
      {error && <pos-banner tone="critical">{error}</pos-banner>}
      {!error && !status && <pos-spinner size="small" />}
      {status && (
        <pos-stack direction="horizontal" gap="small" align="center">
          <pos-badge tone={status.synced ? 'success' : 'warning'}>
            {status.synced ? 'Đã đồng bộ' : 'Chưa đồng bộ'}
          </pos-badge>
          {status.erp_code && <pos-text tone="subdued">Mã ERP: {status.erp_code}</pos-text>}
        </pos-stack>
      )}
    </pos-section>
  );
}

export default async function (data) {
  render(<ErpStatus systemId={data.record.system_id} />, document.body);
}
```

## 2. Action headless "Đẩy sang ERP"

`extensions/push-erp.ts`

```ts
export default async function (data) {
  const shopId = pos.shop.id;
  const systemId = data.record.system_id;

  const { data: order } = await pos.api.get(`/shops/${shopId}/orders/${systemId}`);

  const res = await fetch('https://erp.example.com/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      pos_order_id: systemId,
      customer_name: order.bill_full_name,
      total: order.total_price,
      items: order.items,
    }),
  });
  if (!res.ok) throw new Error(`ERP từ chối đơn (${res.status})`);
  const { erp_code } = await res.json();

  // Ghi mã ERP vào ghi chú đơn; xem tài liệu Open API về các trường cập nhật được.
  const note = [order.note, `ERP: ${erp_code}`].filter(Boolean).join('\n');
  await pos.api.put(`/shops/${shopId}/orders/${systemId}`, { note });

  return { message: `Đã đẩy sang ERP: ${erp_code}`, refresh: true };
}
```

Lỗi ném ra (`throw`) được POS hiện thành toast lỗi.

## 3. Action modal "Phiếu bảo hành"

`extensions/warranty.tsx`

```tsx
import { render } from 'preact';
import { useState } from 'preact/hooks';

function WarrantyForm({ systemId }: { systemId: number }) {
  const [serial, setSerial] = useState('');
  const [expiresOn, setExpiresOn] = useState<string | null>(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const submit = async () => {
    if (!serial) return setError('Nhập số serial');
    setSaving(true);
    setError(null);
    try {
      const res = await fetch('https://erp.example.com/api/warranties', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ pos_order_id: systemId, serial, expires_on: expiresOn }),
      });
      if (!res.ok) throw new Error(`ERP trả về ${res.status}`);
      pos.toast.show('Đã tạo phiếu bảo hành');
      pos.close();
    } catch (e) {
      setError(e.message);
    } finally {
      setSaving(false);
    }
  };

  return (
    <pos-stack>
      {error && <pos-banner tone="critical">{error}</pos-banner>}
      <pos-text-field label="Số serial" required value={serial} onChange={(e) => setSerial(e.detail)} />
      <pos-date-picker label="Hết hạn bảo hành" value={expiresOn} onChange={(e) => setExpiresOn(e.detail)} />
      <pos-stack direction="horizontal" justify="end" gap="small">
        <pos-button onClick={() => pos.close()}>Huỷ</pos-button>
        <pos-button variant="primary" loading={saving} onClick={submit}>Tạo phiếu</pos-button>
      </pos-stack>
    </pos-stack>
  );
}

export default async function (data) {
  render(<WarrantyForm systemId={data.record.system_id} />, document.body);
}
```

## 4. Hook chặn lưu đơn

`extensions/require-phone.ts`

```ts
const LIMIT = 20_000_000;

export default async function ({ draft }) {
  if (draft.total_price > LIMIT && !draft.bill_phone_number) {
    return { ok: false, message: 'Đơn trên 20 triệu cần có số điện thoại người nhận.' };
  }
  return { ok: true };
}
```

Nếu shop ẩn `bill_phone_number` bằng `access.hidden_order_fields`, trường này luôn là `undefined` và hook sẽ chặn mọi đơn lớn. Kiểm tra `access` khi viết hook.

## 5. Trang iframe "Đối soát ERP"

`page/src/main.tsx` (React + Vite)

```tsx
import { createRoot } from 'react-dom/client';
import { useEffect, useState } from 'react';
import { connect } from '@pancake-pos/ui-extensions/page';

const pos = await connect();

function Reconcile() {
  const [page, setPage] = useState(1);
  const [orders, setOrders] = useState([]);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    pos.api
      .get(`/shops/${pos.shop.id}/orders`, {
        query: { page_number: page, page_size: 20 },
        signal: controller.signal,
      })
      .then((res) => {
        setOrders(res.data);
        setTotal(res.total_entries);
      })
      .catch((e) => e.name !== 'AbortError' && pos.toast.show(e.message, { tone: 'error' }));
    return () => controller.abort();
  }, [page]);

  useEffect(() => {
    pos.page.setTitle('Đối soát ERP');
    pos.page.setPrimaryAction({ label: 'Đồng bộ lại' });
    return pos.on('primary-action', () => pos.toast.show('Đang đồng bộ...', { tone: 'info' }));
  }, []);

  return (
    <table>
      <thead>
        <tr><th>Mã đơn</th><th>Khách hàng</th><th>Tổng tiền</th></tr>
      </thead>
      <tbody>
        {orders.map((o) => (
          <tr key={o.system_id}>
            <td>{o.system_id}</td>
            <td>{o.bill_full_name}</td>
            <td>{o.total_price}</td>
          </tr>
        ))}
      </tbody>
      <tfoot>
        <tr>
          <td colSpan={3}>
            <button disabled={page === 1} onClick={() => setPage(page - 1)}>Trước</button>
            <span> Trang {page} / {Math.max(1, Math.ceil(total / 20))} </span>
            <button disabled={page * 20 >= total} onClick={() => setPage(page + 1)}>Sau</button>
          </td>
        </tr>
      </tfoot>
    </table>
  );
}

createRoot(document.getElementById('root')!).render(<Reconcile />);
```

Trang iframe dùng HTML/React tự do; có thể thay bảng trên bằng component của `@pancake-pos/ui` để giao diện giống POS.

## 6. Action hàng loạt cho phiếu chuyển kho

`extensions/push-transfers.ts`

```ts
// warehouse_transfer.index.selection-action: records là WarehouseTransferRef[]
export default async function ({ records }) {
  const res = await fetch('https://erp.example.com/api/stock-transfers', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(
      records.map((t) => ({
        pos_transfer_id: t.id,
        code: t.custom_id || String(t.display_id),
        from_warehouse_id: t.from_warehouse_id,
        to_warehouse_id: t.to_warehouse_id,
        status: t.status,
      }))
    ),
  });
  if (!res.ok) throw new Error(`ERP từ chối phiếu chuyển kho (${res.status})`);
  return { message: `Đã đẩy ${records.length} phiếu chuyển kho sang ERP`, refresh: false };
}
```

Module viết giống hệt cho đơn hàng, chỉ khác resource: `records` chứa `WarehouseTransferRef`, dùng `id` khi gọi Open API (`/shops/{shop_id}/transfers/{id}`). Cần danh sách sản phẩm trong phiếu thì đọc qua Open API.
