Sales Order
Overview
Section titled “Overview”Sales Order enables field users to create orders during outlet visits using barcode scanning, cart management, and offline-first submission.
graph TD
A[Visit Progress] --> B[Sales Order FAB]
B --> C[Scan/Search Product]
C --> D[Add to Cart]
D --> E{More?}
E -->|Yes| C
E -->|No| F[Order/Payment Type]
F --> G[Photos + Signature]
G --> H[Review]
H --> I{Confirm?}
I -->|Yes| J[Submit]
I -->|No| F
J --> K[Success]
Screens: CreateSalesOrderScreen, GlobalCameraScreen, GlobalSelectProductScreen, ReviewOrderScreen
Offline: Cart persists in state, orders queue to MMKV outbox, auto-sync on reconnect.
CreateSalesOrderScreen
Section titled “CreateSalesOrderScreen”1. Outlet — InputSelectOutlet with search. Mandatory.
2. Products
[Scan Barcode]→ camera,[Search Product]→ picker- FlashList: name, SKU, qty stepper, price, subtotal, remove
- Empty: “No products added yet”
- Total: auto-calculated IDR
3. Order Details
| Field | Type | Options |
|---|---|---|
| Order Type | Chips | Sell In, Replenishment, Pre Order |
| Payment Type | Chips | Cash, Transfer, Credit |
| Notes | Text | Optional |
| Signature | Photo | Optional |
| PO Photos | Photo (5 max) | Optional |
4. Footer — [Cancel], [Review Order] (validates outlet + products)
ReviewOrderScreen
Section titled “ReviewOrderScreen”Read-only: outlet card, items table, order chips, signature/PO photo thumbnails.
Submit: Upload photos, build payload, call createOrder() mutation.
Cart State
Section titled “Cart State”interface CartItem { product: Product; quantity: number; unit_price: number; subtotal: number;}
interface CartState { products: OrderProduct[]; orderTypeId?: number; paymentTypeId?: number; notes: string; signature: string; poPhotos: string[]; outlet: { id: number; name: string; address?: string }[];}Add:
const addProductToCart = useCallback((product: Product) => { setOrderProducts(prev => { const idx = prev.findIndex(p => p.product.id === product.id); if (idx >= 0) { const updated = [...prev]; updated[idx] = { ...updated[idx], quantity: updated[idx].quantity + 1, subtotal: (updated[idx].quantity + 1) * updated[idx].unit_price }; return updated; } return [...prev, { product, quantity: 1, unit_price: product.unit_price ?? 0, subtotal: product.unit_price ?? 0 }]; });}, []);Update: setOrderProducts(prev => prev.map(i => i.product.id === productId ? { ...i, quantity: Math.max(1, i.quantity + delta), subtotal: Math.max(1, i.quantity + delta) * i.unit_price } : i))
Remove: setOrderProducts(prev => prev.filter(p => p.product.id !== productId))
Total: useMemo(() => items.reduce((sum, i) => sum + i.subtotal, 0), [items])
Barcode Scan
Section titled “Barcode Scan”// Queryconst { data: barcodeProduct } = useProductByBarcodeQuery(pendingBarcode);
// Auto-add on scanReact.useEffect(() => { if (!barcodeProduct || !pendingBarcode) return; addProductToCart(barcodeProduct); setPendingBarcode('');}, [barcodeProduct, pendingBarcode]);
// Scanconst handleScanBarcode = useCallback(async () => { const result = await openScanner(navigation, { cameraType: 'back' }); if (result?.value) setPendingBarcode(result.value);}, [navigation]);Query config: enabled: isAuthenticated && !!barcode, networkMode: 'offlineFirst', staleTime: 5 * 60 * 1000
Mutation:
export function useCreateSalesOrderMutation() { const queryClient = useQueryClient(); const { isOnline } = useNetwork();
return useMutation({ mutationFn: async (payload) => { if (!isOnline) { await outbox.add({ mutation_type: 'create_sales_order', endpoint: '/app/v1/sales-orders', method: 'POST', payload }); return { queued: true }; } return salesOrderService.create(payload); }, onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['visits'] }); showToast(res.queued ? 'Saved locally' : 'Order created'); }, });}Payload:
interface CreateSalesOrderPayload { outlet_id: number; visit_id?: number; order_type_id: number | null; payment_type_id: number | null; products: { product_id: number; quantity: number; unit_price: number }[]; signature?: string; po_photos?: string[]; notes?: string;}| Field | Required | Notes |
|---|---|---|
outlet_id | Yes | Outlet ID |
order_type_id | No | 1=Sell In, 2=Replenishment, 3=Pre Order |
payment_type_id | No | 1=Cash, 2=Transfer, 3=Credit |
products | Yes | Min 1 item |
signature | No | Base64/URL |
po_photos | No | Max 5 URLs |
Endpoint: POST /app/v1/sales-orders
Offline
Section titled “Offline”When offline: Cart in state, payload → MMKV outbox (pending), toast: “Saved locally. Will sync when online.”
Sync process:
- Check online status on reconnect/foreground
- Get pending entries in FIFO order
- Send with
X-Idempotency-Keyheader (UUID) - Success: remove from outbox, invalidate visits query
- Error: retry with exponential backoff (max 5 attempts)
Persistence:
| Data | Storage | TTL |
|---|---|---|
| Cart | React state | Session |
| Outbox | MMKV | 30 days |
| Photos | Device | Until upload |
Eviction: Max 500 entries (oldest failed first).
Related Topics
Section titled “Related Topics”- Submission User Guide — Step-by-step guide for end users
- Offline Mutation — Outbox architecture and retry logic
- Product Lookup — Barcode scan integration
- Order & Payment Types — Backend reference