Skip to content

Sales Order

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.

1. OutletInputSelectOutlet 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

FieldTypeOptions
Order TypeChipsSell In, Replenishment, Pre Order
Payment TypeChipsCash, Transfer, Credit
NotesTextOptional
SignaturePhotoOptional
PO PhotosPhoto (5 max)Optional

4. Footer[Cancel], [Review Order] (validates outlet + products)

Read-only: outlet card, items table, order chips, signature/PO photo thumbnails.

Submit: Upload photos, build payload, call createOrder() mutation.

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])

// Query
const { data: barcodeProduct } = useProductByBarcodeQuery(pendingBarcode);
// Auto-add on scan
React.useEffect(() => {
if (!barcodeProduct || !pendingBarcode) return;
addProductToCart(barcodeProduct);
setPendingBarcode('');
}, [barcodeProduct, pendingBarcode]);
// Scan
const 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;
}
FieldRequiredNotes
outlet_idYesOutlet ID
order_type_idNo1=Sell In, 2=Replenishment, 3=Pre Order
payment_type_idNo1=Cash, 2=Transfer, 3=Credit
productsYesMin 1 item
signatureNoBase64/URL
po_photosNoMax 5 URLs

Endpoint: POST /app/v1/sales-orders

When offline: Cart in state, payload → MMKV outbox (pending), toast: “Saved locally. Will sync when online.”

Sync process:

  1. Check online status on reconnect/foreground
  2. Get pending entries in FIFO order
  3. Send with X-Idempotency-Key header (UUID)
  4. Success: remove from outbox, invalidate visits query
  5. Error: retry with exponential backoff (max 5 attempts)

Persistence:

DataStorageTTL
CartReact stateSession
OutboxMMKV30 days
PhotosDeviceUntil upload

Eviction: Max 500 entries (oldest failed first).