2026-02-18 21:23:15 +00:00
|
|
|
defmodule BerrypodWeb.Admin.Orders do
|
|
|
|
|
use BerrypodWeb, :live_view
|
2026-02-07 21:59:14 +00:00
|
|
|
|
2026-02-18 21:23:15 +00:00
|
|
|
alias Berrypod.Orders
|
|
|
|
|
alias Berrypod.Cart
|
2026-02-07 21:59:14 +00:00
|
|
|
|
|
|
|
|
@impl true
|
|
|
|
|
def mount(_params, _session, socket) do
|
|
|
|
|
counts = Orders.count_orders_by_status()
|
|
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:page_title, "Orders")
|
|
|
|
|
|> assign(:status_filter, "all")
|
|
|
|
|
|> assign(:status_counts, counts)
|
|
|
|
|
|
|
|
|
|
{:ok, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@impl true
|
2026-03-01 09:42:34 +00:00
|
|
|
def handle_params(params, _uri, socket) do
|
|
|
|
|
page_num = Berrypod.Pagination.parse_page(params)
|
|
|
|
|
page = Orders.list_orders_paginated(status: socket.assigns.status_filter, page: page_num)
|
2026-02-07 21:59:14 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
2026-03-01 09:42:34 +00:00
|
|
|
|> assign(:pagination, page)
|
|
|
|
|
|> assign(:order_count, page.total_count)
|
|
|
|
|
|> stream(:orders, page.items, reset: true)
|
2026-02-07 21:59:14 +00:00
|
|
|
|
|
|
|
|
{:noreply, socket}
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-01 09:42:34 +00:00
|
|
|
@impl true
|
|
|
|
|
def handle_event("filter", %{"status" => status}, socket) do
|
|
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:status_filter, status)
|
|
|
|
|
|> push_patch(to: ~p"/admin/orders")}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-07 21:59:14 +00:00
|
|
|
@impl true
|
|
|
|
|
def render(assigns) do
|
|
|
|
|
~H"""
|
2026-02-12 08:35:22 +00:00
|
|
|
<.header>
|
|
|
|
|
Orders
|
|
|
|
|
</.header>
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
|
|
|
|
<.filter_tab
|
|
|
|
|
status="all"
|
|
|
|
|
label="All"
|
|
|
|
|
count={total_count(@status_counts)}
|
|
|
|
|
active={@status_filter}
|
|
|
|
|
/>
|
|
|
|
|
<.filter_tab
|
|
|
|
|
status="paid"
|
|
|
|
|
label="Paid"
|
|
|
|
|
count={@status_counts["paid"]}
|
|
|
|
|
active={@status_filter}
|
|
|
|
|
/>
|
|
|
|
|
<.filter_tab
|
|
|
|
|
status="pending"
|
|
|
|
|
label="Pending"
|
|
|
|
|
count={@status_counts["pending"]}
|
|
|
|
|
active={@status_filter}
|
|
|
|
|
/>
|
|
|
|
|
<.filter_tab
|
|
|
|
|
status="failed"
|
|
|
|
|
label="Failed"
|
|
|
|
|
count={@status_counts["failed"]}
|
|
|
|
|
active={@status_filter}
|
|
|
|
|
/>
|
|
|
|
|
<.filter_tab
|
|
|
|
|
status="refunded"
|
|
|
|
|
label="Refunded"
|
|
|
|
|
count={@status_counts["refunded"]}
|
|
|
|
|
active={@status_filter}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<.table
|
|
|
|
|
:if={@order_count > 0}
|
|
|
|
|
id="orders"
|
|
|
|
|
rows={@streams.orders}
|
|
|
|
|
row_item={fn {_id, order} -> order end}
|
|
|
|
|
row_click={fn {_id, order} -> JS.navigate(~p"/admin/orders/#{order}") end}
|
|
|
|
|
>
|
|
|
|
|
<:col :let={order} label="Order">{order.order_number}</:col>
|
|
|
|
|
<:col :let={order} label="Date">{format_date(order.inserted_at)}</:col>
|
|
|
|
|
<:col :let={order} label="Customer">{order.customer_email || "—"}</:col>
|
|
|
|
|
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
|
|
|
|
|
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
|
|
|
|
|
<:col :let={order} label="Fulfilment">
|
|
|
|
|
<.fulfilment_badge status={order.fulfilment_status} />
|
|
|
|
|
</:col>
|
|
|
|
|
</.table>
|
|
|
|
|
|
2026-03-01 09:42:34 +00:00
|
|
|
<.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} />
|
|
|
|
|
|
2026-02-12 08:35:22 +00:00
|
|
|
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
|
|
|
|
|
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
|
|
|
|
|
<p class="text-lg font-medium">No orders yet</p>
|
|
|
|
|
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
|
|
|
|
|
</div>
|
2026-02-07 21:59:14 +00:00
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp filter_tab(assigns) do
|
|
|
|
|
count = assigns[:count] || 0
|
|
|
|
|
active = assigns.active == assigns.status
|
|
|
|
|
|
|
|
|
|
assigns = assign(assigns, count: count, active: active)
|
|
|
|
|
|
|
|
|
|
~H"""
|
|
|
|
|
<button
|
|
|
|
|
phx-click="filter"
|
|
|
|
|
phx-value-status={@status}
|
|
|
|
|
class={[
|
2026-02-17 23:05:01 +00:00
|
|
|
"admin-btn admin-btn-sm",
|
|
|
|
|
@active && "admin-btn-primary",
|
|
|
|
|
!@active && "admin-btn-ghost"
|
2026-02-07 21:59:14 +00:00
|
|
|
]}
|
|
|
|
|
>
|
|
|
|
|
{@label}
|
2026-02-17 23:05:01 +00:00
|
|
|
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
|
2026-02-07 21:59:14 +00:00
|
|
|
</button>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp status_badge(assigns) do
|
|
|
|
|
{bg, text, ring, icon} =
|
|
|
|
|
case assigns.status do
|
|
|
|
|
"paid" ->
|
|
|
|
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
|
|
|
|
|
|
|
|
|
"pending" ->
|
|
|
|
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
|
|
|
|
|
|
|
|
|
"failed" ->
|
|
|
|
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
|
|
|
|
|
|
|
|
|
"refunded" ->
|
2026-02-12 22:55:34 +00:00
|
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
|
|
|
"hero-arrow-uturn-left-mini"}
|
2026-02-07 21:59:14 +00:00
|
|
|
|
|
|
|
|
_ ->
|
2026-02-12 22:55:34 +00:00
|
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
|
|
|
"hero-question-mark-circle-mini"}
|
2026-02-07 21:59:14 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
|
|
|
|
|
|
|
|
|
~H"""
|
|
|
|
|
<span class={[
|
|
|
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
|
|
|
|
@bg,
|
|
|
|
|
@text,
|
|
|
|
|
@ring
|
|
|
|
|
]}>
|
|
|
|
|
<.icon name={@icon} class="size-3" /> {@status}
|
|
|
|
|
</span>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp format_date(datetime) do
|
|
|
|
|
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
|
|
|
|
end
|
|
|
|
|
|
feat: add Printify order submission and fulfilment tracking
Submit paid orders to Printify via provider API with idempotent
guards, Stripe address mapping, and error handling. Track fulfilment
status through submitted → processing → shipped → delivered via
webhook-driven updates (primary) and Oban Cron polling fallback.
- 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps)
- OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment
- FulfilmentStatusWorker polls every 30 mins for missed webhook events
- Printify order webhook handlers (sent-to-production, shipment, delivered)
- Admin UI: fulfilment column in table, fulfilment card with tracking info,
submit/retry and refresh buttons on order detail
- Mox provider mocking for test isolation (Provider.for_type configurable)
- 33 new tests (555 total), verified against real Printify API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:51:51 +00:00
|
|
|
defp fulfilment_badge(assigns) do
|
|
|
|
|
{bg, text, ring, icon} =
|
|
|
|
|
case assigns.status do
|
|
|
|
|
"submitted" ->
|
|
|
|
|
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
|
|
|
|
|
|
|
|
|
"processing" ->
|
|
|
|
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
|
|
|
|
|
|
|
|
|
"shipped" ->
|
|
|
|
|
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
|
|
|
|
|
|
|
|
|
"delivered" ->
|
|
|
|
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
|
|
|
|
|
|
|
|
|
"failed" ->
|
|
|
|
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
|
|
|
|
|
|
|
|
|
"cancelled" ->
|
2026-02-12 22:55:34 +00:00
|
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
|
|
|
"hero-no-symbol-mini"}
|
feat: add Printify order submission and fulfilment tracking
Submit paid orders to Printify via provider API with idempotent
guards, Stripe address mapping, and error handling. Track fulfilment
status through submitted → processing → shipped → delivered via
webhook-driven updates (primary) and Oban Cron polling fallback.
- 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps)
- OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment
- FulfilmentStatusWorker polls every 30 mins for missed webhook events
- Printify order webhook handlers (sent-to-production, shipment, delivered)
- Admin UI: fulfilment column in table, fulfilment card with tracking info,
submit/retry and refresh buttons on order detail
- Mox provider mocking for test isolation (Provider.for_type configurable)
- 33 new tests (555 total), verified against real Printify API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:51:51 +00:00
|
|
|
|
|
|
|
|
_ ->
|
2026-02-12 22:55:34 +00:00
|
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
|
|
|
"hero-minus-circle-mini"}
|
feat: add Printify order submission and fulfilment tracking
Submit paid orders to Printify via provider API with idempotent
guards, Stripe address mapping, and error handling. Track fulfilment
status through submitted → processing → shipped → delivered via
webhook-driven updates (primary) and Oban Cron polling fallback.
- 9 fulfilment fields on orders (status, provider IDs, tracking, timestamps)
- OrderSubmissionWorker with retry logic, auto-enqueued after Stripe payment
- FulfilmentStatusWorker polls every 30 mins for missed webhook events
- Printify order webhook handlers (sent-to-production, shipment, delivered)
- Admin UI: fulfilment column in table, fulfilment card with tracking info,
submit/retry and refresh buttons on order detail
- Mox provider mocking for test isolation (Provider.for_type configurable)
- 33 new tests (555 total), verified against real Printify API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:51:51 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
|
|
|
|
|
|
|
|
|
~H"""
|
|
|
|
|
<span class={[
|
|
|
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
|
|
|
|
@bg,
|
|
|
|
|
@text,
|
|
|
|
|
@ring
|
|
|
|
|
]}>
|
|
|
|
|
<.icon name={@icon} class="size-3" /> {@status}
|
|
|
|
|
</span>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-07 21:59:14 +00:00
|
|
|
defp total_count(counts) do
|
|
|
|
|
counts |> Map.values() |> Enum.sum()
|
|
|
|
|
end
|
|
|
|
|
end
|