Elixir untuk Technical Leader — Bab 5 dari 10

Phoenix & LiveView

Web framework yang bikin v1 (React + 31 Edge Functions + 16 RPC) jadi v2 (1 codebase, 0 JavaScript framework). Ini bab paling penting untuk paham arsitektur LabaBersih.


Apa Itu Phoenix

Phoenix = web framework untuk Elixir. Posisinya sama kayak Rails untuk Ruby, atau Next.js untuk JavaScript. Dibuat oleh Chris McCord tahun 2014.

Tapi Phoenix punya 1 senjata yang gak dimiliki Rails atau Next.js: LiveView — teknologi yang bikin UI real-time tanpa perlu React, Vue, atau framework JavaScript apapun.

Analogi Phoenix = restoran lengkap. Dapurnya (Context) masak makanan. Pelayan (LiveView) antar ke meja. Menu (Router) daftar semua makanan yang tersedia. Dan semua staf komunikasi via intercom terus-menerus (WebSocket), bukan cuma saat pelanggan manggil (HTTP request).

Kenapa LabaBersih pakai Phoenix:

KebutuhanPhoenix kasih
UI tanpa React (1 bahasa end-to-end)LiveView — server render HTML, kirim via WebSocket
Real-time dashboard (stok, order)WebSocket persistent — server push update tanpa user refresh
Business logic terstrukturContext pattern — setiap domain 1 modul
Background jobs (sync API, daily journal)Oban — persistent queue, berjalan di proses yang sama
Hosting murahPhoenix di Fly.io = ~$10-15/bulan (v1 di Vercel+Supabase = $45/bulan)

Arsitektur: v1 vs v2

Ini perbedaan paling fundamental. Pahami ini dan kamu paham kenapa v2 lebih sederhana.

v1 (Next.js + Supabase) — 3 LAYER TERPISAH ================================================= Browser (React) | HTTP request setiap klik v Supabase Edge Functions (31 functions, Deno) | SQL query v PostgreSQL (16 RPC stored procedures) Masalah: - Logic tersebar di 3 tempat - Error baru ketemu saat runtime - Setiap klik = HTTP request baru (lambat) - Edge Function stateless (gak inget apa-apa)
v2 (Phoenix/LiveView) — 1 LAYER TERINTEGRASI ================================================= Browser (HTML dari Phoenix, minimal JS) ^ | WebSocket (koneksi TETAP terbuka) v Phoenix LiveView | panggil function v Context (Orders, Accounting, Inventory, ...) | Ecto query v PostgreSQL (Fly.io, ZERO stored procedures) Keuntungan: - Logic HANYA di Context (1 tempat) - Compile-time error checking - WebSocket = gak ada delay HTTP - Server inget state user (assigns)
Buat Hafish Di v1, kalau ada bug kamu harus cari di 3 tempat (React component? Edge Function? RPC di database?). Di v2, bug bisnis PASTI ada di 1 tempat: file context (orders.ex, accounting.ex, dll). Ini bukan cuma soal teknologi — ini soal kecepatan kamu mendeteksi masalah.

Request Lifecycle — Alur Setiap Permintaan

Ketika user buka halaman atau klik tombol, ini yang terjadi di belakang layar:

User buka /pesanan | v Router.ex "URL /pesanan cocok sama route mana?" | jawab: OrderLive v OrderLive.mount/3 load data awal dari Context | v Orders.list_orders() Context: query database via Ecto | v PostgreSQL return data | v OrderLive.render/1 render HTML dari data | v Browser tampilkan halaman --- lalu user TETAP terhubung via WebSocket --- User klik tab "Dikirim" | v OrderLive.handle_event/3 "filter_fulfillment" event masuk | v Orders.list_orders(...) Context: query ulang dengan filter | v LiveView hitung DIFF (apa yang berubah di HTML) | v Browser update HANYA bagian yang berubah (bukan re-render seluruh halaman)
Kenapa ini penting Di v1 (React): setiap klik = HTTP request baru, server proses dari nol, kirim SELURUH halaman. Di v2 (LiveView): koneksi sudah ada (WebSocket), server kirim HANYA yang berubah (diff). Makanya v2 terasa lebih cepat — bahkan dengan server di Singapore.

Router — Peta URL ke Halaman

Router = peta jalan. "Kalau user buka URL ini, tampilkan halaman ini." File-nya: lib/lababersih_web/router.ex

Ini potongan dari router LabaBersih yang sebenarnya:

# lib/lababersih_web/router.ex (LabaBersih aktual)

scope "/", LababersihWeb do
  pipe_through [:browser, :auth]    # wajib login dulu

  live_session :app,
    layout: {LababersihWeb.Layouts, :app},
    on_mount: [{LababersihWeb.LiveAuth, :require_auth}] do

    # Pesanan
    live "/pesanan",         App.OrderLive          # list semua order
    live "/pesanan/new",     App.OrderFormLive       # form buat order baru
    live "/pesanan/import",  App.OrderImportLive     # upload XLSX
    live "/pesanan/:id",     App.OrderDetailLive     # detail 1 order

    # Produk & Stok
    live "/produk",           App.ProdukLive
    live "/produk/:id",       App.ProdukDetailLive

    # Keuangan
    live "/jurnal",           App.JurnalLive
    live "/bagan-akun",       App.CoaLive
    live "/laporan",          App.LaporanLive
    live "/buku-besar",       App.BukuBesarLive
    live "/rekonsiliasi",     App.RekonsiliasiLive

    # ... dan 20+ route lainnya
  end
end

Cara bacanya sederhana:

RouteURLModuleArtinya
live "/pesanan"lababersih.id/pesananOrderLiveHalaman list pesanan
live "/pesanan/:id"lababersih.id/pesanan/PS2603-00001OrderDetailLiveDetail order tertentu. :id = dynamic.
pipe_through [:auth]--Semua route di bawah ini WAJIB login
Review tip Kalau mau tau semua halaman yang ada di LabaBersih, buka router.ex dan scroll. Setiap baris live "/..." = 1 halaman. Saat ini ada 40+ halaman.

Context Pattern — Aturan #1 LabaBersih

Ini konsep PALING PENTING di seluruh arsitektur LabaBersih. Kalau kamu cuma bisa ingat 1 hal dari bab ini, ingat ini:

ATURAN MUTLAK Logic di Context, BUKAN di LiveView. Titik. Gak ada pengecualian.

Apa itu Context?

Context = modul Elixir yang berisi semua logic bisnis untuk 1 domain. Gak ada logic bisnis di tempat lain.

ContextFileTanggung jawab
Lababersih.Ordersorders/orders.exBuat order, ship order, delete order
Lababersih.Accountingaccounting/accounting.exJurnal, trial balance, laporan, close period
Lababersih.Inventoryinventory/inventory.exProduk, stok, FEFO lot, gudang
Lababersih.Salessales/sales.exToko, fee mapping, channel
Lababersih.Returnsreturns/returns.exRTS, inbound, klaim kurir
Lababersih.Purchasingpurchasing/purchasing.exPO, receive, bayar, supplier return
Lababersih.Reconciliationreconciliation/reconciliation.exSettlement matching, jurnal rekonsil
Lababersih.Accountsaccounts/accounts.exAuth, user, organisasi, member

Kenapa ini penting?

SALAH — logic di LiveView
# Di OrderFormLive:
def handle_event("ship", _, socket) do
  order = Repo.get!(Order, id)
  if order.status != "dikemas" do   # logic di LiveView!
    # potong stok manual
    # bikin jurnal manual
    # update status manual
  end
end

Logic tersebar. Gak bisa di-test terpisah. Gak bisa dipanggil dari tempat lain (API, Oban job).

BENAR — LiveView panggil Context
# Di LiveView: HANYA panggil function
def handle_event("ship", _, socket) do
  case Orders.ship_order(id, email) do
    {:ok, _} -> put_flash(:info, "Berhasil")
    {:error, reason} -> put_flash(:error, ...)
  end
end

# Di Context: SEMUA logic ada di sini
def ship_order(order_id, actor_email) do
  Repo.transaction(fn ->
    # 1. Validasi status
    # 2. Potong stok (FEFO)
    # 3. Bikin jurnal
    # 4. Update status
    # 5. Audit trail
  end)
end

Logic di 1 tempat. Bisa di-test. Bisa dipanggil dari mana aja.

Analogi Context = manajer operasional di gudang. LiveView = kasir di depan. Kasir terima request dari customer ("saya mau kirim order ini"), lalu minta manajer yang proses semua (cek stok, potong stok, bikin jurnal, dll). Kasir gak boleh masuk gudang sendiri dan utak-atik stok — itu tugas manajer.

Alur panggilan yang benar:

# ALUR BENAR:
# LiveView → Context → Repo → Database
#
# User klik "Kirim" di browser
#   → LiveView.handle_event("ship", ...)
#     → Orders.ship_order(order_id, email)    ← Context
#       → Repo.transaction (stok + jurnal + audit)
#         → PostgreSQL
#     → {:ok, result} atau {:error, reason}
#   → put_flash(:info, "Berhasil") atau put_flash(:error, "Gagal")
#   → browser update otomatis
#
# LiveView TIDAK TAU cara potong stok.
# LiveView TIDAK TAU cara bikin jurnal.
# LiveView HANYA tau: panggil function, tampilkan hasil.

LiveView — UI Tanpa React

LiveView = cara Phoenix render UI. Gak pakai React, Vue, atau framework JS apapun. Semua HTML di-render di server, dikirim ke browser via WebSocket.

Kenapa ini revolusioner?

Aspekv1 (React + API)v2 (LiveView)
Bahasa UITypeScript + JSXElixir + HEEx (HTML)
State managementReact useState + useEffect + SWRAssigns (@) — 1 mekanisme
API layer31 Edge Functions + fetch()Gak ada — LiveView langsung panggil Context
Real-time updateManual polling atau custom WebSocketOtomatis (WebSocket built-in)
Page reloadSetiap navigasi (kecuali SPA)Gak pernah (persistent connection)
JS bundle size~500KB+ (React + deps)~30KB (Phoenix LiveView JS client)

Anatomi LiveView — 4 Function Penting

Setiap LiveView module punya 4 function utama. Ini yang kamu akan lihat di SETIAP file LiveView:

1. mount/3 — Load Data Awal

Dipanggil SEKALI saat user pertama kali buka halaman. Tugasnya: ambil data dari Context, simpan ke assigns.

# Dari OrderLive (LabaBersih aktual)
def mount(_params, _session, socket) do
  org_id = socket.assigns.current_org.id          # org user yang login
  {orders, total} = Orders.list_orders(org_id,     # panggil Context
                       page: 1, per_page: 20)
  fulfillment_counts = Orders.count_orders_by_fulfillment(org_id)

  {:ok,
   assign(socket,
     page_title: "Pesanan",
     orders: orders,          # data yang akan ditampilkan
     total: total,
     page: 1,
     search: "",
     fulfillment_counts: fulfillment_counts
   )}
end
Perhatikan mount/3 HANYA panggil Orders.list_orders/2 (Context function). Gak ada SQL query langsung. Gak ada logic filter. Semua delegasi ke Context.

2. render/1 — Gambar HTML

Dipanggil SETIAP KALI data berubah. Phoenix otomatis hitung diff dan kirim hanya yang berubah ke browser.

# Dari OrderLive (LabaBersih aktual, disederhanakan)
def render(assigns) do
  ~H"""
  <.page_header title="Pesanan">
    <:action>
      <.button navigate={~p"/pesanan/import"} variant="secondary">
        Upload XLSX
      </.button>
      <.button navigate={~p"/pesanan/new"}>
        + Buat Pesanan
      </.button>
    </:action>
  </.page_header>

  <.stats_row>
    <.stat_card label="Siap Kirim" value={@ready_count} />
    <.stat_card label="Dikirim" value={@shipped_count} />
    <.stat_card label="Terkirim" value={@delivered_count} />
    <.stat_card label="RTS" value={@rts_count} />
  </.stats_row>

  <div :for={o <- @orders}>
    <div phx-click="navigate" phx-value-to={~p"/pesanan/#{o.id}"}>
      {o.id} — {o.customer_name} — <.money amount={o.total_harga} />
    </div>
  </div>
  """
end

3. handle_event/3 — User Klik Sesuatu

Dipanggil saat user interaksi (klik button, submit form, ketik search). Ini jembatan antara UI dan Context.

# User ketik di search bar → event "search" masuk
def handle_event("search", %{"q" => q}, socket) do
  org_id = socket.assigns.current_org.id
  {orders, total} = Orders.list_orders(org_id, q: q)    # panggil Context
  {:noreply, assign(socket, orders: orders, total: total, search: q)}
end

# User klik tab filter "Dikirim" → event "filter_fulfillment"
def handle_event("filter_fulfillment", %{"fulfillment" => f}, socket) do
  # ... panggil Context dengan filter baru, update assigns
end

# User submit form "Buat Pesanan"
def handle_event("save_order", params, socket) do
  case Orders.create_order(attrs, items) do      # panggil Context
    {:ok, order} ->
      {:noreply, socket
        |> put_flash(:info, "Pesanan #{order.id} berhasil dibuat")
        |> push_navigate(to: ~p"/pesanan/#{order.id}")}
    {:error, reason} ->
      {:noreply, put_flash(socket, :error, "Gagal: #{inspect(reason)}")}
  end
end
Pola yang SELALU sama handle_event SELALU ikut pola ini: (1) ambil data dari event, (2) panggil Context function, (3) handle {:ok, _} atau {:error, _}, (4) update assigns atau flash. Kalau kamu lihat pola lain — sesuatu salah.

4. handle_params/2 — URL Berubah

Dipanggil saat URL berubah (navigasi antar halaman dalam live_session). Biasanya untuk halaman detail yang butuh :id dari URL.

# User navigasi ke /pesanan/PS2603-00001
def handle_params(%{"id" => id}, _uri, socket) do
  order = Orders.get_order!(id)                   # panggil Context
  {:noreply, assign(socket, order: order)}
end

HEEx Templates — HTML di Elixir

HEEx = HTML + Embedded Elixir. Ini template engine Phoenix. Kamu nulis HTML biasa tapi bisa sisipkan data Elixir.

Syntax penting yang akan kamu lihat:

SyntaxArtinyaContoh
{@variable}Tampilkan nilai dari assigns{@order.customer_name} → "Budi"
:if={kondisi}Tampilkan elemen HANYA kalau kondisi true<div :if={@order.no_resi}>Resi: {@order.no_resi}</div>
:for={item <- list}Loop — ulangi elemen untuk setiap item<div :for={o <- @orders}>{o.id}</div>
<.component />Panggil shared component<.button>Simpan</.button>
~p"/path"Generate URL path (aman dari typo)~p"/pesanan/#{order.id}"
phx-click="event"Saat diklik, kirim event ke server<button phx-click="delete">

Contoh nyata dari LabaBersih:

<!-- Tampilkan order row (dari OrderLive) -->
<div
  :for={o <- @orders}
  phx-click="navigate"
  phx-value-to={~p"/pesanan/#{o.id}"}
  class="hover:bg-gray-50 cursor-pointer"
>
  <!-- ID pesanan -->
  <div class="font-mono text-sm">{o.id}</div>

  <!-- Nomor platform + resi (hanya tampil kalau ada) -->
  <div class="text-xs text-gray-400">
    {o.nomor_pesanan}
    <span :if={o.no_resi}> · {o.no_resi}</span>
  </div>

  <!-- Customer -->
  <div>{o.customer_name}</div>

  <!-- Platform badge (shared component) -->
  <.platform_badge platform={o.platform} />

  <!-- Status badge (shared component) -->
  <.status_badge status={o.fulfillment_status} />

  <!-- Revenue (shared component) -->
  <.money amount={o.total_harga} />
</div>
Baca HEEx kayak HTML biasa Kalau kamu bisa baca HTML, kamu bisa baca HEEx. Yang beda cuma: {@...} = data dinamis, :if = tampilkan kondisional, :for = loop, <.xxx /> = komponen reusable.

Components — UI yang Bisa Dipakai Ulang

LabaBersih punya CoreComponents — kumpulan komponen UI yang dipakai di SEMUA halaman. Ini yang bikin tampilan konsisten (beda dari v1 yang badge-nya beda style tiap halaman).

Semua ada di 1 file: lib/lababersih_web/components/core_components.ex

ComponentPakai di HEExFungsi
Button<.button>Simpan</.button>Tombol dengan variant (primary/secondary/danger)
Input<.input name="email" label="Email" />Input form + label + error
Status Badge<.status_badge status="shipped" />Badge warna otomatis sesuai status
Platform Badge<.platform_badge platform="tiktok" />Badge khusus platform (hitam TikTok, orange Shopee)
Money<.money amount={200000} />"Rp 200.000" — format Rupiah otomatis
Stats Row<.stats_row>...</.stats_row>4-kolom stat cards
Page Header<.page_header title="Pesanan">Header halaman + action buttons
Tab Filter<.tab_filter options={...} />Tab "Semua | Dikirim | Selesai"
Filter Bar<.filter_bar>...</.filter_bar>Search + filter dropdowns
Pagination<.pagination page={@page} />Prev/Next halaman
Icon<.icon name="hero-truck" />Heroicons (library icon bawaan Phoenix)
Kenapa ini bagus Di v1, setiap halaman copy-paste badge style sendiri — hasilnya beda-beda ("AI banget" kata Hafish). Di v2, ada <.status_badge> yang SEMUA halaman pakai. Ubah di 1 tempat = berubah di SEMUA halaman.

Contoh component definition:

# Di core_components.ex
def status_badge(assigns) do
  {bg, text} = Map.get(@badge_colors, assigns.status, {"#E2E7E5", "#3D4B46"})
  assigns = assign(assigns, bg: bg, text: text)

  ~H"""
  <span style={"display:inline-flex;padding:4px 10px;border-radius:999px;
    font-size:13px;font-weight:600;background:#{@bg};color:#{@text};"}>
    {status_label(@status)}
  </span>
  """
end

# status_label mengubah kode teknis ke bahasa Indonesia:
# "shipped" → "Dikirim"
# "delivered" → "Terkirim"
# "rts_detected" → "RTS Terdeteksi"

Flash Messages — Notifikasi ke User

Flash = pesan sementara yang muncul di atas halaman setelah aksi (berhasil/gagal). Di v1 ini pakai toast library. Di v2 built-in.

# Berhasil (hijau)
socket |> put_flash(:info, "Pesanan PS2603-00001 berhasil dibuat")

# Gagal (merah)
socket |> put_flash(:error, "Stok Forbest tidak cukup (ada: 3, butuh: 5)")

# Contoh di handle_event:
case Orders.create_order(attrs, items) do
  {:ok, order} ->
    socket
    |> put_flash(:info, "Pesanan #{order.id} berhasil dibuat")
    |> push_navigate(to: ~p"/pesanan/#{order.id}")
  {:error, reason} ->
    put_flash(socket, :error, "Gagal: #{inspect(reason)}")
end
Review tip Setiap handle_event yang melakukan aksi (save, delete, ship) HARUS punya flash message untuk case berhasil DAN gagal. Kalau cuma handle {:ok, _} tanpa {:error, _} — itu red flag.

Assigns (@) — State LiveView

Assigns = data yang tersimpan di LiveView process. Setiap kali assigns berubah, Phoenix otomatis re-render bagian HTML yang pakai data itu.

# Di mount:
assign(socket,
  orders: orders,            # → akses via @orders di template
  page: 1,                   # → @page
  search: "",                # → @search
  fulfillment_counts: counts  # → @fulfillment_counts
)

# Di template:
# {@orders}   → list of order data
# {@page}     → halaman saat ini (1, 2, 3, ...)
# {@search}   → teks yang diketik user di search bar

# Update assigns (misalnya user pindah halaman):
assign(socket, page: 2, orders: new_orders)
# → @page berubah jadi 2
# → @orders berubah jadi data baru
# → Phoenix re-render HANYA bagian yang pakai @page dan @orders
Analogi Assigns = papan tulis di dapur. Chef (LiveView) tulis info di papan ("pesanan hari ini: 300, halaman: 1"). Saat info berubah, pelayan (render) langsung lihat papan baru dan update menu di meja customer. Gak perlu minta chef ulang — papan otomatis di-sync.

phx- Attributes — Event dari Browser ke Server

Ini yang bikin LiveView interaktif tanpa JavaScript custom. Tambahkan phx-xxx di HTML, dan LiveView otomatis handle.

AttributeKapan dipanggilContoh
phx-click User klik elemen <button phx-click="delete" phx-value-id={@order.id}>Hapus</button>
phx-submit User submit form <form phx-submit="save_order">...</form>
phx-change User ketik di input (real-time) <input phx-change="search" name="q" /> (validasi sambil ketik)
phx-disable-with Disable button saat processing <button phx-disable-with="Menyimpan...">Simpan</button>
phx-value-xxx Kirim data tambahan ke event phx-value-id="PS2603-00001" → masuk di params %{"id" => "PS2603-..."}

Contoh nyata — form pesanan LabaBersih:

<!-- Form buat pesanan baru -->
<form phx-submit="save_order">                     <!-- submit → server -->
  <.input name="customer_name" label="Customer" required />
  <.input name="platform" type="select"
    options={[{"TikTok", "tiktok"}, {"Shopee", "shopee"}]} />

  <!-- Tombol tambah item -->
  <.button type="button" phx-click="add_item">     <!-- klik → server -->
    + Tambah item
  </.button>

  <!-- Tombol simpan -->
  <.button type="submit"
    phx-disable-with="Menyimpan...">                <!-- disable saat proses -->
    Simpan
  </.button>
</form>

Setiap phx-click="add_item" di template akan memanggil function ini di LiveView:

def handle_event("add_item", _, socket) do
  items = socket.assigns.items ++ [%{product_id: "", quantity: 1, price: 0}]
  {:noreply, assign(socket, items: items)}
end
# → items bertambah 1 → template re-render → 1 row baru muncul di browser
# Gak ada JavaScript. Gak ada API call. Gak ada useState.
Red flag: button tanpa phx-disable-with Setiap tombol yang melakukan aksi (save, delete, ship) WAJIB punya phx-disable-with="Memproses...". Tanpa ini, user bisa double-click dan submit 2x. Ini bug yang v1 punya dan v2 harus hindari.

WebSocket vs HTTP — Kenapa LiveView Cepat

Ini perbedaan teknis yang menjelaskan kenapa v2 terasa lebih responsif:

HTTP (v1 — setiap klik)
1. Browser buka koneksi TCP     ~50ms
2. TLS handshake               ~100ms
3. Kirim HTTP request           ~10ms
4. Server proses dari NOL       ~100ms
5. Kirim SELURUH HTML/JSON      ~50ms
6. Tutup koneksi
   ──────────────
   Total: ~310ms PER KLIK
WebSocket (v2 — koneksi tetap)
1. Koneksi sudah TERBUKA        0ms
2. Kirim event kecil            ~5ms
3. Server proses (state ada)    ~50ms
4. Kirim HANYA yang berubah     ~10ms
5. Koneksi tetap terbuka
   ──────────────
   Total: ~65ms PER KLIK

5x lebih cepat. Dan itu belum hitung keuntungan lain: server ingat state user (assigns), jadi gak perlu query ulang semua data dari nol.


Checklist Review untuk Leader

Setiap kali kamu review code LiveView yang dibuat Claude, cek hal-hal ini:

#CekYang benarRed flag
1 Logic di mana? handle_event panggil Context function (1 baris) handle_event punya if, Repo.query, atau logic bisnis — TOLAK
2 Error handling? case dengan {:ok, _} DAN {:error, _} Cuma handle {:ok, _} — error dibuang silent
3 Loading state? Button punya phx-disable-with="Memproses..." Button tanpa disable — bisa double-submit
4 Flash message? put_flash(:info, "...") dan put_flash(:error, "...") Gak ada feedback setelah aksi — user gak tau berhasil/gagal
5 N+1 query? Data di-load di mount dengan preload, BUKAN loop di template Template panggil function yang query DB per row (N+1)
6 Shared components? Pakai <.button>, <.status_badge>, <.money> Inline style atau copy-paste HTML badge per halaman
7 Empty state? :if={@orders == []} tampilkan pesan "Belum ada pesanan" Halaman kosong tanpa pesan — user bingung
8 Pagination? List page punya <.pagination> dan limit data per page Load ALL data tanpa limit — lambat kalau data banyak
9 Title halaman? assign(socket, page_title: "Pesanan") Tab browser tidak berubah saat pindah halaman
10 Bahasa Indonesia? Semua label, placeholder, flash, error dalam bahasa Indonesia "Order created successfully" — user Nelly gak paham

Red Flags — Hal yang WAJIB Ditolak

1. Logic bisnis di LiveView handle_event punya if order.status == "dikemas", Repo.update, atau kalkulasi HPP. Ini SALAH. Semua logic HARUS di Context.
2. Query database langsung dari LiveView Repo.all(Order) atau Repo.get!(Product, id) langsung di handle_event. Ini bypass Context — gak ada validasi, gak ada org_id scope.
3. N+1 query di mount atau render Mount load 20 order, lalu di template setiap row panggil Accounting.has_journal?(order.id). Itu 20 query tambahan. Seharusnya di-preload di mount (1 query batch).
4. Button tanpa phx-disable-with Form submit tanpa disable = user bisa klik 2x = order dibuat 2x. SELALU wajib ada.
5. Error di-ignore Orders.ship_order(id, email) dipanggil tanpa case — hasilnya dibuang. Kalau gagal, user gak tau apa-apa. WAJIB handle kedua case.

Struktur File — Di Mana Semuanya

# Semua file web (LiveView, Components, Router) ada di:
lib/lababersih_web/
  router.ex                          # peta URL → halaman
  components/
    core_components.ex               # <.button>, <.status_badge>, dll
    layouts/
      app.html.heex                  # layout dengan sidebar
      root.html.heex                 # layout dasar (HTML head)
  live/
    app/
      order_live.ex                  # list pesanan
      order_detail_live.ex           # detail 1 pesanan
      order_form_live.ex             # form buat pesanan
      produk_live.ex                 # list produk
      jurnal_live.ex                 # list jurnal
      coa_live.ex                    # bagan akun (tree)
      laporan_live.ex                # laporan keuangan
      buku_besar_live.ex             # general ledger
      settings_live.ex               # pengaturan
      # ... 30+ file lainnya

# Semua business logic (Context) ada di:
lib/lababersih/
  orders/orders.ex                   # Context: pesanan
  accounting/accounting.ex           # Context: jurnal, laporan
  inventory/inventory.ex             # Context: produk, stok
  # ... dst
Aturan nama file LiveView file = xxx_live.ex (akhiran _live). Context file = xxx.ex (tanpa suffix). Kalau kamu lihat file di live/ yang namanya gak ada _live — itu bukan LiveView.

Pertanyaan Review yang Bisa Kamu Tanya

Pertanyaan kamuJawaban yang benarRed flag
"Logic ini kenapa di LiveView, bukan di Context?" "Saya pindahkan ke Context sekarang." "Karena ini cuma kecil / simple" ← TOLAK. Gak ada "cuma kecil".
"Kalau user double-click, apa yang terjadi?" "Ada phx-disable-with, button disabled saat proses." "Gak mungkin user double-click" ← SALAH. Ini terjadi setiap hari.
"Kalau data kosong, halaman ini tampilkan apa?" "Ada empty state: icon + pesan 'Belum ada pesanan'" "Tabel kosong aja" ← User bingung, kirain error.
"Ini load berapa data saat mount?" "20 per page, ada pagination." "Semua data di-load" ← BAHAYA. Kalau 10.000 order = lambat.
"Error message-nya bahasa apa?" "Bahasa Indonesia: 'Stok tidak cukup (ada: 3, butuh: 5)'" "Constraint violation" atau bahasa Inggris ← User gak paham.
"Kenapa gak pakai React untuk halaman ini?" "LiveView sudah handle semua use case ini. Real-time update, form handling, navigasi. Tambah React = tambah complexity tanpa benefit." "LiveView gak bisa handle case complex" ← Salah. Discord 5 juta concurrent user pakai BEAM.

Ringkasan Bab 5

7 hal yang kamu sekarang tau:

1. Phoenix = web framework Elixir. LiveView = UI tanpa React via WebSocket.
2. Router = peta URL ke halaman. live "/pesanan" = halaman pesanan.
3. Context = ATURAN #1. Logic di Context, BUKAN di LiveView. Gak ada pengecualian.
4. 4 function LiveView: mount (load awal), render (gambar HTML), handle_event (user klik), handle_params (URL berubah).
5. Assigns (@) = state di server. Berubah = Phoenix otomatis re-render yang berubah saja.
6. phx-click, phx-submit, phx-disable-with = cara HTML bicara ke server tanpa JavaScript.
7. WebSocket > HTTP: koneksi tetap terbuka, kirim diff saja, 5x lebih cepat per interaksi.