Skip to content

feat(dashboard): store admin SPA with the product catalog flow - #26

Draft
Rl0007 wants to merge 178 commits into
mainfrom
feat/admin-dashboard
Draft

feat(dashboard): store admin SPA with the product catalog flow#26
Rl0007 wants to merge 178 commits into
mainfrom
feat/admin-dashboard

Conversation

@Rl0007

@Rl0007 Rl0007 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Vue 3 + frappe-ui admin dashboard at /dashboard, so a shop owner can run the catalog without touching Desk.

Why

Publishing one product today means visiting Item, Item Variant Attribute, Style Attribute Configurator, Item Price and the variant publish flags — five doctypes. The target user is a single store owner who knows Shopify and not ERPNext, so the dashboard speaks Product / Collection / Options / Live and never surfaces company, warehouse, price list, UOM or naming series — all resolved server-side from Lifestyle Settings.

What's here

Backendls_shop/api/admin/catalog.py, a task-shaped API rather than doctype CRUD:

  • get_products() — the whole list screen in one call (variant + published counts, price range, stock), fully batched, no N+1
  • get_product() — one product's edit screen, including publish blockers
  • create_product() — title, collection, colours, sizes, price; generates variants and prices them
  • update_product(), set_variant_published(), get_collections()

Reuses what already existed rather than reimplementing: StyleAttributeConfigurator.generate_variants(), set_variant_prices(), and the unpublish_if_incomplete_data() rule (surfaced up front as "Add at least one image" with Publish disabled, instead of a validation error after the click).

Frontenddashboard/, Vite + Vue 3 + frappe-ui@1.0.0-beta.37 (pinned to match buzz; note npm's latest tag is the older 0.1.x line). DesktopShell + Sidebar, routes grouped under /store/* so a Rail can be added later without restructuring.

Verified

End-to-end in a browser against a real site: create → auto-generated variants with prices → detail page → publish/unpublish, plus the blocked-publish path.

Not in this PR

  • No tests. Deliberate — we're getting the flow working end to end first; tests follow as their own pass.
  • Image upload, size-price editing, stock receipt and bulk publish are next; until images land, products created here cannot go live.
  • No permission gate on the route or endpoints yet.

Draft until those land.

Rl0007 and others added 30 commits August 19, 2026 17:02
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… layout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… inventory table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Homepage-complete second theme for the shop_themes engine, ported from the
Pixio bootstrap template. Splits the shared document shell out of the default
theme into shop_base_theme/components/base.html so a theme owns only its chrome.

Vendored CSS, fonts, icons and demo photography come from the public Pixio demo
and are NOT licensed - they must be replaced from the purchased package and the
demo imagery stripped before this ships anywhere public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The homepage sections all read Pixio Theme Settings, which until now was only
populated by throwaway console scripts - so any fresh site rendered every
section empty. Idempotent: tables fill only while empty, scalars only while
falsy, so a re-run never clobbers merchant-curated content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Words come from the enabled category tree rather than a settings field, so the
strip self-updates instead of drifting out of sync with the menu.

Two tracks instead of the reference's one: upstream pins its single ul to the
wrapper width while the content is 2.7x wider, so its -100% keyframe scrolls one
viewport and snaps mid-word. Duplicating the track makes the loop seamless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(core): read the customer group from Selling Settings

_create_party_for_user read a customer_group field off Lifestyle Settings that
has never existed, so party creation threw and every checkout failed at the
quotation step - on the un-themed storefront too, not just Pixio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(core): resolve a leaf customer group for party creation

get_root_of("Customer Group") returns the is_group=1 root, which Customer
rejects, so an unset Selling Setting still broke checkout. Falls through to
frappe's default then to any leaf group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ettings tabs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ERPNext moved its transaction mappers into sibling `mapper` modules, and both layouts are
live across the versions this app runs against. The pinned import raised at module import
time, which took `ls_shop.migrate` down with it and skipped the whole `after_migrate` hook -
so route seeding, the search index and the payment modes never ran on an older bench.

Mirrors the resolution already used in `api/admin/orders.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XS-XXL are labels, not measurements, so `numeric_values` made ERPNext demand a from/to
range on Item Attribute and throw "From Range has to be less than To Range". It is the
first step of the installer, so the whole demo catalog failed before anything was created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`configure_lifestyle_settings` never set the mandatory company, and steps 5 and 6 assumed the
Website Item doctype exists. Website Item ships with webshop, which this app no longer
depends on, so on a bench without it the installer threw and rolled the whole catalog back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds the menu the storefront header renders, without opening Desk. The tree is drag and
drop at every level: dropping inside another entry re-parents it, dropping between siblings
reorders them, and sortable's own move hook refuses a drop that would bury a branch past the
depth the server allows, so the gesture never animates into an error.

An entry can link to several item groups at once, which is the case Desk's Link field could
not express - products from every listed group are shown together under the one entry.
Building the whole menu from an existing item group tree is one action.

The preview frames the real storefront rather than mocking a menu, so what is checked is the
theme's own header. Every mutation returns the whole tree, so the editor adopts the server's
answer instead of patching a local copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rl0007 and others added 30 commits August 31, 2026 19:57
Section 3 of 8: Collections.vue and Attributes.vue move off mock.js onto
the real catalog, following the shared data layer sections 1-2 built.

Collections manages Item Group, not Ecommerce Category — section 1's
catalog.get_collections (the Products filter) already reads Item Group, so
this stays consistent rather than introducing a second collections concept.
catalog.py grew list_collections (leaf Item Groups only, picked out by
nested-set shape lft/rgt rather than by name, so a structural parent like
"All Item Groups" never shows up as a collection) with a real per-collection
product count, and create_collection. ls_shop has no smart-collection rule
engine, so "Type"/"Condition" now read "manual"/"—" for real instead of the
mock's per-row fabrication - flagged in commera-open-questions.md as
separate, larger work.

Attributes reads catalog.get_attributes (Item Attribute + its values, plus a
usage count computed from Item Variant Attribute, all in a fixed 3 queries
regardless of how many attributes or values exist - verified by call
counting, not assumed). "New attribute" now creates a real Item Attribute
via create_attribute; "Edit" was previously unwired and now opens the same
dialog.prompt pattern to append one value via the new add_attribute_value.

The wiring map's variant traps get their guard: catalog
.check_abbreviations_are_distinct (new - it did not already exist, despite
the brief assuming it did) refuses a colliding abbreviation up front,
verified live against Size's existing "XL". Renaming/deleting a value or the
"Size" attribute itself stays unwired - both are edits dangerous enough
(item codes don't move; generate_variants() depends on the literal name
"Size") to need their own confirmation-dialog design, not a data-source swap.

Product Types stays untouched on mock, per scope - a short options memo for
the owner is appended to commera-open-questions.md instead of guessing at a
schema overnight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Section 4 of 8: Inventory.vue, Adjustments.vue and Pricing.vue move off
mock.js onto the real ls_shop backend, following the shared data layer
sections 1-3 built.

inventory.get_inventory now carries committed (Bin.reserved_qty) and a
per-variant fallback image, both batched the same way catalog.get_products
already reads them - reused catalog.get_size_stock instead of a second
Bin query. Receiving stock reuses the existing additive inventory.receive_stock
unchanged; the on-hand column stays read-only (same convention as
ProductStock.vue) since ls_shop has no "set to X" write, only "add".

Adjustments has no adjustment-with-reason ledger of its own to read - the
only stock write anywhere in the app is the additive receive above. Rather
than fake a ledger, the new inventory.get_stock_movements reads the real
one ERPNext already keeps (Stock Ledger Entry) against the shop's own
warehouse, mapping voucher types to plain-language reasons (Received/Sold/
Stock count/Transferred). Every row in this dataset today is a receipt
because nothing else has posted against the warehouse yet, but the mapping
isn't Received-only by design.

Pricing gets a new catalog.get_pricing_rows, reusing get_products()'s
batched joins at variant grain instead of duplicating them, and reuses the
existing catalog.set_variant_price for both the inline price edit and the
two bulk actions. Price/compare-at follow the same sale_rate/default_rate
convention VariantDetail.vue already established. ls_shop has no cost or
margin field anywhere, so the Margin column is left inert with an in-code
comment rather than reviving the mock's fabricated 55%-COGS math; the
"Type" filter stays a single disabled option rather than repeating section
1's unapproved Type-to-Collection substitution. A memo with real cost-field
options (Bin.valuation_rate, Item.last_purchase_rate, a new explicit field)
goes into commera-open-questions.md for the owner to pick between.

Query cost: get_inventory 5 queries, get_stock_movements 7, get_pricing_rows
7 - all batched, independent of row count (measured at N=72/72/18).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
The .gitignore rules that kept the old dashboard's build output out of the repo
were written against ls_shop/public/dashboard/ and www/dashboard.html. Renaming
the route to /commera orphaned both rules, so every wiring commit since the swap
has been carrying 83 generated files, sourcemaps included.

Repoint the rules at the commera paths and untrack what leaked in. The files stay
on disk — the site serves them through the sites/assets/ls_shop symlink — they
are just no longer version-controlled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Section 5: move Orders and Order Detail off mock.js onto ls_shop.api.admin.orders,
which already backed most of this (get_orders/get_order/fulfil_order/get_overview
from earlier sections). Extends orders.py rather than duplicating it: the five tab
filters (all/unfulfilled/unpaid/open/closed) are new filter builders, and a
payment_state (paid/pending/refunded/partly refunded) is computed alongside the
existing fulfilment state — batched for the list, per-order for the detail screen.

Building a real prepaid order end to end (own scratch customer/order/invoice/
payment, deleted afterward) to verify the payment badge surfaced a real, previously
unexpected bug: a captured payment's Payment Entry Reference points at the Sales
Invoice raised for the order, never at the order itself, so ls_shop.api.orders.
get_refund_status/make_refund_payment_entry (keyed on "Sales Order") can never find
one — refunded and never-paid orders look identical to it. describe_payment here
reads the correct Sales Invoice-linked path instead, so the badge is trustworthy,
but Refund stays inert rather than wired to a helper that would look live and do
nothing; Cancel order also stays inert, since admin-initiated cancellation is a
real product decision the owner-scoped storefront cancel_order doesn't answer.
Both are memoed in docs/commera-open-questions.md.

OrderProgress.vue now renders the order's real progress ladder (confirmation
pending through delivered, with cancelled/returned as terminal states) instead of
deriving a four-step guess from mock payment/fulfillment enums. A draft
confirmation-pending COD order (this site's entire 318-order dataset) reads as
"Confirmation pending" / "Cash on delivery", never as broken, per the COD domain
rule. Bulk "mark fulfilled" loops fulfil_order per order, same shape as Products.
vue's bulk archive, since there is no bulk-fulfil endpoint. Export, packing slips,
Duplicate, print invoice, and Create order stay inert — no backend concept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Customers and CustomerDetail were still reading dashboard/src/data/mock.js.
Added ls_shop/api/admin/customers.py (get_customers, get_customer), following
orders.py's conventions since there was no customers module at all.

Every seeded Sales Order is a draft (docstatus 0) COD order, so both
endpoints treat a draft the same way orders.py's is_webshop_order/
read_sales_window already do: it counts as a real order and real spend,
not a zero. Lifetime spend sums base_grand_total, matching the Overview
screen's revenue convention. A customer's order/payment badges reuse
orders.py's describe_state/describe_payment_state so a status means the
same thing here as it does on the Orders screen.

The list aggregates order count and spend for a whole page of customers
in one grouped query, and resolves city off a batched Dynamic Link ->
Address read, rather than looping per customer.

Customers.vue gained real pagination (the list is 300 rows and the API
paginates, so an unpaginated dump wasn't an option). CustomerDetail's
recent-orders row was reshaped to the real order fields section 5 already
settled on (state/payment_state badges, item_count, total) instead of the
mock's fabricated "channel" field, which orders.py already dropped as a
concept ls_shop's data model doesn't have. Average-order now guards
against a zero-order customer instead of showing NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Every list view died on `Unexpected token '<'`. frappe-ui's useCall fetches
`baseUrl + url` verbatim and does not prepend /api/method/, so the shared wrapper
was handing the browser a bare dotted path. Relative to /commera/orders that
resolves to /commera/ls_shop.api.admin.orders.get_orders, which the SPA
catch-all route rule answers with the HTML shell — so every screen parsed a web
page as JSON.

The prefix is now absolute. Every screen calls through this one wrapper, so this
is the whole fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Two dead-ends in Commera's Products screen, closed end to end.

Add product: catalog.create_product already existed but nothing wired it
in - AddProductDialog.vue is new (no such file was in the tree despite the
brief pointing at one), built as a single-screen form on the same Dialog +
FormControl conventions VariantDialog.vue already uses rather than a new
pattern. Company, warehouse, price list, UOM and naming series stay
resolved server-side; the owner never sees an ERPNext concept. Two traps
get real guards instead of a late DuplicateEntryError or a silent
"Value missing for: Size": create_product now refuses up front if the size
axis isn't literally the "Size" attribute, and gained optional
option_abbreviations/size_abbreviations overrides so
check_abbreviations_are_distinct is reachable from product creation itself,
not only from the Attributes screen's add_attribute_value. The dialog does
not expose an abbreviation field - Shopify doesn't either - but the
capability exists for a future bulk-import "variant code" column.

Bulk import: ls_shop/api/admin/imports.py is new. The template is a real
xlsx built with frappe's own make_xlsx/build_xlsx_response (the same
machinery Data Import's own template download uses), served through
frappe.response so a plain anchor href triggers a real download - no
hand-rolled binary and no relative-URL trap. Its header row is generated
from the same field list the importer parses, so a filled-in copy of it
maps itself. The importer accepts .xlsx and .csv, validates every row
before writing anything (collection must already exist, colour/size
required, one price for the whole product, duplicate variant detection),
and only ever creates a product through catalog.create_product - never
touches Item directly. A product whose own creation still fails after
validating clean is rolled back to its own savepoint, so one unlucky row
never leaves orphaned Items behind or blocks the rest of the file. The
existing wizard UI (Upload/Map/Review/Run) is wired to this real data
instead of redesigned; the Images step stays a documented placeholder,
matching the existing "zip-based bulk photo import" inert-control note.

Verified against dev.localhost: created and deleted a real product via
create_product (counts back to 9/18/81), the Size-attribute guard and the
abbreviation guard both refuse with the exact message pasted, the template
downloads as a real xlsx over HTTP with a matching header row, a 2-product
round-trip import created and was cleanly deleted, and a mixed file with
one bad row imported the good product while the bad row's error was
reported with zero orphaned Items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Section 7: the audit's two "fix these first" navigation bugs, plus the three
analytics report screens, still fully on mock data.

Dashboard.vue and SearchPalette.vue both pushed mock ids (order slug "1420",
product id "p-2") into real routes, so the owner's first click and every ⌘K
result threw a ValidationError. Both now read orders.get_overview,
catalog.get_top_products (already landed in this tree) and, for search,
catalog.get_products/orders.get_orders/customers.get_customers/catalog.
list_collections — every id on screen is a real record name the
corresponding detail endpoint resolves.

ls_shop/api/admin/analytics.py gains three report endpoints (get_revenue_
report, get_inventory_report, get_storefront_report) that extend rather than
duplicate the existing analytics_dashboard.py aggregates, so a session or
conversion figure means the same thing on the report as it does on the Desk
analytics dashboard. Revenue and inventory stay on orders.is_webshop_order's
convention throughout (a draft COD order, this shop's entire seeded book,
counts as real revenue); storefront month-buckets 34k+ events with a
portable DATE_FORMAT/TO_CHAR groupby (the same MariaDB/Postgres split
frappe.utils.goal.get_monthly_results already uses), never a Python loop
over the raw rows. Inventory's stock-value trend reuses analytics_dashboard.
get_stock_movement's warehouse-level ledger walk rather than re-deriving a
historical valuation this data model doesn't carry, clamped at zero where
the walk drifts negative past this shop's single seed-time Stock
Reconciliation.

Storefront's "Search terms" table has no backend concept at all (the
Storefront Analytics Event doctype carries no search-term field) and stays
an honest empty state rather than fabricated rows; "Top pages" is relabelled
because it's landing sessions, not a true page-view count. Both documented
in docs/commera-open-questions.md (already merged in this tree), along with
a related pre-existing bug (catalog.get_recent_product_sales still filters
docstatus == 1) found but left alone since it's outside this section's
screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Two agents edited catalog.py concurrently and one reconstructed its commit
against HEAD, dropping the other's changes back into the working tree. The
create_product guards survived only as an uncommitted diff.

They are worth keeping: generate_variants() lowercases the attribute name into a
"Color Size Item" fieldname, so any size attribute not named exactly "Size" fails
deep inside variant generation with "Value missing for: Size", far from the call
that caused it. And an explicit abbreviation override can collide with an
existing value, which surfaces as a DuplicateEntryError naming an item code
nobody typed. Both now refuse up front with a message that names the real cause.

Verified: both guards throw the intended error and leave no orphaned Items —
counts stay at 9 configurators / 18 variants / 81 items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LryGnhtV448ts6ujYbjRV4
Wire the navigation and footer editors with a live chrome preview, add the
attribute and option-size pickers to product creation, and move the admin
calls onto /api/v2 so useCall can unwrap them. Backend: quote the selling
rate rather than the list price, guard reserved and duplicate product
titles, and stop system_user_session() from corrupting the shopper session
so orders stay owned by the buyer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FTuqvyRcrr2cY6qqNdUsi
A book has neither axis, but every layer here is keyed on Style Attribute
Variant: Color Size Item.size is reqd, checkout.py drops a cart line whose
item has no "Size" attribute row, and a variant with an empty sizes table
unpublishes itself. So the axes are hidden from the owner rather than
removed - the trick Shopify plays with its "Default Title" variant.

create_product now takes both axes as optional and fills them with
"Standard"/"One Size", creating either attribute on first use so a store
that has never sold a garment can still list a book. A grid where every
option is sizeless is a book; one where only some are is still the owner
forgetting a row, and is still refused.

On the storefront a lone size counts as already chosen - without that,
nothing ever sets ?size= and add_to_cart refused the item forever - and the
picker, the option row and the size list hide themselves.

Also repairs TestCreateProduct, whose six tests had errored in setUp since
the size-attribute guard landed in bd0b693.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fNrHeRfcK6HnAMJdtEMfH
The settings dialog listed seven gateways and five apps from a hardcoded array in
data/integrations.js, with Shiprocket marked `connected: true` — a literal in a JS
file. "Save keys" toasted and set a local flag. Nothing reached the server, so a
store owner still had to open Desk to connect anything.

The backend for this already existed and is provider-agnostic: `describe_integration`
derives every field from `frappe.get_meta()` in layout order, and payments.py and
shipping.py are each a registry plus two whitelisted wrappers. Only the frontend half
was missing.

IntegrationsPanel takes a store and is mounted twice, once per registry, so payments
and shipping are the same screen. Field groups come from the settings doctype's own
Section Breaks, which means a new docfield on a gateway or carrier Single appears in
the dashboard with no change here. A Password renders blank with "leave blank to keep
it", never its value. `available`, `enabled`, `configured` and `missing` are the
server's answers, so a card cannot claim a connection the site does not have — and
enabling a provider with a required field blank is refused by the server, by name.

Both registries load when the dialog opens rather than when their tab is first shown:
an unread registry counts zero, which reads as "nothing connected" instead of "not
looked yet".

Verified in a browser against dev.localhost: the sidebar reads Payments 1 / Shipping 1,
the payments tab lists the four registered gateways (not the seven mock ones) with
Razorpay Live, the shipping tab lists Shiprocket "Add keys" and AfterShip "Live", and
Shiprocket's screen renders its five field groups with "3 still needed".

The mock analytics and accounting rows move to data/mock.js, minus the shipping
carriers, which are real now and would otherwise contradict the Shipping tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DwUYNuBnzS6jfri3SQMtt
A `git reset --hard` (reflog HEAD@{0}, straight after a fast-forward pull)
discarded every uncommitted change on this branch. Nothing was stashed and
nothing had been pushed, so git itself held no copy.

The tree it left behind did not hold together: data/api.js was back to a
revision without useMethodRead/useMethodAction, which RefundDialog.vue
imports, and router.js had lost the two /storefront/pages routes that
PageDetail.vue is reached through. The files those screens need had never
been committed, so they survived as untracked orphans pointing at code that
no longer existed.

Recovered 45 files from Claude Code's own session transcripts, which record
Write contents and Read results verbatim. Only captures dated 04 Sep — the
day before the reset — were restored: earlier captures predate 486eb3f and
are the old merchant-dash tree, so restoring them would have overwritten
current files with stale ones. router.js had no capture and was rebuilt from
a full listing taken earlier in the same session.

Also commits the untracked source the branch has always depended on and
never carried: the pages and theme editors, the integrations components, the
Shop Web Page doctype and its theme templates, and their tests. Being
untracked is what let the reset leave them stranded.

Verified: every route's component resolves, no unresolved local imports
across the tree, and `yarn build` completes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
A mechanical audit of dashboard/src for controls and screens that look
finished and are not — 22 findings across dead controls, shell screens, mock
data still in place, and whitelisted methods no UI reaches. Each row carries
its file:line, the handler as it stands, why it is unfinished, and what
wiring, deleting or hiding it would cost.

The HTML sheet is the same 22 findings as a decision surface: one verdict
plus a note per row, kept in the browser. The decisions taken against it are
recorded separately and drive the follow-up work; nothing here changes any
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
A read-only pass over all 71 .vue files in dashboard/src, run by five
parallel reviewers against frappe-ui beta.56 and Gameplan's patterns, with
the per-reviewer reports kept under docs/implementation/mobile-audit/.

Fourteen findings, four of which are already fixed in the tree: the mobile
shell exists (App.vue swaps AppShell for MobileLayout on viewport), menu
reordering works on touch, the data tables scroll, and the settings controls
no longer hardcode a width. The remaining six are still open and the audit
records them as a queue, not a report.

Note the version skew the doc opens with: Gameplan is the look reference but
runs five frappe-ui versions behind what this dashboard compiles against, so
its patterns transfer and its API calls do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
P0-2 variant photos are deletable on touch: the remove control is visible
below sm and hover-revealed above it. P0-4 order detail keeps its customer:
the right-rail sections move into OrderCustomerPanel and render again inside
the scroll area below lg, so the desktop rail is untouched. P1-6 and P1-7 give
the customers list and the import mapping grid the overflow-x-auto wrapper
their siblings already had. P1-10 matches the customer stat row to the two
equivalents it had drifted from.

P1-13 turned out to span more call sites than the audit counted (17, not 4),
so it is split: AppPageHeader gets min-w-0/shrink-0, which stops every page
overflowing sideways, and only the headers that are actually crushed at 375px
change further — real secondary actions drop to icon-only, deliberately inert
ones (Export, Price rules, Preview store) step aside below sm.

Customer detail's own recent-orders list needed the same wrapper as #6: its
rows were clipped rather than scrollable at 375px, with three cells collapsed
to zero width. The audit missed it because the list is in feed mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
P1-8 hides the import footer's reassurance below sm so Back and Continue keep
the row. P1-9 stacks the images step's tab strip under its title, which was
down to 103px beside it. P1-11 caps the storefront device mock at max-w-full;
at 320px it was 352px wide inside its own parent. P1-12 collapses the footer
editor's preview on a phone, as the navigation editor already did — both now
read the shared useIsMobile rather than restating its media query.

P2-15: backTo was declared, passed by eight pages and never rendered, so
Adjustments — which passes only backTo and no breadcrumbs — had no way back at
all. It becomes a PageHeaderBackButton below sm, where the sidebar is gone;
desktop keeps breadcrumbs and is unchanged.

P2-16: six steps never fit "Confirmation pending" on one line at any width, so
the labels wrap instead of truncating, and below sm the ladder becomes two rows
of three with the connectors dropped rather than running off a row's edge.

Dashboard's recent-orders list was one step from the bug that bit Customers:
31rem of fixed columns inside a 34rem minimum left its name column 3rem. Swept
every list for the same shape; this was the only other one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
P2-17 gives the pagination's Previous/Next icon-only twins below sm, so the
control stops wrapping mid-row at ~360px. P2-18 moves the variant dialog's
actions into frappe-ui's #actions slot, the way AddProductDialog already does;
the slot supplies the footer band and its padding but no flex layout, so the
row stays local. The v-if sits on the template itself, which keeps the slot out
of $slots entirely while there is no variant rather than rendering an empty
action band. P2-20 lets the import step label truncate and drops its hint below
sm, so no string length can push "Step N of M" off the row.

The stock page showed no number at all on a phone — every quantity column sat
off-screen in the horizontal scroller. Available now rides on the variant line
below sm. It shares one availableStock() helper with the column, and stays on
that line rather than a third because --list-row-height is a fixed px value and
a third line would be clipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
OrderProgress hid its connectors below sm, which left the dot as the only
child of a justify-start row: every dot sat 27.5px left of the label it
belonged to. It centres below sm now, and the ladder centres its last row —
describe_progress returns 2 to 7 steps, not the 6 the wrap math assumed.

ImportStepNav's row went back to items-center. truncate makes a span a scroll
container, and a scroll container synthesises its baseline from the border box
rather than propagating its line box's, so the truncated spans had started
aligning by their bottoms against a counter still on its text baseline.

The images step's tab strip is 321px of hard content and overflowed the dialog
body by 8px at 375 and 63px at 320; it scrolls in place below sm instead.
TabButtons cannot shrink — its track hardcodes inline-flex shrink-0 — and its
size prop already defaults to sm, so neither of those was a way out.

The twin-button idiom is now one ResponsiveButton. frappe-ui has no
single-element form of it, but hand-typing the pair meant ListPagination and
ReportHeader wrote their handler and disabled condition twice.

Also: @vueuse/core is declared rather than borrowed from frappe-ui's hoisted
copy; View in ERP always sits in the order menu, since sm starts at 640px while
useIsMobile ends at 639.98 and a fractional width between them hid both copies;
Pricing shows no action below sm rather than one permanently disabled one; and
VariantDialog guards sizes everywhere it reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
The unfinished-work audit found controls that report success and do nothing.
Most could not be wired because no endpoint existed. This is that backend half.

catalog: delete_product refuses a product that has ever been ordered and names
Archive instead, so a never-sold product is the only one that hard-deletes;
add_products_to_collection files a selection under a leaf Item Group, which is
what a collection is here; set_restock_level writes Item.safety_stock, and
inventory.get_inventory now reads that per row instead of one store-wide
threshold, so "low stock" means what the owner set it to. Deliberately not
ERPNext's reorder/auto_indent - nothing raises a purchase and nobody is emailed.

orders: get_orders and get_order surface the documents a merchant prints.
Delivery Notes include drafts, because fulfil_order creates a draft and that is
what a packing slip prints from; invoices are separate, since a prepaid order is
invoiced at payment time and the invoice is not the delivery.

settings: get_company_profile and get_locations, so the dashboard can stop
rendering an invented company and a mock warehouse as if they were records.

imports: match_import_images matches uploaded photos to a product colour by file
name, and run_import attaches them, replacing a step that fabricated its counts.
Also fixes a latent crash next door - a bare hash is not a parseable MariaDB
savepoint name, so the existing rollback path threw a syntax error over whatever
had actually failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
delete_product destroyed the product's photos before ERPNext was ever asked
whether the delete was allowed. delete_doc runs on_trash and attachment removal
before check_if_doc_is_linked, so the files left the disk and only the SQL rolled
back. It fired on the common path, not an edge: Storefront Analytics Event links
to Item and is not in ignore_links_on_delete, so any product a shopper has merely
viewed is blocked. The whole chain is now proved deletable before anything is
touched, and the history probe covers all eight doctypes that make a product
historical rather than order lines alone, each refusing in words a shop owner
reads.

A malformed image_assignments took the import's own products down with it -
parse_json returns a list or a string just as happily as a dict, and .items()
then threw after the creation loop, outside any try, so the rollback discarded
exactly what the per-group savepoints were protecting. The shape is validated
before any product exists, and run_import now puts its URLs through the same
validation match_import_images uses, private files included: add_images only
checks that some File row holds that url, never who owns it.

add_products_to_collection was the bug it was written to fix. It wrote
Item.item_group, but the storefront and the search index read the variant's own
copy, which is only ever filled while blank - so the admin list moved and the
category page did not. The variants are synced through their own controller, and
one helper now answers "is this a valid collection?" for both writers.

Credit notes are no longer returned as invoices, and return notes no longer join
a bulk packing-slip run: a refunded parcel does not belong back on the dispatch
bench. Tests cover the two paths that could lose data, each proved red first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
The test sampled five arbitrary Sales Orders and compared the cost of reading
the first against the cost of reading all five. An order with no paperwork skips
the delivery-note and packing-slip reads entirely, so as soon as the newest order
on a site had no delivery note the two numbers differed for a reason that has
nothing to do with scaling, and the test failed on data rather than on code.

Measured directly, the reader costs four queries for one order and four for
five. Sampling only orders that carry a delivery note keeps the assertion about
what it claims to be about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
An audit found 22 controls and screens that look finished and are not. They do
not crash, so clicking through never caught them. Each one is now wired to a
real endpoint, or gone.

Wired: delete a product (refusing, with a reason, anything that has ever been
ordered), copy and preview the storefront link, receive stock, set the low-stock
level, move products into a collection from both the bulk bar and the product
menu, print a delivery note and an invoice, publish the products under a menu
entry, view the storefront, log out, and refund an order. Settings now reads and
writes the store's own records instead of rendering an invented company, an
invented warehouse and three invented members of staff; its long tail of fields
comes from the doctype's own meta, so adding a field surfaces it.

The import wizard's Images step was the worst of them: a setTimeout progress bar
ending in "356 of 372 images matched by file name", hardcoded, over an import
that really did create the products. Photos are now really uploaded, really
matched to a product and colour by file name, and really attached - and every
number on the screen is counted from the files chosen.

Removed rather than dressed up: product types (a routed screen with no backend
at all, leaking mock rows onto the real product form), a simulated zip import, a
storefront listing form that saved nowhere, the URL-column photo mode (making it
real means fetching arbitrary URLs server-side), and the grey furniture whose
only purpose was to name a feature that does not exist.

Two screens turned out to have a wired implementation sitting unused beside a
mock: the theme editor and the refund dialog were both complete and imported by
nothing, while the route and the menu pointed at fixtures. Both now reach the
real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
Saving the cart Quotation resolves the party's receivable account, and ERPNext's
account_perm_check calls frappe.has_permission("Account") directly, so no
ignore_permissions reaches it and no storefront role has Account read. Every
write to the cart in the shopper's own session was refused: Place Order, the
checkout page's shipping rule, the address step, the coupon, the delivery
charges. Nobody could buy anything.

system_user_session() already existed three functions away, its docstring already
named this exact ERPNext behaviour, and place_order and place_cod_order already
used it - the cart saves did not. They do now, through one helper, with only the
save inside the elevated window: the caller has already proved the cart belongs
to the session user, because _get_cart_quotation scopes on contact_email, and
every ownership and validation check still runs as the shopper.

The stock check existed too, and only the cart's own JavaScript ever called it,
so a tampered client could order 9999 of an item with 15 in stock and get a
quotation for it. It is enforced on the server now, through the same function
rather than a second implementation.

An item with no row in the sale price list showed a real price on the product
page and nothing in the cart, because the cart alone had no fallback. It falls
back the same way the product page does. Note this fixes what the cart shows,
not the rate ERPNext books: the quotation still sells from the sale price list
alone, which is a decision about how the two configured lists relate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
Storefront Pages had no way in at all: both "Add page" buttons passed `to`,
which is not a frappe-ui Button prop, so it landed in $attrs as an inert DOM
attribute. With no pages on the site there was no row to click either, and a
working editor sat behind a button that did nothing. It is the only misuse of
that prop in the tree.

The theme editor's preview pane was a 500. theme_editor_preview imported two
names that had never existed, so the module failed on import - invisible until
the Theme route stopped pointing at a mock and started rendering the real editor.
render_page_preview now renders the live theme's home page the way a real request
does, and blanks the tracking blocks so an editor pane emits no analytics.

Saving a price of zero reported "Price updated" and wrote nothing: the pricing
helper treats a non-positive rate as "leave this list alone", which the create
flow depends on, so the refusal belongs at the endpoint. It says a price cannot
be removed once set, because there is nowhere in the app that can remove one.

The Overview's attention tiles counted the preview list, which the API caps at
five, so they read five however many there were. Both totals come from the
server now, out of the same pass that builds the preview.

Also: bulk actions no longer sit enabled over an empty selection; three menu rows
that promised editing now say what they really do - a read-only list of
addresses, a read-only list of options, and the unfiltered orders list; Settings
no longer states that no company is set while it is still asking; an unknown
/commera path gets a real not-found screen instead of an empty shell; the variant
dialog stops rendering the literal word undefined; and a Margin column that could
never hold a value is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
Orders offered Export and Create order, Customers offered Export and Add
customer, and all four only ever toasted that they were not supported. Create
order and Add customer contradict a decision already made: a shopper places
their own order and a customer record is created the moment they check out,
which is why validate_document_access compares owner to the session user. The
two exports have no endpoint, the same reason the product CSV export went.

The import wizard keeps its six sources. Only the spreadsheet works, but the
other five stay named and badged: a merchant arriving from Shopify should see
that we know about it and that it is not their way in yet. That is what the
step's own comment argued, and it is a different case from a button that looks
like every other working button in a header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
Publishing one option and receiving stock against it both existed, on the
variant's own page - product, swatch, dialog, "Open full page". Three clicks from
the matrix that shows every option, which is where a merchant is standing when
they want either.

Both are row actions on that matrix now, and the matrix says what the storefront
thinks: Live, Hidden, or the reason it cannot go live yet. That reason is the
server's own blocker list, the same text set_variant_published refuses with, so
an option missing a photo says so before the click rather than after it.

Receiving is the additive receipt the Stock screen and the product menu already
use - one quantity onto every size of the option - because ls_shop has no way to
set on-hand to an exact number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdA6pnQb2yRMg8RFZdV1pH
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0127esXD5REhi7otqsBLYVaW
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0127esXD5REhi7otqsBLYVaW
…mark

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbXDZ5bAG5GmPFpDUVswS3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants