feat(dashboard): store admin SPA with the product catalog flow - #26
Draft
Rl0007 wants to merge 178 commits into
Draft
feat(dashboard): store admin SPA with the product catalog flow#26Rl0007 wants to merge 178 commits into
Rl0007 wants to merge 178 commits into
Conversation
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>
…d a flat inventory table" This reverts commit e6c733d.
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Backend —
ls_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+1get_product()— one product's edit screen, including publish blockerscreate_product()— title, collection, colours, sizes, price; generates variants and prices themupdate_product(),set_variant_published(),get_collections()Reuses what already existed rather than reimplementing:
StyleAttributeConfigurator.generate_variants(),set_variant_prices(), and theunpublish_if_incomplete_data()rule (surfaced up front as "Add at least one image" with Publish disabled, instead of a validation error after the click).Frontend —
dashboard/, Vite + Vue 3 +frappe-ui@1.0.0-beta.37(pinned to match buzz; note npm'slatesttag 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
Draft until those land.