Dokploy Path /admin on the customer app host needs Vite base /admin/ so index.html loads /admin/assets instead of user-app root.
Co-authored-by: Cursor <cursoragent@cursor.com>
UTF-8 UI strings in product detail/cart were Latin-1-resaved; also remove the centred desktop phone frame so fixed chrome spans full width again.
Co-authored-by: Cursor <cursoragent@cursor.com>
The migration re-pointed Branch/Customer at hardcoded prov-* ids before
they existed (the insert is skipped when a city already has the name),
causing Branch_cityId_fkey failures on boot. Merge now runs after the
inserts, resolves the target province by name, and relinks every table
with a City foreign key before deleting the legacy row.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Nginx 502 Host-not-found meant the API never stayed listening. Stamp all
migrations when tracking is empty but a late table exists, apply DDL
statement-by-statement skipping already-exists, harden remaining CREATE
TYPE/TABLE files, and never let seed crash the process.
Co-authored-by: Cursor <cursoragent@cursor.com>
Dokploy boots crash-looped on CREATE TYPE AdminRole against an existing
Postgres with an empty _goldbank_migrations table. Adopt Prisma CLI history
when present, record the baseline if AdminRole exists, and treat duplicate
object errors as already-applied so the API can start without DB shell access.
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep OrdersView typing against this branch's API, and omit channel from createOrder since the server already forces WHOLESALE for app placements.
Co-authored-by: Cursor <cursoragent@cursor.com>
Rename the brand, ship Hedena logos, persist login OTP text in the SMS list, and port home/nav/auth/theme polish from main while keeping expert customers intact.
Co-authored-by: Cursor <cursoragent@cursor.com>
Rebuilds apps/api/release so the deployed image carries 5f3b004. The vendored
Prisma client changes are only its recorded build paths. No source change.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Experts (AdminUser) and customers (Customer) sign in to the same apps by mobile
but live in different tables, so the database cannot make the number unique
across them. A number could be given to an expert and to a customer, and the
login then silently merged the two (the customer row was adopted as the expert's
trading account), which left the expert on a customer session and the panel's
name edits unreachable.
The reference rejected a duplicate at entry (`unique:customers`, the agent being
a customers row too); this does the same where a number is typed in:
- customer create/update (wholesale, banker, walk-in): refused when an ACTIVE
expert holds the number;
- expert create/update: refused when a customer holds it (the expert's own
trading account excepted);
- the app's self sign-up: refused when an active expert holds it.
Only a number being created or CHANGED is checked, so existing duplicates keep
signing in and stay editable; the login's "expert wins" adoption is untouched.
Plain equality on the two unique columns, normalised with faToEn, so both
lookups are index hits.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- apps: the fixed «در حال مشاهده بهعنوان» strip (38px) sat over the fixed page
headers, which still sat at top: 0 (the old 30px body padding was shorter than
the strip too). The strip's height is now one variable, --aab-h, that the
fixed headers (PageBar, home, dashboard, toasts) use as their top.
- admin: the customer form offers only «مغازه دار اپلیکیشن» to anyone but the
super admin; bankers and walk-in shopkeepers are listed by super-admin-only
endpoints, so ordinary admins lost the customers they created under them.
- admin: the mobile slider rid (custom.config.statics.slider) was only under the
«سفارشی» branch, which the permission screen hides outside level 104. Same rid,
now listed under «مدیریت ثابت ها». Display only: the slider routes are still
gated by config.statics.
Rebuilds admin-panel, user-app and user-app-v2 dist.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
CustomerChoiceView, ChildOrdersView and ReportView bound their Drawer with
v-model:open, which only exists from ant-design-vue 4. Both apps ship 3.2.20,
so the drawer never opened: tapping a customer in «مشتریان من» did nothing and
the "ورود به حساب این مشتری" button inside it was unreachable.
Rebuilds user-app and user-app-v2 dist.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The "سفارش مشتریان" / "مشتریان من" entries are shown from session.isSalesExpert,
which was fetched from /expert/whoami only once, at sign-in. A slow or failed
request (silently swallowed) left the flag false until the next login, so an
expert saw a customer-only menu. The dashboard now asks again on mount and on
every re-activation, and keeps the current value when the request fails.
Rebuilds user-app and user-app-v2 dist.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Rebuilds apps/api/release and apps/admin-panel/dist so the deployed images
carry 0156a97 (X-Debug counts block on the wholesale lists, console output in
the panel). No source change.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sending X-Debug: 1 makes the five wholesale list endpoints return a counts-only
`debug` block next to the rows: the WHERE that actually ran, how many customers
the scope matches before the grid's own filters, how many customers exist per
kind/active/archived/step and how many are an expert's trading account. The
panel sends the header and console-logs the block when localStorage.debug === '1'.
Off by default; DIAG_LOG=false disables it server-side. No names or mobiles.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Prints aggregate counts only, prefixed [DIAG], right after migrations: customers
by kind/active/archived/step, what the omde list can show and how many rows are
sales-expert trading accounts (created vs adopted), experts' profile/trading
state, referral counts, the Admin table and the last applied migrations.
Read-only, no mobile/name/id is printed, each query isolated so a failure is
reported without stopping boot. Disable with DIAG_LOG=false; remove once the
empty-omde-list investigation is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app shows a sales expert their own account through the linked trading
Customer row (/customer/me). That row was seeded from the expert once, at first
sign-in, and never updated again, so a name, mobile, credit-limit or wage-percent
change made in the panel never reached the app. In the reference the agent and
that account were one row, so an edit changed both.
Editing an expert now mirrors exactly the changed fields onto the trading row in
the same transaction: name parts, credit limit (gram_level), wage percent, and the
mobile when no other customer holds it. Existing drift heals on the next save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PrismaExceptionFilter returned "Unique constraint failed on: mobile" in
English for a duplicate. It now answers in Persian, naming the field
(mobile, national code, member code, order number), with a safe generic
fallback. Covered by a unit test.
- The admin panel still shipped the old "NZ" favicon. favicon.png/.ico are now
generated from the project logo (the same one the user apps use), in both
public/ and dist/, and the links carry ?v=2 so browsers drop the cached icon.
- Rebuilt apps/api/release/app.js.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The reference (henza_server AdminController::getCustomer) lists type=2 AND
is_archive=0 with no verification or active filter. The develop-branch change
that hid inactive customers with a pending verification step from «عمده»
emptied the list for customers migrated in that state, while their numbers
stayed taken ("already exists"). Restore parity; the sales-expert trading
account exclusion is kept.
Includes the rebuilt release/app.js and a regression test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
app.js, seed.js, the Prisma client in release/vendor and the new migration
20261026120000_split_admin_table, built with `pnpm build:release` (API only;
the admin panel and user apps are unchanged by this work).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.
- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
copies non-expert rows with their ids, re-points the "who did this" foreign
keys (ledger, order status, sms, notification, upload, allocation, workshop,
admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
"AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.
Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keep admin browser bootstrap from falling back to the customer app URL, and exclude activated customers from the pending-verification list so it no longer mirrors the main omde grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.
Co-authored-by: Cursor <cursoragent@cursor.com>
اپ۲: دکمهی «افزودن به سفارش» در صفحهی جزئیات محصول دیگر از پایین صفحه بیرون نمیافتد و همیشه دیده میشود.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
اپ۲: رنگ دکمهها، نوار نرخ طلا و خطهای جداکنندهی بالای صفحه از سرمهای به قرمز-شرابی برند تغییر کرد.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
اپ۲: رنگ سرمهای از پسزمینهی بالای برنامه و رنگ نوار مرورگر حذف شد؛ همهجا از رنگ صفحهی اصلی استفاده میشود.
پنل: فرم افزودن شخص با هر بار جابهجایی نوع شخص خالی باز میشود و اطلاعات نیمهکارهٔ قبلی دیگر نمایش داده نمیشود.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
پنل: بخشهای «تک فروشی» داشبورد (کارت، نمودار روند و تفکیک عمده/تک) فقط برای مدیر اصلی نمایش داده میشود.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
اپ: لوگوی تازهی برند در صفحهی اصلی، لودر و صفحهی ورود نمایش داده میشود.
پنل: لوگوی تازهی برند در ورود، منو و کامنتها نمایش داده میشود.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود.
اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند.
پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند.
پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است.
پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Migrations were applied in plain name order, so 0002_admin_schema.sql ran
before 20260910113720_first and failed with relation "City" does not exist.
sortMigrations now always puts the baseline first; existing databases are
unaffected because their applied names are unchanged.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Only the template, styles and decorative icon imports change. The script
logic (login flow, validation, redirects) is identical to dev.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Zarrin v228 was tested on the real install (test account 953): negative
balance = owes, and temporary documents move the balance at once. Update
the admin copy and provider comments to match what was verified.
پنل: متن راهنمای زرین بهروز شد: علامت مانده روی نصب واقعی بررسی شده است، و هشدار مربوط به ثبت سند موقت در «ثبت سفارش» اضافه شد.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Side branches (fix/*, integrate/*, release/*, ui/admin-responsive, migration/legacy-data)
are fully contained in dev and main. dev and main are the only long-lived branches now.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Rebuilt dist/ and api release bundle, with the migration for catalog paging indexes.
Fix Less build error: fade(@token, N%) on theme tokens failed in both user-app
and user-app-v2 CustomerChoiceView; use alpha(), the project's token-aware helper.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Tested 20/20 on the owner's live Zarrin v228 (870 accounts, 656 items):
Reads fixed:
- listKarSakhte: now pages by 100 via pageNum (was returning only first 100 of 656)
- goroohSakhte: HTTP 400 with a valid body is now accepted (was treated as missing)
- parvande «مانده از قبل» row is not a balance; skip it and anchor the running
balance on the live listMandeHesabha balance, walked back through movements
- infogardeshsakhte: hand-printed JSON repaired, timeout raised to 90s
- document delete: now searches all ledger pages, not just the first one
- listhesabha names are «first * last»; the «*» is now stripped
- keep-alive ECONNRESET: a socket hang-up on a reused connection is retried once
on a fresh connection (server closes idle connections)
Writes fixed (test account 953 «تست اپ گلدبانک»):
- temporary flag: set from shomareSanad («موقت»), not noeFactor
- noeFactor «فاکتور موقت» also flags a document left open in Zarrin's own UI
New: test script apps/api/scripts/test-accounting-zarrin.ts (13 read + 7 write checks)
Docs: §13 added to ACCOUNTING-INTEGRATION-PLAN.md; ACC-01 in ISSUES.md updated
New: docs/FRESH-SERVER-DEPLOY-AUDIT.md (fresh-server deploy checklist)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted
routes; these are the five callers that were fetching all ~16k products
to find a handful of rows:
product screen whole catalogue, then find(id) -> GET /products/:id
cart re-sync whole catalogue for 2-3 lines -> ?ids=
favourites whole catalogue, filtered here -> ?ids=
home categories categories derived from it -> /products/facets
order row labels joined on variantId -> included with the order
Three of these were not merely slow, they were going to be wrong.
Favourites filtered the loaded catalogue, so once the list pages a
favourite outside the loaded slice silently disappears. Home categories
were derived the same way, so a category whose products all sit further
down would vanish from the home screen. Order rows looked their title up
in the catalogue, which ALREADY failed for a product that had since left
the storefront — exactly the old orders history exists for — and
rendered a blank row.
Order labels went to the server rather than to another id lookup: the
item carries productVariantId, not a product id, so ?ids= could not
answer it. One nested include in getOrders returns the variant, its
product and the product's first image. Deliberately NOT filtered by
storefront visibility, so a de-listed product still shows what was
bought; asserted in the new spec. OrderItemCard is synchronous now — no
fetch, no onMounted, the row renders complete on its first frame.
variantLookup is deleted from catalogStore rather than left behind: it
existed only to label order rows, and keeping it would have kept a
reason to load the full catalogue.
getProductsByIds chunks at 100 because the route caps ids there and
drops the rest. A partial answer reads to callers as "these products are
no longer on the storefront" — the cart would silently drop valid lines.
Favourites already run to 874 rows in the imported data (FAV-01).
A failed cart re-sync now leaves the cart alone: it is the person's own
data in localStorage, and dropping lines because the network blinked is
worse than a slightly stale price. The product screen tells a real 404
from a failed request, so only the former renders the not-found page.
One bug introduced and caught while doing this: moving the home
categories to /facets meant memory.init() no longer loaded the catalogue
as a side effect, and the home rails read catalog.products — the home
screen would have rendered empty rails. HomeView now loads it
explicitly. Found by grepping every caller of configStore.init, which is
also how I know HomeView is the only one.
Still on the unpaged list by design: the home rails and the
search/newest lists. Moving those onto paged reads is step 4, kept
separate because it is the risky part — debounce, loading state, and
telling "no results" from "still loading".
api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a
spec now (3 cases) because its include is a contract both apps depend on
and the method had no test at all. Nothing click-tested — the cart
re-sync and the favourites screen are the two worth a manual pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The real catalogue reaches ~16k products and GET /catalog/products had no
paging parameters at all: it returned every active product with its
variants, images, category, brand and collections in one response. The
apps' PAGE_SIZE = 20 was only a render window over that full array, so
scrolling made zero requests. The reference paged this server-side; the
port had moved it to the client (STUB-03), so this is a return to
reference behaviour.
Step 1 — indexes, which were not optional. Product carried NO index
whatsoever (not isActive, categoryId, brandId or createdAt) and
ProductVariant had none on productId, so every per-product include
scanned its whole table. Adding take/skip on top of that would have
paged the response without making the query faster, which is the exact
failure this change exists to avoid. Product_isActive_createdAt_idx is
composite so one index serves the filter, the default ordering and the
OFFSET walk.
CREATE INDEX, not CONCURRENTLY: the deploy image's migration runner
wraps each file in a transaction, which CONCURRENTLY cannot join (D15).
Every statement is IF NOT EXISTS because both migration paths run it.
Step 2 — the routes. Paging is opt-in, which is the whole
backward-compatibility story: with no query parameters the route returns
the same plain array from the same cache as before, so the admin panel,
the smoke scripts and installed app builds are untouched. Pass page,
limit or any filter and it becomes { rows, total, page, limit }.
The filters had to move too, not just the paging. The apps filtered
title, category, brand and gram range in the browser over the full
array; paging the response while leaving that in place would have made
search quietly WRONG — searching only the first page and reporting
"محصولی پیدا نشد" for products that exist. So every filter is built into
the Prisma query, and count uses the same where clause, so `total`
describes the set the app is actually scrolling.
Three routes added so the apps can stop fetching everything to find one
thing (wired up in step 3): ?ids=a,b,c for the cart, favourites and
order items; /:id for the product screen, which today downloads the
whole catalogue to find one row; /facets for the filter sheet's options,
which the apps currently derive by scanning the catalogue in the
browser.
Deliberately not cached: CatalogCacheService exists because the unpaged
query is expensive and every screen wanted it. A paged, filtered query
is neither — bounded by limit and index-served — and caching it would
need a key per filter combination, i.e. unbounded memory for cheap
queries plus a second place for stale rows to hide.
D27's two storefront rules (active, has an image) live in one shared
where clause, so the list, its count, the single-product route, by-ids
and the facets can never disagree about what is visible. limit and ids
are both capped at 100; an uncapped limit is just the old "give me
everything" query spelled differently.
17 new cases, 329/329 overall. All 17 passed on the first run, which is
not evidence by itself, so one filter was deliberately broken to confirm
the suite catches it (1 failed/16 passed) and then restored.
Not measured on real data — the database container was not running, so
16k-row behaviour is reasoned from the query plan, not timed. Worth one
EXPLAIN ANALYZE on a full dataset before step 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: from the profile page, going to login and pressing back landed
on home instead of the profile — with the instruction to audit the whole
structure rather than that one case. Seven defects, four reachable in
ordinary use.
NAVIGATION
PageBar's back arrow pushed a FIXED route name (`backTo`) and ignored
history, so it could not know where the person came from. That was true
of 14 of the 15 PageBar uses in each app — only SearchView used
router.back() — so it was never one screen's bug. Inherited from the
reference, which pushed a fixed name in every component's back arrow;
not replicated (D4).
`backTo` is now a fallback, used only when there is nothing to go back
to (a shared link, a refresh). All 14 declarations are kept untouched,
because that is exactly when they are right.
Answering "is there anywhere to go back to?" needs care: history.length
counts entries from before the app was opened, so a link opened in a
fresh tab would look navigable and back would walk the person out of the
app. navStore records the history `position` of the app's first
navigation and compares the current one against it — direction-proof,
unlike a counter, which only grows and would be wrong after one
back-and-forward. Confirmed vue-router writes `position` on every entry
rather than assuming it.
Three pushes became replaces, each of which left behind a page the
guards immediately bounce, making "back" a no-op or a trip home:
- after sign-in the login screen stayed in history — the worst, since
the same file already honoured ?redirect=, so signing in and
pressing back disagreed about where the person belonged;
- the 401 handler kept the page whose request had just failed;
- complete-profile kept a form that is unreachable once the documents
are in.
CartView's post-order push is left alone: back lands on the emptied
cart, which is a real screen, not a dead end.
READ FRESHNESS
Owner's rule: fetch what a screen needs when it is opened, rather than
caching with a lifetime. Better than the TTL I had proposed — a TTL
guesses how long data stays true, while entering the screen is when it
actually matters.
OrdersView was the worst: it is in the keep-alive list but only loaded
in onMounted, so the second and every later visit made no request at
all, and the first read the cache. Order status changes on the admin
side and nothing here invalidates it, so the list could be stale for a
whole session. Ledger entries had the same shape and a stronger reason —
they are written by the accounting side, which this app can never learn
about. The gold price was forced only by pull-to-refresh on the two
screens that show it most prominently.
The catalogue is deliberately NOT re-read, by the owner's own reasoning
that this must not add server load: it is the entire product list in one
response, it changes when an admin edits it, and pull-to-refresh and
placing an order already invalidate it.
The cache layer itself is sound — concurrent callers share one in-flight
promise, and a response arriving after an invalidate is returned but not
stored, so a write cannot lose a race against a read in flight. Its one
real gap was that nothing ever went stale. The policy is now written at
the top of queryCache.ts so a TTL is not re-introduced.
Both apps vue-tsc clean. Not click-tested in a browser; the back-button
behaviour is worth one manual pass.
اپ: دکمه بازگشت به صفحه قبلی واقعی برمیگردد و اطلاعات هر صفحه در هر بازدید بهروز میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported by the owner: a number that is not in the system produced no
error at all. The app advanced to the code box and then nothing
happened, and there was no way to tell apart "the SMS is late", "the
gateway is down" and "this number was never registered" — the only one
of the three the person can actually do something about.
Step 1 now says which it is:
- unregistered number -> 404 «این شماره در سیستم ثبت نشده است…»
- SMS send failed -> 503 «ارسال پیام کوتاه … ممکن نیست…»
- sales expert, account unusable -> 403, its own message
and both apps surface the server's Persian message for a 404 exactly as
they already did for a 429. Without that second half the person would
still have seen the generic «خطا در ارسال کد», which is the confusion
being removed.
This restores the reference's behaviour (firstOrFail() in
ApiMobileController::userAuth:43 returns 404); the opaque answer was our
deviation, not this. The anti-enumeration protection it was there for is
deliberately reduced at step 1 on the owner's instruction — the cost was
being paid by real users and by support. Still standing: the per-mobile
and per-IP send throttles, which the reference never had, so enumeration
is rate-bounded rather than free, and verifyOtp keeps one message for
every failure reason. ISSUES.md SEC-03's "no enumeration" claim is
corrected rather than left to go stale.
Two more instances of the same shape turned up while fixing it:
A failed SMS send also returned success, because SmsService never throws
(D20), so a dead gateway produced the identical dead end. D20 exists so
a failed SMS cannot break an operation — an order must still be placed —
but in a login the SMS IS the operation, so it now fails loudly. The
local-dev path keys off SmsService's own `sms-not-configured` result
rather than re-testing the config: with D26's default relay driver,
smsConfigured() is true on any machine with an external IPv4, so a
config test would have called a dev box configured and then failed its
login. The new test caught precisely that on its first run.
Three states return no trading account for a sales expert who does
exist. Under the new 404 each would have claimed the number was not
registered — false, and it would send support hunting a registration
problem that is not there.
Also corrects both apps' LoginView header comment, which still described
this flow as stubbed: no SMS provider, any 4-digit code accepted, step 1
matching the customer list in the client (that was the login leak, D8).
All three had been untrue for weeks and the stale text misled a reader
during this session.
requestOtp had NO test coverage: the 28-test customer-auth suite stayed
green through a change to its response for every unregistered number, so
its greenness was not treated as evidence. request-otp.spec.ts adds 9
cases, including that the dev fallback does not apply in production.
api: 295/295 tests, 27 suites, typecheck clean; both apps vue-tsc clean.
اپ: اگر شماره شما در سیستم ثبت نشده باشد، همان را میگوید و دیگر بیدلیل به صفحه کد نمیرود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A guest tapping a personal page — orders, transactions, profile,
favourites, complete-profile — was redirected in silence, so they landed
on the login box with nothing explaining what happened. Legacy showed
«ابتدا وارد شوید» (henza_pwa/src/router/index.ts:259).
The message is now shown in both apps. The destination deliberately
stays the target's /login?redirect=…, which legacy did not have: legacy
sent the guest to home and lost where they were going, while here
signing in returns them to the page they asked for. The owner chose that
trade-off, so it is logged as «عمداً تکرار نشد — D3/الف» rather than
quietly left as a divergence. The guard also keys off meta.requiresAuth
instead of legacy's hand-kept list of five route names, so the two
sales-expert pages are covered too.
Legacy's isLoaded router guard is NOT ported, and the reasoning is
written into both apps' configStore beside the line that says so: it
guarded legacy's one big bootstrap request, which this target does not
have, so it would guard nothing and only add a bounce — a shared product
link would open home instead of the product. That is porting the
breakage, not the behaviour (D4).
Route analytics is deferred at the owner's request; its design is parked
in docs/PHASE2-D3-PLAN.md so it need not be re-derived, along with the
two reference defects not to copy (log/create was unauthenticated and
accepted any title; log/update was a GET that wrote) and the open
retention and IP questions.
Seven of D3's ten items turned out to be built already — all five
screens in both apps, the sts_verify_step_two guard, v2's cart ceiling —
verified in the code rather than from the checkmarks.
Neither app has a test runner: verified with vue-tsc -b on both (clean)
plus confirming the toast can actually appear, not merely compile —
toast.info exists and <ToastHost /> is mounted in each App.vue.
اپ: وقتی بدون ورود صفحه شخصی را باز کنید، پیام «ابتدا وارد شوید» نشان داده میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 2 of the owner's MON-08 ruling. An app order is priced by the server
(steps 1, 3, 4 landed with the wage-formula work), but the client's own
wagePercent, discountPercent and flatRialCharge were being thrown away in
silence — so a stale app build, a promotion that expired between the
product page and checkout, and a deliberately tampered request were
indistinguishable and left no trace anywhere.
They are now logged with both numbers, the same pattern already used for
the body's customerId. The pricing itself is untouched: the server's
figures are what the order is charged, and an operator ordering from the
panel still keeps the numbers they typed.
Also adds the comparison test the ruling asked for, which step 3 never
actually had — the suite only proved a tampered request gets overridden.
The new test places one honest order as a customer and the same order as
an operator and asserts the totals are identical, which is the real
claim: an ordinary customer's total does not move. Two more cover the
logging, including that an honest order logs nothing — so the quiet case
cannot pass merely because the method never ran.
Closes STUB-05 in the docs as well: the reference's six hardcoded staff
numbers have been behind PROFILE_ADMIN_MOBILES / ORDER_ADMIN_MOBILES for
a while, but its status still read "pending".
api: 286/286 tests, 26 suites, typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference texted three audiences on every placed order
(henza_server Api/ApiMobileController::setOrder:733-766). None of it was
wired here — OrdersService had no SmsService at all, so a customer could
place an order and nobody was told: not the customer, not the admins, not
the referring sales expert.
Now, after the transaction commits (so a rolled-back order texts nobody)
and without ever being awaited (D20 — a dead gateway must not fail an
order):
- every mobile in ORDER_ADMIN_MOBILES, with the customer's name and
the rounded gold weight;
- the customer, with distinct text for a normal and an over-limit
suspended order;
- the referring sales expert, looked up on AdminUser (the target's
referring expert is an admin, not a customer as in the reference).
The reference's four hardcoded personal numbers stay out of git, behind
the same env-var pattern as PROFILE_ADMIN_MOBILES.
The «در سیستم ویرایش شد» variant was deliberately not ported: the target
has no order-edit route, so it would be dead code — «عمداً تکرار نشد».
Also fills §2 of the admin matrix, the D1 deliverable that had been
(pending) since the phase opened: 163 rid-guarded routes, 68 distinct
rids, 176 grantable tree keys, and a per-module map to the reference
controllers. Only two enforced rids are ungrantable from the permission
screen (newsletters, persons.list.sellers.view) — filed as PERM-02,
awaiting an owner ruling because it changes the permission vocabulary.
Phase 1 (admin parity) is at its gate. api: 283/283 tests, 26 suites.
پنل: پیامک ثبت سفارش برای مشتری، ادمینها و کارشناس فروش فعال شد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebuilds apps/admin-panel/dist (first build with the montzar rename), apps/user-app/dist and apps/user-app-v2/dist from 8faf4a7. The api release was rebuilt in 8faf4a7.
Co-authored-by: Cursor <cursoragent@cursor.com>
The SMS texts were ported verbatim from henza and still carried its brand:
the login code, the welcome message (with the app.henzagold.com link), the
two complete-profile messages and the financial-document confirm/reject.
All now say منتزر, and the welcome link is https://bnk.montzar.ir.
The login code keeps its first line ("<#> code is <CODE>") and the trailing
SMS Retriever hash unchanged, so Android autofill still works. «اپلیکشن» is
corrected to «اپلیکیشن» in the same line.
Tests: sms.service.spec OTP template. All 263 API tests pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
Rebuilds apps/api/release, apps/user-app/dist and apps/user-app-v2/dist
from b3dae57 (login code without the ~15s wait, no wage-quote loop).
The admin panel is unchanged and not rebuilt.
Co-authored-by: Cursor <cursoragent@cursor.com>
SMS: with SMS_RELAY_HOST unset inside Docker, every send first looked up the
server's public IP from ipify / ifconfig.me / icanhazip, one after another
with a 5s timeout each. They are unreachable from the server, and a failed
lookup was not remembered, so EVERY SMS, the login code included, waited
~15s. The three are now tried in parallel (3s), concurrent sends share one
lookup, and a failure is not retried for 10 minutes.
Apps: the server answers /customer/me/wage-quote only for variants that
exist. The store re-asked for every unanswered id from a computed, and each
answer replaced `quotes`, re-running every card, so one missing variant kept
the app posting wage-quote in a loop. Unanswered ids are now remembered, an
empty answer does not touch `quotes`, and a failed call waits 30s.
Tests: relay-host.spec (failed lookup not repeated, one shared lookup).
اپ: کد ورود بدون معطلی ارسال میشود.
اپ: صفحهها بعد از ورود دیگر چند بار پشت سر هم بارگذاری نمیشوند.
Co-authored-by: Cursor <cursoragent@cursor.com>
The relay gateway now prints timing lines before its result, e.g.
"1. Function Start at: ...<br>...572381951{"data":"sent"}". The message
was delivered, but the whole body is not JSON, so it was logged as a failed
SMS. When the body does not parse, the trailing JSON object is read instead.
A body with no JSON at all (the gateway's bare PHP errors) still fails.
Tests: sms.service.spec (relayRejection). 33 SMS tests pass.
پیامکِ ارسالشده دیگر به اشتباه در گزارش سرور «ناموفق» ثبت نمیشود.
Co-authored-by: Cursor <cursoragent@cursor.com>
With platform "kave" (Kavenegar) the gateway answered "cURL library is not
loaded" and nothing was delivered. The body is now { phone, host, text } and
the gateway picks the line itself. SMS_RELAY_PLATFORM is removed from the
driver, docker-compose and the env examples.
Tests: sms.service.spec updated. 150 tests pass.
پیامک بدون انتخاب خط کاوهنگار فرستاده میشود و سرویس پیامک خودش خط را انتخاب میکند.
Co-authored-by: Cursor <cursoragent@cursor.com>
A production API with incomplete config threw before listening, so the
container restarted forever and every /api request through nginx was a 502.
The problems are now printed as one loud warning and each gets a safe
fallback:
- JWT_SECRET / JWT_CUSTOMER_SECRET missing, weak or equal: a random key is
generated once and kept in the Setting table (boot-secrets.ts), so tokens
survive restarts. A strong value in the environment always wins.
JwtModule now uses registerAsync so it reads the key after boot fills it.
- OTP_MASTER_CODE: ignored in production unless
TEST_SERVER_ALLOW_MASTER_CODE=yes.
- CORS_ORIGINS: invalid entries dropped; empty allows every origin.
Tests: boot-secrets.spec, production-checks.spec. 118 tests pass.
سرور با تنظیمات ناقص دیگر مدام ریست نمیشود و ورود دوباره کار میکند.
Co-authored-by: Cursor <cursoragent@cursor.com>
Messages went out locally but not from the server. The relay gateway
identifies the sending server by `host`, and the owner's ruling (D26)
is that this is the server's IP. With SMS_RELAY_HOST unset, the driver
sent the machine's first external IPv4. On the bare PHP server that was
the real IP; inside the API container it is the container's (172.x).
Reproduced: a container on this machine reports 172.17.0.3.
- relay.driver: when SMS_RELAY_HOST is unset and the machine address is
private (RFC 1918, CGNAT, link-local), the public IPv4 is looked up
once (ipify, ifconfig.me, icanhazip in turn) and cached. If every
lookup fails, it falls back to the old value. SMS_RELAY_HOST still wins.
- SmsService logs the host it will send at boot, and warns when it is
still private, so a wrong value is visible instead of silent.
- Removed a debug console.log that wrote every recipient's mobile to the
server log.
- .env.example and SERVER-SETUP-CHECKLIST: SMS_RELAY_HOST = this
server's public IP.
Tests: relay-host.spec (5). All 254 API tests pass.
اپ: پیامک کد ورود دوباره بهدرستی ارسال میشود.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The build number is the commit count, so every build raises it with no
file to bump. scripts/app-version.mjs bakes it into each front-end
(__APP_BUILD__) and writes dist/version.json with that app's notes.
- Notes come from commit messages: only Persian lines that start with
one of the four app tags are shown, each to its own app
(docs/RELEASE-NOTES.md).
- Both customer apps and the admin panel poll version.json (every 5
minutes and on return to the tab, bypassing caches). A newer build opens
a modal that cannot be dismissed. «بهروزرسانی» unregisters the service
worker, empties Cache Storage and reloads; localStorage (sign-in, cart,
settings) is kept.
- Replaces legacy's version dialog, which compared against a
package.json version that was never bumped and so never opened, and
useRefresh, which swapped in a new service worker silently.
- nginx (all three apps and nginx-spa.conf): index.html, sw.js and
version.json are never cached. sw.js used to fall under the 6-month rule,
which could pin a browser to an old release.
Checked: served with the real nginx config, a newer version.json opens
the modal with its notes; the same build does not.
اپ: اجرت هر کالا حالا همان است که در فاکتور حساب میشود؛ درصد خود شما و تخفیفها هم در آن آمده است.
اپ: علاقهمندیهای شما ذخیره میشوند و در هر دستگاهی که وارد شوید دیده میشوند.
اپ: از این به بعد هر نسخهی تازه با فهرست تغییراتش اعلام میشود.
پنل: بخش تازهی «اجرت»: فرمول اجرت را با یک نمونه امتحان و بعد تأیید کنید.
پنل: شهرها مثل قبل بهصورت درختاند (کشور، استان، شهر، محله)؛ شهری که درصد ندارد درصد شهر بالاتر را میگیرد.
پنل: «تاریخ تولد» به فرم مشتری عمده و تکی برگشت.
پنل: در گزارش تجمیعی سفارشها، دکمهی «دریافت فایل اکسل» اضافه شد.
پنل: رد احراز هویت مشتری حالا دلیل هم میگیرد.
پنل: با تأیید یا رد سند مالی، به مشتری پیامک میرود؛ در پیامک گروهی، %name با نام مشتری عوض میشود.
پنل: از این به بعد هر نسخهی تازهی پنل با فهرست تغییراتش اعلام میشود.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fresh release output after merging dev: apps/api/release (server
bundle, migrations and the database client) and the three front-end
dist folders. All three front-ends type-check clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings main up to date with dev: the issues triage (favourites, ledger
debt, making-charge formula and «اجرت» screen, order ban, cities tree,
permissions, birth date, unused-code removal, production safety checks)
and release/delivery's IAM single sign-on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.
Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
release bundle): rebuilt from the merged source instead of picking a
side.
Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
the identity-service stand-ins in three test files lacked it. 15 tests
failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
never got `platform` (documented body: phone, host, text, platform).
relayConfig() now returns it ("default" → null) and the request carries
it. sms.service.spec now passes.
All 249 API tests pass; the three front-ends type-check and build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NODE_ENV=production (always, in the Docker image) the API lists
every problem at once and does not start while any of these holds:
- OTP_MASTER_CODE is set: the fixed test login signs in any customer. A
test server built from the production image must say so with
TEST_SERVER_ALLOW_MASTER_CODE=yes.
- JWT_CUSTOMER_SECRET is missing, equal to JWT_SECRET, or under 32
characters.
- JWT_SECRET is missing, the published dev key, or under 32 characters.
- CORS_ORIGINS is empty, `*`, or not full origins. It no longer falls
back to allow-all.
Checked live: a misconfigured start lists all four problems; a correct
one starts, answers the allowed origin, and blocks any other.
docs/SERVER-SETUP-CHECKLIST.md is the operator's list. DELIVERY.md,
docker-compose.yml and .env.example are updated to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PERS-03: «تاریخ تولد» (Jalali DateInput) on the wholesale and retail
forms, as legacy. The server accepts birth_date or birth_day, refuses
impossible or future dates, clears on '', and returns both names.
- SEC-07: setRoles stores only rids in ALL_RIDS or their tree parents.
Anything else is dropped with a warning log, so a tick on a legacy-only
tree leaf never fails the save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner approved the deletions. Found by walking imports from each app's
main.ts; sw.ts stays (the PWA plugin's entry).
- user-app: 8 unreachable components, loadingStore.
- user-app-v2: 11 unreachable components, loadingStore.
- both: the unused validators and price helpers, and the gsap and
@ant-design/icons-vue packages.
- the stray root user-app-v2/ folder (an old package.json and
vite.config.ts that nothing referenced).
Both apps type-check clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finished from a parallel session that expired (owner: «بساز»).
- RPT-01: GET admin/report/order-items — legacy report/order_item's
32-column Excel (order items, one row per unit, plus financial
documents), RTL, Jalali dates. The «گزارش تجمیعی» screen gets
«دریافت فایل اکسل». Review fixes:
- weight: computedWeightGrams is already one unit's weight; legacy
divided it by qty again, under-reporting «بدهکار» on multi-unit
lines. Not repeated.
- «درصد»: the final percent (wage − discount), as legacy.
- dates: the end date covers its whole day; a bad date is a 400.
- SMS-01: confirming or rejecting a financial document texts the customer,
as legacy CashController::toggleConfiremCash.
- SMS-02: bulk SMS substitutes %name per recipient and is actually sent
through the configured gateway (D26 relay), no longer only logged.
- Docs: 01-ADMIN-MATRIX and D16-BC-GAP-SUMMARY.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cities (owner: as legacy)
- City.parentId (self relation) replaces the flat `province` label; the
migration turns each label into a parent row («استان …») first, so nothing
is lost.
- Admin pickers are legacy's tree (CityTreeSelect): the top level, the
country, is a heading. The city form and grid get «والد».
- Restored legacy _getP: a city without its own percent takes its
ancestor's. Restored the omde-customer form's city → percent autofill.
- Import copies legacy parents; delete refuses a city with children; a
parent loop is refused.
Permissions (owner approved the deletion)
- dashboard / dashboard.omde / dashboard.tak rows become dashboard.view,
then are deleted.
- Import maps them the same way: legacy types 102, 104, 105, 108 and 135
had lost the dashboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The formula shown as a sentence with coloured terms; each term can be
switched off or given a coefficient; promotion as largest or sum; a
constant, floor, cap and rounding. A sample order line is computed by
the server for the formula in force and the draft side by side, changes
highlighted. Saving needs a change, a confirmed sample and a final
confirm; the approved sample is kept and shown as the formula's
reference, with earlier versions below.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy: final % = variant % + customer % − the largest active promotion
on the product, its category or its brand; rial charge 0. The apps sent
the variant's own % only, so the new app undercharged by the customer's
percent (usually 3) and ignored promotions, and the server trusted
whatever the app sent.
- pricing/wage-formula.ts: one formula, legacy by default, with
coefficients, promotion pick, constant, floor/cap and rounding so the
admin screen can change it; validated
- an app order is priced by it; the app's wage, discount and rial charge
are ignored; an operator's manual numbers are kept
- POST customer/me/wage-quote; both apps show that figure
- admin/pricing/wage-formula (config.wage): read, preview, approve with
the sample it was checked on, last 20 versions kept; the setting is
kept off the generic const routes
Owner approval: 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy stored customers.sts but no code read it, so the switch never
stopped an order, and 313 active customers carry it, mostly as the
sign-up default. Owner option 2: enforce it from now on, for orders
placed from the app only (an operator can still order for the
customer), and import every customer as allowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- production refuses to boot without JWT_SECRET instead of signing
admin tokens with the published development key
- CORS_ORIGINS restricts browser callers; blank keeps allow-all with a
production warning so a running deployment is not cut off
- the boot-time seed creates only roles and permissions in production;
the demo customers, orders, debt and known-password accounts need
SEED_DEMO_DATA=true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy never posted order debt; updateRemain recomputed it as
SUM(orders.input) WHERE sts > 4. The port posted it once on entering
DELIVERED_TO_AGENT, guarded on deliveredAt, which:
- posted it twice when an order reached that status again
- posted nothing for an order sent straight to DELIVERED_TO_CUSTOMER
- kept it after a rollback or cancellation
syncOrderDebt compares what the order should owe with the confirmed
ORDER_STOCK entries on it and posts only the difference (append-only,
idempotent). Status removal runs it too, so removing «تحویل به نماینده»
is allowed again, as in legacy. Existing data: 0 orders out of line.
Owner approval: 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both apps kept favourites in memory only, and the API had no route, so
the 874 favourites imported from legacy major_favorites never showed.
- GET customer/me/favorites, POST customer/me/favorites/toggle
- apps load them on sign-in, clear on sign-out, toggle optimistically
and roll back on failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MON-01/V2-01, V2-02/APP-02 are fixed in code; MON-03 has no effect
(Prisma.Decimal(4.331802) is exactly 4.331802); PERS-03 has its column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API sends ISO timestamps where legacy sent formatted Jalali strings,
so every grid printed "2026-07-25T12:32:29.000Z". Date columns now render
1405/5/3-16:02. The order-status column is a green/red «فعال/عدم سفارش»
tag again (Customer::$STS), not bare «مجاز».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy fixed `orders.reagent_id` once, when the order was placed, and its
«میزان فروش» summed `customer_id = X OR reagent_id = X`. The app credited
orders to the customer's CURRENT referrer and left out an expert's own
orders, so 9 of 49 imported experts showed a different figure.
- Order.salesAgentId (+ referrerCustomerId for 17 legacy orders whose
referrer was not an expert); additive migration, recent app orders
backfilled from the customer's referrer
- createOrder records the expert at placement time
- one shared filter (agent-orders.ts) for the experts list, expert
detail, dashboard and the expert app; child orders follow salesAgentId
- customer page gets its «میزان فروش» back
- import carries orders.reagent_id as is; ex-experts kept as admins keep
their profile so their own orders stay linked
- reconcile-sales: 1,557 of 1,558 match legacy's own formula; balances
unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DATA-MIGRATION-MAP.md:
- §10.7 is rewritten from the full dump loaded into MySQL. The legacy
debt formula is confirmed (1,590/1,591 balances). The earlier claims
that new_city is a cities5 id, that birthdays are clean and that
types 3/4/5 are unknown were wrong and are corrected in a table.
Favourites come from major_favorites, not fav. The role-number clash
(legacy 86 «حسابدار» vs target 86 «کارشناس فروش») is recorded.
- §10.8 adds the tool's usage, safety rules, test result, and what it
deliberately does not migrate.
- §11 and §5.5 are brought up to date. The rejected count is 7, not 14.
migration-precheck-3.sql: two bugs that would have failed on the server
are fixed: the reserved word `both` and a collation clash between
cities.title and customers.new_city.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apps/api/scripts/legacy-import/ reads the legacy MySQL (read only) and
merges into the target Postgres. Every rule is from
docs/DATA-MIGRATION-MAP.md, and each step cites its section. It covers
cities, roles and permissions, branches, admin users, sales experts,
workshops, customers, brands, categories, products, variants, product
images, orders, the ledger, favourites, custom orders and a
reconciliation report.
Key rules:
- Debt is rebuilt from legacy's own formula (updateRemain): delivered
orders minus confirmed type 1-4 documents. Type 9 «سفارش» documents
mirror orders and are skipped, or every order's debt would count twice.
A balance the records cannot explain is carried as one ADJUSTMENT entry.
In the 2026-10-04 dump that is only customer #125 (owner ruling).
- Merge, not import: rows already in the target are matched by mobile or
product code, and only their empty fields are filled. Never written:
accounting* columns, ProductVariant.imageId, Accounting* tables. No
ProductImage is ever deleted.
- Roles map by title, not number. Legacy 86 is «حسابدار» but target 86
is «کارشناس فروش». An expert sharing a mobile with an admin never
demotes the admin.
- Soft-deleted variants and products that live order lines still point
at (473 of 610 lines) come over inactive, so order history stays whole.
- Owner rulings 2026-10-07: birthdays filtered (541 of 915), typed city
kept plus exact-match cityId, the 7 legacy rejections get the sentinel
reason, OTP-only accounts get OTP_ONLY_PASSWORD_HASH.
- Tehran wall-clock timestamps are converted with ICU rules, including
Iran's pre-2022 DST. Jalali receipt dates are converted with the
jalaali-js arithmetic (truncating division; verified on 8 known dates).
Safety: --target is required and never read from .env. Nothing is
written without --yes, and a target not named *import*/*staging* also
needs --confirm-db. The id map lives in its own schema, legacy_import,
so a re-run updates instead of duplicating. Writes bypass the API
services, so no SMS is sent and nothing is queued to accounting.
Tested against goldbank_import_test, a clone of the dev DB, in three
runs:
- 1,503/1,503 created customers match legacy gold and rial balances
(±0.0001 g / ±1 rial)
- accounting data is byte-identical before and after
- 0 imported variants have an imageId
- runs 2 and 3 created zero duplicate rows
- 6,196 image files copied, 3 missing in the legacy storage
New devDependency: mysql2 ^3.24.5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compareLocalHash, OTP_ONLY_PASSWORD_HASH and a new isPasswordHash now
live in password-hash.ts, which depends on bcryptjs only. The data-import
script needs them, and importing auth.service would load NestJS and the
ESM-only @nestjs/jwt.
auth.service re-exports the first two, so callers and tests are
unchanged. Its own bcrypt import, now unused, is dropped. No behaviour
change: 21/21 auth tests, 178/178 overall.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Additive and nullable. Owner ruling 2026-10-07: keep the typed text
verbatim, and set cityId as well only on an exact match.
Legacy customers.new_city is free text: all 172 live values are names,
none are ids. The legacy city table has only 15 rows and 43 of the names
match no city, so dropping the text would lose where those customers are.
Legacy sign-up also forced city_id = 5 (Tehran) on everyone
(Major/CustomerController.php:21). 91 of the 92 customers with both
values carry that forced Tehran, so the typed name is the truth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oapi.sql is the owner's full data dump of the legacy database: customers'
names, mobiles and addresses. It was untracked but not ignored, so a single
`git add .` would have committed it and a push would have sent it to the
remote. Only the structure-only oapi-struncture.sql belongs in the repo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DATA-MIGRATION-MAP.md now records:
- the precheck numbers from the live henza DB and the assumptions they
overturned: verify steps are 1/2/3, not boolean; reagent_id points at
customers, not users; cashes.remain/rial_remain are NULL on every row
- owner rulings: mobile as the match key, sales expert wins conflicts,
merge rather than import, allow-list columns only, retail
orders_orders deferred, fav and birth_date kept, token_mobile and
marriage_date dropped
- run status of the prep migration and the REJECTED consumer audit
- a process hazard: on the dev machine, a file in prisma/migrations is
applied by the watch-mode server on the next src/ edit
- two open owner questions about the live verify() flow
migration-precheck-3.sql: read-only (COUNT only). Checks where new_city
should land and whether birth_date holds Jalali years.
PROGRESS.md: workstream row pointing here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compareLocalHash() returns false for any passwordHash that does not
start with `$2`: '' (accounts created without a password) and the new
OTP_ONLY_PASSWORD_HASH sentinel the data import writes for ~1,590
OTP-only legacy customers. It is used on both local-compare paths of
verifyPassword(), IAM off and IAM fallback.
bcryptjs 3 already returns false for these. The guard keeps the result
from depending on that: a future version or a native binding that
throws would turn a wrong password into a 500 (owner ruling 2026-10-07).
Behaviour-neutral for existing data. Real hashes start with $2a$, $2b$
or PHP's $2y$, and every row that fails the prefix already failed in
bcrypt. Verified live: wrong password 401, correct password 200.
Tests: refusal for both values with IAM off and on, bcrypt never
called for them, and legacy $2y$ hashes still verify. 21/21.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verifyStepOne() sent REJECTED to `default`, which returns 2
("accepted"). The admin list unlocks the order/account switches when
step one is 2, so a rejected customer would have had them unlocked.
REJECTED now maps to 3, matching legacy, where a step-one reject also
set active = -1.
Both switches are now exhaustive with no `default`, so a future enum
value fails the build instead of falling through silently.
Both user apps' ProfileView label it «رد شده». Before this they showed
the raw English value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Purely additive; nothing dropped, renamed or retyped. Owner rulings of
2026-10-06/07, recorded in docs/DATA-MIGRATION-MAP.md.
- Customer.nationalCode is now optional. All 1,595 live legacy customers
have none, and fabricating one is a data violation. Still @unique:
NULLs do not collide in Postgres, so the import must write NULL, not ''.
- Customer.birthDate added, empty. Must not be backfilled until the
Jalali-vs-Gregorian check (precheck-3) is in.
- VerificationStep.REJECTED plus verificationRejectionReason/RejectedAt.
Legacy has a third "rejected" state that the enum could not represent.
- CustomerFavorite for legacy `fav` (251 rows), which had no home.
accounting-sync customerPayload() now takes `nationalCode: string | null`.
validNationalCode() already handled null, so Tahesab/Zarrin receive
exactly what they did before. Caught by tsc after the column change.
Not touched: accounting* columns, ProductVariant.imageId, the
Accounting* tables, SMS (PROTECTED.md §B, §G).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production build of both customer apps for f441ca8 (appearance settings,
dark mode, reduced motion, product morph, filter sheet). The API,
admin-panel and shared-types builds are unchanged: none of their sources
changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.
Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
dashboard and available without login. It offers a light/dark/system
theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
script in index.html applies it before first paint so there is no
white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.
Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
values come from a generated virtual:palette.css (themeConfig/
appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
.van-theme-dark.
- Light mode looks the same as before.
Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
setting.
Product detail
- Opening a product is now a container-transform morph from the tapped
card (ProductMorph). It replaces the bottom Drawer and uses composited
transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
opens blank and jumps.
Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
until applied, so the result list no longer re-renders while
dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).
Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
filters never cleared the selected brands.
Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. "0. Quick start — run these in order" at the top of DELIVERY.md: five steps
from an empty machine to a verified running stack, copy-pasteable, with no
decisions to make. Covers which env vars must have a value (and how to
generate the two JWT secrets), `pnpm build:release` BEFORE
`docker compose build`, what the boot log should say, which banner is
expected and which means a step was missed, and how to run the end-to-end
check. Every command in it was executed verbatim to confirm it works.
2. "Before the real release" replaces the old three-item list:
- A. blocking config: real JWT_SECRET and a SEPARATE JWT_CUSTOMER_SECRET,
remove OTP_MASTER_CODE, configure the SMS credentials
- B. credentials the reference leaked and that must be rotated (SEC-12,
SEC-06) — same live accounts, never copied into this repo
- C. parity work not finished: admin (financial documents, users and
permissions incl. the still-open PERM-01, reports) and apps (MON-08 first,
then STUB-05 SMS recipients, STUB-04, STUB-03, analytics), pointing at
ISSUES.md for the rest
Also removed the obsolete `version:` key from docker-compose.yml, which printed
a deprecation warning on every compose command and made the checklist's output
noisy. Config re-validated.
Verified after these changes: container bundle is byte-identical to the local
release build (sha256 match), and that container was re-tested end to end:
compose e2e 32/32, complete-profile 38/38, report 40/40, expert login 36/36.
Locally: jest 54/54, build 5/5, typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports "گزارش" end to end (D24), the last missing app page.
In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.
API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
the row rather than rendering a fabricated value (REP-01)
SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.
Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
the reference's hasMCode() gate has no target equivalent
Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports "تکمیل پروفایل" end to end (D22).
API
- POST customer/me/complete-profile — three required document ids, the
customer taken from the token, verification step set to the reference's
LIVE value (approved outright; its `= 1` line is commented out at :83 in
favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking
per D20.
- POST customer/me/documents — the reference let the app upload through the
same file/create the admin used, inside its auth:api-customer group
(routes/api.php:95); the target's copy is admin-guarded, so the page could
not have uploaded at all. Reuses AdminUploadsService unchanged, so both
paths share one size limit, MIME whitelist and naming scheme (UPL-01).
- PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal
numbers; blank means nobody is notified and the upload still succeeds.
Three reference behaviours deliberately NOT ported, all in ISSUES.md:
- SEC-14: `$customer->update($request->all())` mass-assigned the whole request
body, letting the app write its own credit limit, making charge, gold debt,
active flag or mobile. Replaced with a three-field whitelist.
- SEC-15: four real personal mobiles hardcoded in the source.
- APP-03: refresh()'s unterminated setInterval page reload.
Apps (both, template+script byte-identical, per-app styling only)
- CompleteProfileView + SingleImageUploader
- route /complete_profile, redirecting home once the documents are in, as the
reference's guard did (router/index.ts:266)
- the entry point on the profile screen, shown only while they are missing
(profile.vue:96-102)
Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls
back to JWT_SECRET, which signs app and admin tokens with the same key and
leaves the `typ` check as the only barrier — and an expert's app token carries
their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest
risk); DELIVERY.md lists it as the first pre-release step (REL-03).
Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) ·
expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 ·
B2B scan 34 ok/0 broken · typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:
ChildOrdersView "سفارش مشتریان" — legacy ChildOrders.vue. Keeps the
reference's title, search, infinite paging, and per-card item count, total
weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
only returns that status, ApiMobileController:766).
CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
title, per-search re-query and the reference's choice to LIST inactive
customers (its active() scope is commented out at :187).
Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.
Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.
APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.
Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.
[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sales expert can now sign into the apps with their AdminUser account, without
reversing D17 — the expert remains an AdminUser and is never turned back into a
Customer.
The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy
resolves both to one principal whose `id` is always a Customer id, so every
route already written for a customer works unchanged for an expert. That is what
the reference gave them, where an agent was a customer with extra screens.
Each expert gets ONE linked trading Customer row (additive migration, dry-run
clean and rolled back before applying) created at first sign-in, seeded from the
two numbers the reference read off the agent's own row: SalesExpertProfile
.gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent ->
makingChargeOffsetPercent. Orders the expert places land there, exactly as the
reference's single row did.
New expert-only routes scope on the AdminUser id from the token:
POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping
its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy
/my-child, :184) and GET expert/whoami so the apps can hide the entries.
Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's
app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back
to JWT_SECRET when unset — in that configuration the token would otherwise have
validated as an admin with full panel permissions.
NOT ported: myChild minted an api_token per listed customer (:197-201), letting
an agent act AS that customer. Recorded in D21 rather than silently dropped.
Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading
account seeding, own-customers-only (with both negatives), child orders, the
expert ordering for themselves, an ordinary customer refused both screens with
their own app unaffected, and all four token boundaries including immediate
lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43,
B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32.
[ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEPLOY-01: docker-compose.yml passed only DATABASE_URL and PORT to the api
service, so JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and every SMS_TREZ_*
variable were silently dropped in Docker. A deployed build would have used the
development JWT fallback and would not have had the delivery master code at all,
so the demo login would have failed with no obvious cause. All are now forwarded,
defaulting to empty (which the code treats as unset); verified with
`docker compose config`.
TEST-01: test-sales-expert-roundtrip.cjs had localhost:3077 hardcoded and could
only connect by accident. Now defaults to the repo's API_PORT. 33/33.
DELIVERY.md documents env vars (names only), run/deploy commands, how migrations
run inside the container, the verification results and the known issues.
release/delivery contains both parity branches with no conflicts (fast-forward).
build 5/5, jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34,
B2B writes 25/25, auth-smoke 32/32, typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scan-admin-b2b.cjs (34 ok, 0 broken) hits every read endpoint the wholesale desk
uses daily. smoke-admin-b2b-writes.cjs (25/25) covers the money-touching writes:
admin order placement and a cash payment document, asserting the ledger and the
customer balance rather than the HTTP status.
Verified: the making charge computes exactly, a document is created unconfirmed,
confirming moves the gold debt, and confirming again toggles back and restores
the balance to the byte.
Retail/storefront surfaces were deliberately not scanned (D13).
No B2B breakage found. Three first-draft failures were test bugs, corrected
against the real callers: cities/list -> admin/cities/list, by-ids takes
products_id, and document type 4 is cash (2 is a transfer).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
smoke-apps.cjs walks login (master OTP), catalogue, cart cap, order placement
and order history, with the empty-order and unauthenticated negatives: 43/43.
The cart rules are executed from BOTH apps' own source so the result cannot be
carried by v1 alone.
No behavioural divergence found: all 9 stores are byte-identical, and the router,
API client and composables match except v1's protected variant-image picker.
APP-01: both apps' OTP box is exactly 4 inputs and only submits at 4, so a longer
OTP_MASTER_CODE cannot be typed into them. The login UI was left alone; main.ts
now warns at boot when the code is not 4 digits and .env.example states the rule.
APP-02 logged, not fixed: user-app-v2 has no getHomeSlider, so admin-configured
home slides show in v1 only. Changing v2's hero during a delivery is not worth
the risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
views/order/List.vue handed the timeline activity entry straight to
single/order-sts/edit. That entry is {status, title, proceed, uaj, note} and has
no order id, so UpdateOrderStatusDto rejected every call with 400 — neither
"ثبت وضعیت" nor "حذف وضعیت" worked on the retail order screen. Both handlers now
take the id from tempRow, matching OmdeList.vue and TaminList.vue, which were
already correct.
Found by the new scripts/smoke-admin.cjs (43/43), which walks login, customer
create, sales-expert create/edit, product+variant create/edit and order
list/detail/status, asserting the database changed rather than trusting a 200.
[ref: henza_server/routes/admin.php:59]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two live scripts against a running API, covering both halves of the master-OTP
contract:
* with OTP_MASTER_CODE set (8/8): signs in, the JWT works on customer/me,
Persian digits accepted, and the RULE E1 negatives — near-miss code,
unknown mobile, inactive customer — all still rejected.
* without it (6/6): the same code returns 401, no boot banner, a real code
still logs in, and replaying it fails.
Also confirmed from the boot log that with no SMS credentials the server starts
normally and declines to send instead of erroring (D20).
.env.example gained the auth, SMS and upload variables, names only, no values,
with OTP_MASTER_CODE marked delivery-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.
D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.
user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.
jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.
[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.
New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.
reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.
AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.
listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.
ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).
Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner ruling: computing and displaying price / price_with_discount is retail
storefront work, so it falls under D13 and is marked out of scope. The B2B
app works in making charge only.
variant-price.ts and its 11 hand-checked tests are kept and still run, just
unused. Nothing needed reverting: computeVariantPrice was never called from
outside its own file and spec, verified by grep over apps/api/src and
apps/admin-panel/src, so no price had been wired into any screen. PRICE-01
is re-marked in ISSUES.md rather than deleted so the evidence survives if
the storefront is ever taken up.
Making-charge work stays fully in scope: percentage_pay,
makingChargeOffsetPercent, and wage display on variants and order lines.
New Phase 1 order: Persons -> products/categories/variants/order detail ->
financial documents -> users and permissions -> admin SMS -> promotions,
notifications, cities, branches, workshops -> reports -> gate. Per section:
implement the gaps, then field-by-field test that section, and show a gap
summary before fixing.
SEC-02 re-verified live rather than quoted: 9/9 assertions, plus 32/32 in
auth-smoke.sh. POST /orders is 401 without a token; a body customerId of
"customer-2" sent with a customer-dev-test token still lands the order on
customer-dev-test and is warn-logged; a foreign order id returns 404 rather
than 403 so ids stay unenumerable; the admin read rejects a customer token.
Client-supplied wagePercent/discountPercent/flatRialCharge remain as MON-08,
already scheduled as Phase 2 item #1.
Also records why the dev OTP log briefly looked broken: a stale API process
still held port 3077, so the new instance died with EADDRINUSE and the
tailed log belonged to the old process. Not a regression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers D1's 172-vs-174 question with real counts: the catalogues hold 176
(reference) and 174 (target) unique keys, and the ENTIRE difference is the
dashboard. The reference has `dashboard` + `dashboard.omde` + `dashboard.tak`;
the target replaces all three with `dashboard.view`, because the admin
Dashboard is a PROTECTED zone that merged the two reference dashboards.
176 - 3 + 1 = 174. All 173 other keys match exactly, including the same 5
keys that legitimately appear twice in the tree.
Route-guard rids are 57 vs 57, differing only by that dashboard
consolidation plus one target-only route reusing an existing catalogue rid.
All 67 reachable reference admin routes exist in the target at the same
paths; 2 target-only routes are additive.
Three findings logged:
PRICE-01 (critical) — no product price is computed or shown anywhere in the
admin panel. The reference appends price/price_with_discount to every
variant and renders it in three places in its product list; the target's
variantRow() returns neither, nothing fetches a gold rate for the admin
list, and computeVariantPrice() — ported and unit-tested earlier today — is
called from nowhere. This is fix#1.
PERM-01 (medium) — dashboard.omde and dashboard.tak are seeded into the
permission table but absent from the permission UI, so they are grantable
yet unmanageable; the DB holds 178 distinct rids against the UI's 174. The
fix deletes permission rows, so it waits for the owner's approval.
PRICE-02 (info) — rial_pay belongs only to the superseded commented-out
formula; the target correctly omits it. Recorded so it is not "restored".
Contrary to the Phase 0 expectation, the order module is in good shape: the
gram_level credit limit matches the reference exactly including the strict
'>' comparison, and debt recognition fires at the same status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prisma migrate diff proposed DROP TABLE "_goldbank_migrations" because the
table exists in the database but was not declared in the schema. Declaring
it with @@ignore removes the proposal without exposing it to Prisma Client,
and the table and all its rows are kept.
Correcting the earlier DEAD-07 note: this is not a leftover from the
pre-Prisma port. It is the tracking table of the application's own
migration runner (src/prisma/migrate.ts:5), which exists because the Docker
deploy image is copy-only and ships no Prisma CLI, so migrate deploy cannot
run in the container (migrate.ts:7-13). The project therefore keeps two
ledgers deliberately: _prisma_migrations for the CLI in development, and
_goldbank_migrations for the runtime runner in the container. That runner
already filtered out any DROP of its own table (migrate.ts:190-196); the
schema declaration removes the cause rather than relying on that guard.
The practical consequence, recorded in DECISIONS.md D15: a new migration
must be safe under both paths, so its SQL stays idempotent.
The ledger grew 12 -> 14 rows while testing, with nothing deleted: starting
the API ran prepareDatabase(), which appended the two migrations applied
earlier today. Its $$-aware statement splitter handled the PL/pgSQL trigger
body correctly — trigger and function are both present and the 9 trigger
assertions pass against the live database.
Also records the UTF-8 testing rule: Persian test input must come from a
file, never a shell argument, because the Windows shell replaces Persian
characters with '?' and a test can then pass while storing '????'.
Verified: migrate diff -> "This is an empty migration" (zero drift);
migrate status -> up to date; tsc clean; 32 jest; 9 trigger assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the two approved additive migrations and fixes every consumer.
D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.
The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.
D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).
update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.
SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.
Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.
Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.
Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).
[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds computeVariantPrice(), a verbatim port of the live formula at
henza_server/app/Models/ProductVariant.php:183-190, with the per-customer
making-charge offset as an explicit input defaulting to 0. The three
superseded formulas commented out above it apply VAT to the whole price
and are deliberately not ported (D4).
11 unit tests assert against hand calculations derived from the PHP source
rather than from the implementation, covering: with and without the
offset, VAT on the non-metal portion only, floor-to-nearest-1000, the
extraPercent default of 7, and the equivalence between the app folding the
offset into percent_pay and the server deriving it. 18 tests pass overall.
Also adds the D0 name-split review report script (read-only) and two
investigations the owner asked for:
- SMS: the reference uses the Trez panel (smspanel.trez.ir, Sms.php:69,97)
with production credentials committed to git (SEC-12 — rotate them). All
12 message templates catalogued; env var names proposed; nothing
implemented pending approval.
- MON-08: henza_pwa computes percent_pay, discount and rial_pay rather
than asking the user (cart.vue:170-179, Mixins/product/index.ts:69-75),
so Phase 2 item #1 is to derive them server-side and ignore the client's
values. rash_weight stays read from the DB as a recorded deviation.
Records the owner's two new standing rules: migrations only through the
standard Prisma workflow, and every data deletion logged in CHANGELOG.
[ref: henza_server/app/Models/{ProductVariant,Sms}.php, henza_pwa/src/views/cart.vue]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner ruled that the reference also ran a retail storefront (B2C
single-customer shop) and that the wallet, cashback and
ConfigStatic['percent'] belong to it. These are preserved exactly as they
are: nothing deleted or disabled on either side, no parity work, listed in
the matrices with status "preserved — out of current scope" and no gap or
work row. Shared surfaces (product, variant, price, order, customer) need
the owner's approval before a change that could reach the storefront.
Classifying the endpoints for that split surfaced a counting error in the
Phase 0 inventory: routes/admin.php has 151 live explicit routes, not the
117 recorded, so the reference total is 412 and not 369 (logged DOC-01).
get-analytics-tak reads SingleCustomerOrders, so it is a storefront route
despite its neutral name (logged DOC-02).
Of the 412 reference endpoints, 109 are retail storefront, 15 are web.php
dev/ops closures, and 288 remain in scope (218 admin + 70 B2B app).
[ref: henza_server/routes/{admin,api,customer,site,web}.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.
POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.
GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.
Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.
OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.
Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.
Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.
Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.
[ref: henza_server ApiMobileController.php:533,536-545,615-632]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1 narrowed GET /customers but did not close the leak: the response still
carried every customer id, and /customers/:id, /:id/orders and
/:id/ledger-entries were still public, so the full customer book was still two
requests away. The user caught this. Stage 2 deletes the id-bearing surface.
Customer JWT, separate from the admin one (T1):
- new src/modules/customer-auth/ with its own passport strategy,
CustomerJwtGuard, @CustomerAuth() and @CurrentCustomer()
- signed with JWT_CUSTOMER_SECRET, and every token carries typ:'customer'
which the strategy verifies; both checks are present deliberately
- customer state re-read per request, so deactivating takes effect at once
- mirrors the reference's own split: auth:api vs auth('api-customer')
Real OTP (D6), porting ApiMobileController::userAuth/userVerfiy (:39-64):
- new CustomerOtp table, additive migration, nothing existing touched
- parity behaviour: rand(1000,9999), active-customer filter, faTOen digit
normalisation on both number and code, rejection on mismatch
- approved improvements over the reference: bcrypt-hashed code, 2-minute
expiry, 5 attempts then lockout, single use, send throttle (60s per
number, 5/number and 20/IP per 10 min), and no number enumeration
Routes removed, replaced by token-derived equivalents:
GET customers, customers/:id, /:id/orders, /:id/ledger-entries
-> GET customer/me, customer/me/orders, customer/me/ledger-entries
No route accepts a customer id any more, so the IDOR is closed structurally
rather than by a check. The admin panel was unaffected (admin/customers-omde/*).
Both apps migrated in the same commit per the v1/v2 parity rule: token-based
session store, Bearer header, 401 -> clear session and route to login, real
two-step OTP login. Each app keeps its own markup and styling; only logic moved.
Tests: auth-smoke.sh extended with the stage-2 assertions, 46 passed 0 failed
against a live server. jest installed dev-only (user-approved) so pnpm test
works for the first time: 7 passed covering faToEn, which moved to its own
digits.ts to be testable without booting Nest. Typecheck clean on api,
user-app and user-app-v2; both apps build.
[ref: henza_server ApiMobileController.php:39-78]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the
CLAUDE.md section 3 carve-out: matching the reference here would keep a money
write path open to anonymous callers.
Closed, each behind the rid the legacy vocabulary already had:
GET accounting/customers/:id/balance -> wallet.list
POST accounting/customers/:id/reconcile -> wallet.charge
POST accounting/repayments/gold -> document.create
POST accounting/repayments/rial -> document.create
POST accounting/entries/:id/confirm -> document.list.confirm
PATCH orders/:id/status -> order.list.sts.submit
Four of the accounting routes WRITE to the ledger. The order-status route was
worse in kind: its DTO takes a client-supplied actorAdminUserId, so an
anonymous caller could move any order and pin it on any admin. It had no
caller - the admin panel posts to admin/orders/status.
GET /customers kept its public status (both apps' login still matches the
phone number in the browser) but now selects only id/mobile/isActive/
isArchived. It previously returned every active customer's full name, national
code, both debts, credit limit and the URLs of their identity-document scans.
CustomerSummary narrowed in BOTH user apps in the same commit, per the
v1/v2 parity rule.
Not changed: /uploads stays public per ruling T2. The filename check requested
alongside it passed unchanged - md5(time + name + uuid), no directory listing.
Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a
low-privilege token (ut 88, 5 unrelated rids) and pass-through for a super
admin on every closed route, plus that the public routes still answer 200 and
that GET /customers exposes only the four login fields. 23 passed, 0 failed
against a live server. Typecheck clean on api, user-app and user-app-v2.
[ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0 gate passed. All rulings recorded in docs/parity/DECISIONS.md and
applied to PROTECTED.md, PROGRESS.md and ISSUES.md. No source code changed.
Urgent investigations requested by the user, all evidence-cited:
- percent trace: the target's implementation is WRONG. `percent` is the
customer's OWN column in the reference (customer_add.php:32), a per-customer
making-charge offset used to shift catalogue filter bounds
(ApiMobileController.php:391-396,76-77). The target reads the referring
agent's wagePercent instead (admin-customers.service.ts:245) and never
writes the field at all. It is NOT part of the price formula -- that extra
percent comes from a global ConfigStatic row (ProductVariant.php:50,183).
Two unrelated `percent` keys exist and must not be confused with it.
- @Public audit: 15 routes across 7 controllers, grouped by risk. Group A is
5 ledger routes (4 writes) that contradict decision D1; group B is 3 order
routes incl. a public status change accepting a client-supplied
actorAdminUserId; group C is 8 reads, of which only the catalogue, pricing,
slider and uploads are legitimately public.
- Login leak: it is in the TARGET. GET /customers is public and returns every
active customer with national ID numbers, debts and ID-document URLs.
passwordHash is correctly excluded. Not possible in the reference.
Also logged 5 customer-module defects found in the deep check the user asked
for (CUST-01..05), including the fullName collapse the user reported.
Skill updated per D12: Step C gains rule C0 (field-by-field matrix, never
page-by-page) and Step E gains rule E0 (mandatory 4-step round-trip test).
[ref: henza_server, henza_admin]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read-only inventory of all 7 projects (3 reference + 4 target) produced by
parallel subagents, every claim cited path:line. No source code changed.
- 00-INVENTORY.md consolidated mapping: stack, backend, admin, user apps
- PROTECTED.md draft protected list (awaiting user confirmation)
- ISSUES.md 26 logged issues (security, money/stock, validation, dead code)
- CHANGELOG.md change log scaffold
- PROGRESS.md resumable state + Q1-Q5 blocking Phase 1
- _inv-*.md 7 raw per-project inventories (8111 lines)
Key finding: the reference is a gold-jewellery B2B/B2C e-commerce system, not a
gold bank. Buy/sell, deposit/withdraw, transfer, KYC levels, payment callbacks,
cron and websockets do not exist in it, so they cannot be parity gaps.
[ref: henza_server, henza_admin, henza_pwa]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Extract product image gallery into a dedicated GallerySlider component
with swipe/tap support
- Add a teleported fullscreen image preview (tap to open, thumbnail strip,
body scroll lock) to SingleProduct
- Restore the Trust section that was accidentally commented out
- Swap back/favorite button positions and recenter the image counter
- Fix Icon.vue: tempIcon was a static ref set once at setup, so it never
updated on prop change when Vue reused the component instance (e.g. cart
qty steppers toggling trash/minus icons); switched to computed + watch
- Rename brand text from "منتزر" to "هنزاگلد"
- Fix a CSS typo in user-app-v2 SingleProduct (rgba alpha value)
- Rebuild dist assets
- SingleProduct.vue: rebuild gallery/header/action bar as a self-scrolling
layout with a sticky photo, solid-on-scroll header matching PageBar, and
a full-screen image preview (blurred overlay, centered thumbnail strip)
- Add GallerySlider.vue: pointer-based swipe carousel isolated in an
LTR subtree, fixing RTL/transform sign mismatch that caused slide jumps
- Fix Icon.vue: icon was read into a plain ref once at setup and never
updated on prop change; switch to computed so icon swaps (e.g. cart
qty stepper trash/minus toggle) actually re-render
- Fix quantity stepper DOM order in SingleProduct.vue/CartItem.vue so
plus/minus render correctly under RTL flex
- prisma/assign-downloaded-images.js: seed real product photos (FEATURED/
FEATURED_MOBILE/GALLERY/GALLERY_MOBILE) onto the 5 placeholder products
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
index.html was already in git pointing at hashed bundles, but the assets folder was missing from the image so nginx 404ed every JS/CSS request.
Co-authored-by: Cursor <cursoragent@cursor.com>
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
- Fix silent redirect-to-home for unauthenticated users on protected routes;
now redirects to login with a return path
- Restyle OrderCard, SearchInput, Filters, DropDown, and product card image
sizing/centering to match the app's LESS-token design language
- Fix ant-design Slider handle placement/drag under RTL and seed a valid
default range so handles actually render
- Fix chip-row edge padding and top spacing across Orders/Cart/Search lists
- Add pull-to-refresh to the dashboard and fix its header/padding to match
the fixed PageBar pattern
- Remove the floating WhatsApp button from index.html
Phase 2, completing the app-wide rollout of the home/product-detail design
language (LESS colour tokens, BEM class names, hairline-bordered cards,
SectionHead, dashed empty states) onto the account side: dashboard, orders,
transactions, profile, login and register.
Reuse over re-implementation: the dashboard's live rate block is now
components/home/RateStrip.vue rather than a second hand-built strip, and
every screen uses components/ui/PageBar.vue for its chrome.
OrderCard and OrderItemCard are restyled to match. OrderItemCard drops its
always-empty note row: our OrderItem schema has no note field (legacy's
per-item note was a real column there; ours is not), so it could only ever
print a dash.
OrdersView swaps ant Tabs for the same scrolling chip rail the rest of the
redesign uses, and its full-screen Modal with hand-written CSS overrides for
a bottom Drawer, matching how every other detail surface opens.
Deleted AppHeader.vue, orphaned once every screen moved to PageBar. Its
shutter animation was driven by transitionFlag, which nothing reads any
more — the router afterEach comment claiming otherwise is corrected rather
than left asserting something untrue, and the now-inert half is flagged in
place. ScrollFetchSearch loses its isFav prop: it gated two empty-state
branches that rendered the identical image, and its only caller
(FavoritesView) now renders its own.
Behaviour is preserved throughout. Orders still reads GET
/customers/:id/orders and filters client-side; transactions still reads
GET /customers/:id/ledger-entries with legacy's CREDIT/DEBIT colour meaning
intact; profile stays read-only over real fields; login keeps the real
customer lookup plus the documented OTP stub; register stays stubbed with
copy that does not claim an account was created.
Verified in a real browser against the running API and native database:
dashboard 4 tiles with a real balance, orders 1 real order card, profile 4
real spec rows, transactions the correct empty state for a customer with no
ledger rows, and login/register confirmed with the session cleared (the
guard bounces a signed-in user away from /login, which is why they needed a
separate pass). No console errors, no horizontal overflow. vue-tsc and vite
build both clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the home/product-detail design language (LESS colour tokens, BEM
class names, hairline-bordered cards, SectionHead, dashed empty states) to
the screens a shopper actually walks through: search, collection, newest,
cart and favorites.
Two shared pieces are extracted so the language stays consistent rather than
re-typed per screen:
- components/ui/PageBar.vue — the sticky translucent bar with blur and a
hairline, lifted from the home screen's own header so every other screen
gets identical chrome instead of the old dark AppHeader.
- components/product/ProductCard.vue — the catalogue workhorse card. It is
deliberately a sibling of home/ShowcaseCard (which keeps its editorial "wide"
variant and badge) and additionally surfaces stock, which the
catalogue needs and the home screen does not.
Deleted as orphaned once nothing imported them: ProductList,
ProductVerticalFull, ProductVertical, ProductHorizontal, CollectionCard —
all Tailwind utility soup carried over from the legacy PWA.
Behaviour is preserved throughout; only chrome and cards change. The cart
still posts to the real POST /orders with wagePercent from each item's
pinned variant, still gates on a real session, and still keeps the note
drawer and success step. Search keeps its client-side filtering, the Filters
sheet, category chips and collection tiles. No rial price is printed
anywhere, for the same reason as the rest of the redesign: a finished
piece's price is the live gold rate x weight plus wage, computed at order
time and never stored on the product.
FIXED a regression this redesign introduced before it shipped: dropping
ScrollFetchSearch from the search grid meant all 205 catalogue products
mounted at once (205 cards, 205 images). Both search and newest now render
a 20-item window that grows as the user reaches the end.
Verified in a real browser against the running API and native database, not
just a green build: home 13 cards, search 20 -> 40 -> 60 -> 80 on scroll,
newest 20 -> 40 -> 60, cart empty state, favorites 3 cards behind a seeded
session. All images loaded (0 broken), no horizontal overflow, no console
errors beyond pre-existing vue3-lazyload noise that predates this work.
vue-tsc and vite build both clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design language introduced here and reused across both screens:
- LESS colour tokens (@ink/@inkSoft/@inkMuted/@inkFaint, @wine/@wineSoft,
@canvas/@canvasSoft, @hairline/@hairlineSoft) added to
themeConfig/colors.json and injected globally via vite's modifyVars, so
scoped <style lang="less"> blocks resolve them with no import
- BEM-ish scoped class names, hairline-bordered cards at 14-18px radii,
ink surfaces with a wine accent, dashed-border empty states
- components/home/: SectionHead, ShowcaseCard, ShowcaseSlider, CategoryRail,
RateStrip, AccountStrip
Data honesty is deliberate throughout: no rial price is printed for a piece,
because a finished item's price is the live gold rate x weight plus wage,
computed at order time and never stored on the product. Only genuinely
present fields are shown (weight, making-charge percent, stock, category,
brand). Categories and the hero are derived from the real catalogue, so an
empty catalogue renders an empty state rather than placeholder tiles.
Also fixes a real bug in the product detail view: it ran, at module scope,
history.pushState + window.onpopstate = () => history.go(1). Because the
drawer mounts its slot eagerly, that fired for every product card on the
home screen and permanently hijacked the back button for the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The folder-discovery fix in the previous commit surfaced a second problem.
Databases migrated BEFORE that fix recorded the baseline under the name of
the hand-added flat duplicate that used to sit beside it
(prisma/migrations/0001_init.sql). Once the runner started discovering the
real folder (20260910113720_first), it saw a name it had never recorded and
tried to apply the baseline again — against a database that already had it:
Raw query failed. Code: 42710.
ERROR: type "AdminRole" already exists
The two files are identical SQL (verified byte-for-byte after normalising
line endings; they differ only by one trailing blank line), so the recorded
legacy name genuinely means that migration is applied. A LEGACY_NAME_ALIASES
map now treats '0001_init.sql' as an already-applied marker for
'20260910113720_first', so upgrading an existing database is a no-op instead
of a crash.
Verified against the local dev database this actually broke on
(postgresql://localhost:5432/gold_banking, which had recorded only the old
flat name): 'npm run dev' in apps/api now logs 'No pending migrations' and
'Nest application successfully started' instead of dying. That database's
tracking table was also reconciled to record both real migration names, and
it was confirmed to already contain both migrations' schema (AdminRole,
City.percent, user_type, CustomerKind all present) — nothing was re-run
against it. The dockerised database is unaffected: it already records the
real names, so the alias is inert there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three real bugs found and fixed while wiring up product images end-to-end:
1. src/prisma/migrate.ts only discovered flat prisma/migrations/<name>.sql
files, never Prisma's own folder-per-migration layout
(<timestamp>_<name>/migration.sql). Every migration created the normal
way (via 'prisma migrate dev') was silently skipped; only a hand-added
flat duplicate (prisma/migrations/0001_init.sql, now removed) ever ran.
This meant schema.prisma had drifted well ahead of what any real deploy
database actually had (missing columns on City, Customer, Branch,
Product, several new tables). Fixed to discover both layouts; the
drift is closed with a new 20260914165255_sync_schema_drift migration.
2. Neither nginx.conf (production) nor vite.config.ts (dev) proxied
/uploads/ to the API — only /api/. UploadedFile/ProductImage URLs
(/uploads/<file>, served by main.ts's useStaticAssets OUTSIDE the /api
prefix) 404'd through both. Both now proxy /uploads/ the same way /api/
already was.
3. docker-compose.yml's api service had no volume for storage/uploads, so
every uploaded file — and by extension every ProductImage row pointing
at one — was lost on the next container recreate/redeploy. Added a
named volume.
Also: main.ts's static file serving now sets Content-Type: image/avif
explicitly (serve-static's bundled mime table predates .avif and served it
as application/octet-stream; browsers rendered it anyway by sniffing, but
a stricter client would not have).
Product photos: copied 15 real jewelry photos into the uploads volume,
registered them as UploadedFile rows, and assigned each of the 5 existing
Product rows a randomly picked one (duplicates allowed, per request),
replacing their /img/no-items.png placeholder ProductImage row. Verified
end-to-end in a real browser against the real containerized API+DB: logged
in, opened the admin product list, confirmed all 5 rows render their photo
(naturalWidth/Height populated, network 200s, no console errors).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each now has an add button at the top of its own list page, so the nav
entry duplicated it:
- 'افزودن دسته بندی' (/category/add)
- 'وارد کردن سند مالی مشتری' (/add/findoc)
- 'تعریف تخفیف تک' (/major/promotion/tak/add)
- 'تعریف تخفیف عمده' (/major/promotion/add)
- 'افزودن فاکتور' (/invoice/add)
- 'افزودن شهر' (/city/create)
Only adminMenu is touched; all six routes still exist and still work as a
direct-link fallback. Parent submenus keep their remaining children
('تخفیف اتوماتیک' and 'لیست تخفیفات ' both still sit under 'تخفیفات', for
instance).
Verified in a real browser: menu items 26 -> 20, none of the six labels
render, all five parent submenus (دسته بندی ها، اسناد مالی، تخفیفات، فاکتور
انتقال، شهر ها) still present with their list children, icons unchanged at
16, no console errors. Committed dist/ artifact rebuilt to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each of these now has an add button at the top of its own list page, so the
nav entry duplicated it:
- 'اضافه کردن شخص' (/customer/add)
- 'افزودن شعب' (/branch/add)
- 'افزودن کارگاه' (/workshop/add)
- 'افزودن مدیر' (/user/create)
Only the adminMenu tree is touched; branchMenu keeps its own /customer/add
entry, since the branch build has no converted add-modal. All four routes
still exist and still work as a direct-link fallback, and the parent
submenus keep their remaining children (including 'دسترسی ها' under مدیران).
Verified in a real browser: menu items 30 -> 26, none of the four labels
render, all four parent submenus still present with their list children,
icons unchanged at 16, no console errors. Committed dist/ artifact rebuilt
to match (force-added, as it is gitignored but deliberately tracked for
Dokploy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dist/ is gitignored by apps/admin-panel/.gitignore but is deliberately
committed for Dokploy copy-only deploys (see root .gitignore's note), so it
is force-added. The previous artifact was a pre-port prototype build; the
previous commit removed its two stale assets without being able to add the
new ones, leaving index.html pointing at files absent from git. This restores
a self-consistent artifact built from current source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Dockerfile does `COPY dist /usr/share/nginx/html` and nginx serves that
folder's root, but vite's outDir was 'dist/export' (carried over from legacy's
vue.config.js). So the deployed root kept serving whatever index.html had been
committed at dist/ — a stale pre-port prototype build (lang="en", title
"Gold Bank — Admin Panel", no ant-design-vue at all, committed 2026-09-10 in
cea3b80). That artifact, not the current source, renders as an unstyled stack
of bullets and bare tables.
outDir is now 'dist' (and 'branchDist'), matching what the image serves and
matching apps/user-app's convention.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ADV4 upgrade dropped the old `ant-design-vue/dist/antd.less` import but
never added its replacement. ADV4 injects each COMPONENT's styles at runtime
via CSS-in-JS, but NOT the global baseline — that ships separately as
dist/reset.css and must be imported explicitly. Without it the page lost
box-sizing, the body margin reset and the browser default list/heading
resets, so the layout rendered unstyled.
Verified in a real headless browser this time, not just a green build:
reset.css now resolves through LESS and its rules are present in the built
CSS, the CSS-in-JS engine injects component styles (14 <style> tags on the
login page), and buttons/inputs/cards compute real ant metrics with dir=rtl.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bulkCustomer.vue (reached today only via /customer/add's AddCustomer.vue tab
wrapper, and via /bulkCustomer/edit/:id from 5 different list variants) gets
open/id modal support; both routes stay as a fallback.
OmdeCustomerList.vue is shared by 5 list pages (OmdeList, BankerList,
ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList). Per user decision, the
add button and modal-based edit are wired only on OmdeList.vue (the one this
form conceptually belongs to) via a new showAddButton prop, default false —
the other four keep navigating to the edit route unchanged.
singleCustomer.vue and ReagentCustomer.vue were also planned as add-form
candidates but have no create entry point anywhere in the app (edit-only,
reachable solely via /singleCustomer/edit/:id and /reagent/edit/:id) — left
untouched, nothing to convert. offersIgnore-add-edit.vue is a standalone
settings page with no list counterpart, not a create form — left as a page.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PromotionForm.vue (shared by the omde and tak variants) gains open/id modal
support; both thin wrappers (promotion_add_edit.vue, promotion_tak_add_edit.vue)
pass the props/emits through unchanged. promotion_list.vue gets two
ListPageHeader buttons (offers.create / offers.create.tak, matching the two
routes' rids) and picks which variant's modal to open based on the edited
row's type_customer, exactly as the old toEdit() picked which route to
navigate to.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same open/id pattern, kept at width=xl since it embeds its own product-picker
XTable plus a running cart table. Also deduped ListInvoice's confirmDelete
onto the shared useDeleteHandler composable (missed in the earlier delete-
handler pass since it wasn't byte-identical to the other 6 — it used
message.success + a different fallback string, both preserved).
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same pattern as branch/workshop/city: open/id props switch UserCreate.vue and
apps/category/Add.vue into BaseFormModal mode; the /user/create,
/user/edit/:id and /category/add/:id? routes are unchanged as a fallback.
ListUser.vue's pre-existing add button (previously a router.push, added
outside the original port and left there per its own comment) now opens the
modal via ListPageHeader instead.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both share FacilityList.vue, which now accepts an addRid/addLabel pair and a
#form slot: when addRid is supplied, edit opens the slot's modal instead of
navigating, and a ListPageHeader add button appears above the list. Branch
and workshop each plug their own AddBranches/AddWorkshop instance into that
slot, keyed by editingId so switching rows remounts the form (fresh
onMounted load) rather than needing new watch-based reactivity.
The /branch/add, /branch/edit/:id, /workshop/add, /workshop/edit/:id routes
are unchanged and still work as a route-based fallback.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Establishes the pattern the remaining 15 add/edit forms will follow:
- The form component accepts open?/id? props. When open is defined it's
used as-is; asModal is fixed at mount (not reactive) since the parent
decides once whether this instance is a modal or a route page.
- As a modal it renders through BaseFormModal (header=title, footer=
buttons, body=form) and emits success + update:open instead of
navigating; as a route page (the old /city/create still exists and still
works) it falls back to the original <a-card> + button layout unchanged.
- The list page gets a ListPageHeader add button above the list, gated on
the exact rid the route used to gate on (cities.create), opening the
modal instead of navigating.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- VerticalNavMenu.vue now renders the icon: values that navMenuItems.ts
already carried on 33 nodes but were never displayed, via the shared
resolveIcon() map. Sub-menus and leaf items get a #icon slot each; ADV4's
Menu shows icons only (labels hidden) once :inline-collapsed is true, which
is now wired to the collapsed prop.
- Fixed openKeys: it previously started at [] and was only ever updated by
user clicks, so a direct link or page refresh landed with the active
item's parent submenu collapsed. It's now derived from the current route
on load and on every navigation.
- Sidebar collapse state moves from a local ref in Main.vue to
stores/layout.ts's existing (previously unused) sidebarCollapsed, persisted
to localStorage so it survives a reload.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New src/components/common/:
- BaseFormModal.vue — the standard shell every converted add/edit modal will
use: header=title, footer=buttons, body=form. Replaces the ad-hoc
60%/70%/80% <a-modal> widths used across the codebase with 4 named sizes.
- ListPageHeader.vue — the add button that goes above every list page,
gated on the same rid the create route used (modelled on the one place
this already existed, ListUser.vue's add-user button).
New src/components/icons.ts: the legacy-Feather-name -> ant-icon map,
extracted out of TooltipBtn.vue so the sidebar can resolve the same names
through the same map instead of a second copy.
New src/composables/useDeleteHandler.ts: the delete-then-refetch-then-toast
handler that was copy-pasted verbatim in 6 list views (city/cityList.vue,
user/ListUser.vue, apps/category/List.vue, finical/list.vue,
apps/product/ProductList.vue, manage/Const.vue). Preserves each site's exact
success-message wording and message.success/info choice, and ProductList's
irregular POST-based delete endpoint.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Approved departure from the port's byte-faithful rule (docs/ADMIN-PORT-PLAN.md
bug rows 4-6), fixed and logged rather than silently changed:
- farsi.less: bold/700 @font-face blocks now load iran-yekan-700.woff2
instead of the 400 file, so bold text actually renders bold. No source
file exists for weight 300, so that face is dropped rather than faked.
- format("woff") corrected to format("woff2") on all faces.
- Removed 'font-weight: inherit' from the body,* reset, which was flattening
every heading's weight.
Also deduped 24 view files that each redeclared .grid-2/.grid-3/.grid-4 in
a scoped <style> block with drifted minmax values, though utilities.less
already defines all three globally. Removed the scoped copies; kept
PromotionForm's genuinely different 0.5rem gap as a small .grid-4--tight
modifier instead of a full redeclaration.
Verified: pnpm build passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Bump ant-design-vue 3.2.20 -> 4.2.6, @ant-design/icons-vue 6.1.0 -> 7.0.1.
- ADV4 removed LESS theming (modifyVars) for CSS-in-JS design tokens: drop
the vite.config.ts modifyVars block and the antd.less import; move the
three tokens ant actually consumed (primary/link/border-radius) to
App.vue's <a-config-provider :theme>.
- Delete src/assets/styles/ant.less (426 lines of hand-written RTL flips):
ADV4 renders RTL natively via CSS logical properties given direction="rtl",
which App.vue already sets. Preserve only the small residue ADV4 doesn't
cover natively (upload filename LTR, select dropdown z-index, tabs nav
direction) in a new rtl-overrides.css.
- Clean up the stale --primary/--accent token comment in utilities.less now
that ant.less is gone.
- Verified: pnpm build (vue-tsc -b && vite build) passes with zero errors;
the codebase already used v-model:open everywhere (no ADV3 visible prop
usage to migrate).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| `ADMIN_PANEL_PORT`, `USER_APP_PORT` | 5180–5181 | Dev servers only. |
| `RUN_MIGRATIONS` | `true` | Set `false` to skip the boot-time migration run. |
| `RUN_SEED` | `true` | Set `false` to skip seeding. |
| `ACCOUNTING_SECRET_KEY` | *(derived from `JWT_SECRET`)* | Encrypts the accounting-software token the admin enters (D27). Set its own long random value; changing it later means re-entering the token. |
@@ -240,7 +240,7 @@ pnpm dev # all apps via turbo
```
Ports come from the repo-root `.env` (single source of truth):
API 3000, admin-panel 5180, user-app 5181, user-app-v2 5182.
API 3000, admin-panel 5180, user-app 5181.
### Build
@@ -263,8 +263,8 @@ docker compose up -d --build
> success. During this delivery's compose run that bundle was four days old and
> the container was missing every route added since. Tracked as **DEPLOY-03**.
import{Iash}from"./Icon-B9FXOmlS.js";import{dasw,vasB,xasl,oasC,casS,wasu,eas$,tasA,pasE,fasL,kasc,Pasp,Masd,Aasj,rasM}from"./index-QQcP5-AC.js";constV=w({__name:"AccountingSyncButton",props:{endpoint:{}},emits:["done"],setup(m,{emit:f}){constv=m,_=f,s=c(!1),r=c(null),a=c(!1),k={customers:"مشتریان",balances:"ماندهها",products:"کارساختهها و موجودی"},y={scanned:"بررسیشده",inScope:"در گروههای انتخابی",refreshed:"بهروز",linked:"لینکشده",imported:"وارد شده",issues:"مغایرت",updated:"بهروز",items:"کارساخته",stockErrors:"خطای موجودی"},g=e=>e&&typeofe=="object"?Object.entries(e).map(([o,t])=>`${y[o]??o}: ${t}`).join("، "):"";B(async()=>{try{const{data:e}=awaitl.get("admin/accounting-status");s.value=!!(e!=null&&e.connected),r.value=(e==null?void0:e.provider)??null}catch{s.value=!1}});asyncfunctionx(){vare,o;a.value=!0;try{const{data:t}=awaitl.post(v.endpoint),b=Object.entries(t.results).map(([i,n])=>p("p",{style:n.ok?"":"color:#d4380d"},`${k[i]??i}: ${n.ok?g(n.summary)||"انجام شد":`خطا — ${n.error}`}`));(t.ok?d.success:d.warning)({title:t.ok?"سینک با حسابداری انجام شد":"سینک با حسابداری کامل انجام نشد",content:p("div",b),okText:"باشه"}),_("done")}catch(t){j.error(((o=(e=t==null?void0:t.response)==null?void0:e.data)==null?void0:o.message)||"سینک با حسابداری انجام نشد")}finally{a.value=!1}}return(e,o)=>{constt=M("a-button");returns.value?(C(),S(t,{key:0,loading:a.value,onClick:x},{icon:u(()=>[E(h,{icon:"vuesax-linear:refresh-2",size:"16",class:"icon-spin"})]),default:u(()=>[$(" سینک با "+A(r.value||"حسابداری"),1)]),_:1},8,["loading"])):L("",!0)}}});export{Vas_};
import{dasH,xasL,yasc,aasv,wass,hasR,kasq,masC,zasO,rasi,oasp,pasS,casn,bas$,easj,fasg,tasz,FasM,HasI,GasN,lasJ}from"./index-D8WOpuoz.js";import{oasK}from"./select--BgT6D7O.js";import{_asP}from"./BaseFormModal.vue_vue_type_style_index_0_lang-CEg92-2s.js";constQ={class:"grid-2"},W={class:"grid-2"},ee=H({__name:"AddBranches",props:{open:{type:Boolean},id:{}},emits:["update:open","success"],setup(y,{emit:V}){consth=y,w=V,f=h.open!==void0,E=q(),F=J(),r=f?h.id:E.params.id,m=C(!1),_=C([]),t=O({name:"",phone:"",address:"",user_id:void0}),d=O({});functionk(o,e){returnK(e).includes(o)}asyncfunctionb(){varo,e;if(Object.keys(d).forEach(l=>deleted[l]),!t.name){d.name="لطفا مقدار را وارد کنید";return}try{m.value=!0,r?awaitc.put(`branch/${r}`,t):awaitc.post("branch",t),N.info("اطلاعات با موفقیت به ثبت رسید."),r||Object.assign(t,{name:"",phone:"",address:"",user_id:void0}),f?(w("success"),w("update:open",!1)):F.push("/branch/list")}catch(l){N.error(((e=(o=l==null?void0:l.response)==null?void0:o.data)==null?void0:e.message)??"خطا در ثبت اطلاعات.")}finally{m.value=!1}}returnL(async()=>{try{const{data:o}=awaitc.get("user/list");_.value=o}catch(o){console.log(o)}if(r)try{const{data:o}=awaitc.get(`branch/${r}`);Object.assign(t,{name:o.name??"",phone:o.phone??"",address:o.address??"",user_id:o.user_id??void0})}catch(o){console.log(o)}}),(o,e)=>{constl=i("a-input"),u=i("a-form-item"),x=i("a-select-option"),U=i("a-select"),B=i("a-textarea"),T=i("a-button"),A=i("a-card"),D=i("a-form"),G=i("a-spin");returnf?(p(),v(P,{key:0,open:!!y.open,id:R(r),"add-title":"افزودن شعبه","edit-title":"ویرایش شعبه",loading:m.value,submitting:m.value,"onUpdate:open":e[4]||(e[4]=a=>o.$emit("update:open",a)),onSubmit:b},{default:s(()=>[S("div",Q,[n(u,{label:"نام شعبه","validate-status":d.name?"error":"",help:d.name},{default:s(()=>[n(l,{value:t.name,"onUpdate:value":e[0]||(e[0]=a=>t.name=a)},null,8,["value"])]),_:1},8,["validate-status","help"]),n(u,{label:"تلفن شعبه"},{default:s(()=>[n(l,{value:t.phone,"onUpdate:value":e[1]||(e[1]=a=>t.phone=a)},null,8,["value"])]),_:1}),n(u,{label:"مدیر"},{default:s(()=>[n(U,{value:t.user_id,"onUpdate:value":e[2]||(e[2]=a=>t.user_id=a),"show-search":"","allow-clear":"",placeholder:"انتخاب کنید","filter-option":k},{default:s(()=>[(p(!0),$(M,null,j(_.value,a=>(p(),v(x,{key:a.id,value:a.id},{default:s(()=>[g(z(a.name),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1})]),n(u,{label:"آدرس"},{default:s(()=>[n(B,{value:t.address,"onUpdate:value":e[3]||(e[3]=a=>t.address=a),"auto-size":{minRows:2,maxRows:5}},null,8,["value"])]),_:1})]),_:1},8,["open","id","loading","submitting"])):(p(),v(G,{key:1,spinning:m.value},{default:s(()=>[n(D,{layout:"vertical",onSubmit:I(b,["prevent"])},{default:s(()=>[n(A,{title:R(r)?"ویرایش شعبه":"افزودن شعبه",class:"mb-base page-card"},{default:s(()=>[S("div",W,[n(u,{label:"نام شعبه","validate-status":d.name?"error":"",help:d.name},{default:s(()=>[n(l,{value:t.name,"onUpdate:value":e[5]||(e[5]=a=>t.name=a)},null,8,["value"])]),_:1},8,["validate-status","help"]),n(u,{label:"تلفن شعبه"},{default:s(()=>[n(l,{value:t.phone,"onUpdate:value":e[6]||(e[6]=a=>t.phone=a)},null,8,["value"])]),_:1}),n(u,{label:"مدیر"},{default:s(()=>[n(U,{value:t.user_id,"onUpdate:value":e[7]||(e[7]=a=>t.user_id=a),"show-search":"","allow-clear":"",placeholder:"انتخاب کنید","filter-option":k},{default:s(()=>[(p(!0),$(M,null,j(_.value,a=>(p(),v(x,{key:a.id,value:a.id},{default:s(()=>[g(z(a.name),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1})]),n(u,{label:"آدرس"},{default:s(()=>[n(B,{value:t.address,"onUpdate:value":e[8]||(e[8]=a=>t.address=a),"auto-size":{minRows:2,maxRows:5}},null,8,["value"])]),_:1}),n(T,{type:"primary",class:"form-submit",onClick:b},{default:s(()=>[...e[9]||(e[9]=[g("ثبت اطلاعات",-1)])]),_:1})]),_:1},8,["title"])]),_:1})]),_:1},8,["spinning"]))}}});export{eeas_};
import{dasT,vasG,xasc,casv,wasl,qasR,iasH,kasS,yasj,rasi,oasp,masC,pasn,aasO,bas$,easg,tasM,FasN,BasI,AasV,jasJ}from"./index-QQcP5-AC.js";import{_asK}from"./BaseFormModal.vue_vue_type_style_index_0_lang-BOlb0dJf.js";constP={class:"grid-2"},Q={class:"grid-2"},Y=T({__name:"AddBranches",props:{open:{type:Boolean},id:{}},emits:["update:open","success"],setup(y,{emit:z}){consth=y,w=z,f=h.open!==void0,A=H(),E=J(),r=f?h.id:A.params.id,m=S(!1),_=S([]),t=j({name:"",phone:"",address:"",user_id:void0}),d=j({});functionk(s,e){varo,u;returnString(((u=(o=e==null?void0:e.children)==null?void0:o[0])==null?void0:u.children)??"").includes(s)}asyncfunctionb(){vars,e;if(Object.keys(d).forEach(o=>deleted[o]),!t.name){d.name="لطفا مقدار را وارد کنید";return}try{m.value=!0,r?awaitc.put(`branch/${r}`,t):awaitc.post("branch",t),V.info("اطلاعات با موفقیت به ثبت رسید."),r||Object.assign(t,{name:"",phone:"",address:"",user_id:void0}),f?(w("success"),w("update:open",!1)):E.push("/branch/list")}catch(o){V.error(((e=(s=o==null?void0:o.response)==null?void0:s.data)==null?void0:e.message)??"خطا در ثبت اطلاعات.")}finally{m.value=!1}}returnG(async()=>{try{const{data:s}=awaitc.get("user/list");_.value=s}catch(s){console.log(s)}if(r)try{const{data:s}=awaitc.get(`branch/${r}`);Object.assign(t,{name:s.name??"",phone:s.phone??"",address:s.address??"",user_id:s.user_id??void0})}catch(s){console.log(s)}}),(s,e)=>{consto=i("a-input"),u=i("a-form-item"),U=i("a-select-option"),x=i("a-select"),B=i("a-textarea"),F=i("a-button"),q=i("a-card"),D=i("a-form"),L=i("a-spin");returnf?(p(),v(K,{key:0,open:!!y.open,id:R(r),"add-title":"افزودن شعبه","edit-title":"ویرایش شعبه",loading:m.value,submitting:m.value,"onUpdate:open":e[4]||(e[4]=a=>s.$emit("update:open",a)),onSubmit:b},{default:l(()=>[C("div",P,[n(u,{label:"نام شعبه","validate-status":d.name?"error":"",help:d.name},{default:l(()=>[n(o,{value:t.name,"onUpdate:value":e[0]||(e[0]=a=>t.name=a)},null,8,["value"])]),_:1},8,["validate-status","help"]),n(u,{label:"تلفن شعبه"},{default:l(()=>[n(o,{value:t.phone,"onUpdate:value":e[1]||(e[1]=a=>t.phone=a)},null,8,["value"])]),_:1}),n(u,{label:"مدیر"},{default:l(()=>[n(x,{value:t.user_id,"onUpdate:value":e[2]||(e[2]=a=>t.user_id=a),"show-search":"","allow-clear":"",placeholder:"انتخاب کنید","filter-option":k},{default:l(()=>[(p(!0),O(N,null,$(_.value,a=>(p(),v(U,{key:a.id,value:a.id},{default:l(()=>[g(M(a.name),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1})]),n(u,{label:"آدرس"},{default:l(()=>[n(B,{value:t.address,"onUpdate:value":e[3]||(e[3]=a=>t.address=a),"auto-size":{minRows:2,maxRows:5}},null,8,["value"])]),_:1})]),_:1},8,["open","id","loading","submitting"])):(p(),v(L,{key:1,spinning:m.value},{default:l(()=>[n(D,{layout:"vertical",onSubmit:I(b,["prevent"])},{default:l(()=>[n(q,{title:R(r)?"ویرایش شعبه":"افزودن شعبه",class:"mb-base page-card"},{default:l(()=>[C("div",Q,[n(u,{label:"نام شعبه","validate-status":d.name?"error":"",help:d.name},{default:l(()=>[n(o,{value:t.name,"onUpdate:value":e[5]||(e[5]=a=>t.name=a)},null,8,["value"])]),_:1},8,["validate-status","help"]),n(u,{label:"تلفن شعبه"},{default:l(()=>[n(o,{value:t.phone,"onUpdate:value":e[6]||(e[6]=a=>t.phone=a)},null,8,["value"])]),_:1}),n(u,{label:"مدیر"},{default:l(()=>[n(x,{value:t.user_id,"onUpdate:value":e[7]||(e[7]=a=>t.user_id=a),"show-search":"","allow-clear":"",placeholder:"انتخاب کنید","filter-option":k},{default:l(()=>[(p(!0),O(N,null,$(_.value,a=>(p(),v(U,{key:a.id,value:a.id},{default:l(()=>[g(M(a.name),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1})]),n(u,{label:"آدرس"},{default:l(()=>[n(B,{value:t.address,"onUpdate:value":e[8]||(e[8]=a=>t.address=a),"auto-size":{minRows:2,maxRows:5}},null,8,["value"])]),_:1}),n(F,{type:"primary",class:"form-submit",onClick:b},{default:l(()=>[...e[9]||(e[9]=[g("ثبت اطلاعات",-1)])]),_:1})]),_:1},8,["title"])]),_:1})]),_:1},8,["spinning"]))}}});export{Yas_};
import{Iasc}from"./Icon-B9FXOmlS.js";import{dasb,uasg,kasx,aasn,mass,pasl,w,Fas_,basB,cash,a1asS,fasu,iasR,gasz,rasF,o,jasA,nasI,task,_asN}from"./index-QQcP5-AC.js";import{_asV}from"./bulkCustomer.vue_vue_type_script_setup_true_lang-UhBsQkgG.js";import{RasD}from"./ReagentCustomer-DkAyHy8l.js";import{SasP}from"./singleCustomer-XwT9-VyX.js";import"./BaseFormModal.vue_vue_type_style_index_0_lang-BOlb0dJf.js";import"./ImageUploadInput.vue_vue_type_script_setup_true_lang-gPDguZj2.js";import"./CityTreeSelect.vue_vue_type_script_setup_true_lang-qW84W-31.js";import"./DateInput-COU3oLhL.js";import"./index-DT4Uze1z.js";const$={id:"page-user-view",class:"pf-page"},j={class:"pf-head"},q={class:"kind-picker",role:"tablist"},E=["aria-selected","onClick"],L={class:"kind-card__icon"},M={class:"kind-card__text"},O=b({__name:"AddCustomer",setup(T){constv=R(),d=A(),p=g(),t=x(v.query.tab||"omde"),r=a=>!!p.hasPermission(a),i=z(()=>{consta=[];returnr("persons.create.client")&&a.push({key:"omde",title:"مشتری عمده",hint:"مغازهدار، بنکدار و خریدار عمده",icon:"vuesax-linear:shop"}),r("persons.create.seller")&&a.push({key:"reagent",title:"کارشناس فروش",hint:"کارشناس با کمیسیون و سقف اعتبار",icon:"vuesax-linear:personalcard"}),p.isSuperAdmin&&a.push({key:"tak",title:"مشتری تک",hint:"مشتری خردهفروشی حضوری",icon:"vuesax-linear:user"}),a}),f={omde:V,reagent:D,tak:P};functiony(){window.history.length>1?d.back():d.push("/customer/omde-list")}return(a,m)=>{constC=F("a-button");returno(),n("div",$,[s("header",j,[l(C,{class:"pf-head__back",shape:"circle",onClick:y},{icon:w(()=>[l(c,{icon:"vuesax-linear:arrow-right",size:"16"})]),_:1}),m[0]||(m[0]=s("div",{class:"pf-head__text"},[s("h1",null,"افزودن شخص"),s("p",null,"نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.")],-1))]),r("persons.create")&&i.value.length?(o(),n(_,{key:0},[s("div",q,[(o(!0),n(_,null,B(i.value,e=>(o(),n("button",{key:e.key,type:"button",role:"tab",class:I(["kind-card",{"is-on":t.value===e.key}]),"aria-selected":t.value===e.key,onClick:U=>t.value=e.key},[s("span",L,[l(c,{icon:e.icon,size:"22"},null,8,["icon"])]),s("span",M,[s("b",null,k(e.title),1),s("small",null,k(e.hint),1)]),t.value===e.key?(o(),h(c,{key:0,icon:"vuesax-linear:tick-circle",size:"16",class:"kind-card__check"})):u("",!0)],10,E))),128))]),i.value.some(e=>e.key===t.value)?(o(),h(S(f[t.value]),{key:t.value})):u("",!0)],64)):u("",!0)])}}}),se=N(O,[["__scopeId","data-v-84479c56"]]);export{seasdefault};
import{oasO}from"./select--BgT6D7O.js";import{dasP,xas$,yasd,basm,casn,wasl,masu,r,z,oasc,pasD,Fash,easC,fasv,GasE,Mask,aasx,tasB}from"./index-D8WOpuoz.js";constG={id:"page-user-list"},S={class:"grid-3"},H=P({__name:"ChangePercent",setup(j){constg=u([]),y=u([]),p=u("-"),_=u(!1),t=z({category_id:void0,brand_id:void0,percent:void0});functionb(e,a){returnO(a).toLowerCase().indexOf(e.toLowerCase())>=0}asyncfunctionf(){try{const{data:e}=awaitd.post("change/percent/count",{category_id:t.category_id,brand_id:t.brand_id});p.value=e.count}catch(e){console.log("e ==> ",e)}}asyncfunctionL(){if(t.percent===void0||t.percent===null){E.error("لطفا اجرت درصدی را وارد کنید");return}try{_.value=!0;const{data:e}=awaitd.post("change/all",{percent:t.percent,category_id:t.category_id,brand_id:t.brand_id});k.success({title:"تغییرات با موفقیت اعمال شد",okText:"بستن"}),p.value=e.updated}catch(e){k.success({title:"خطا در اعمال تغییرات"}),console.log(e)}finally{_.value=!1}}return$(async()=>{try{const[e,a]=awaitPromise.all([d.get("brand"),d.post("category")]);g.value=e.data,y.value=(a.data.rows??a.data).map(s=>({id:s.id,title:s.type_str?`${s.title} (${s.type_str}) `:s.title}))}catch(e){console.log(e)}f()}),(e,a)=>{consts=r("a-select-option"),w=r("a-select"),i=r("a-form-item"),M=r("a-input"),N=r("a-input-number"),T=r("a-button"),U=r("a-popconfirm"),V=r("a-form"),F=r("a-card");returnc(),m("div",G,[n(F,{title:"تغییر درصد محصولات",class:"page-card"},{default:l(()=>[n(V,{layout:"vertical"},{default:l(()=>[D("div",S,[n(i,{label:"دسته بندی"},{default:l(()=>[n(w,{value:t.category_id,"onUpdate:value":a[0]||(a[0]=o=>t.category_id=o),"allow-clear":"","show-search":"",placeholder:"انتخاب کنید","filter-option":b,onChange:f},{default:l(()=>[(c(!0),m(h,null,C(y.value,o=>(c(),x(s,{key:o.id,value:o.id},{default:l(()=>[v(B(o.title),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1}),n(i,{label:"برند"},{default:l(()=>[n(w,{value:t.brand_id,"onUpdate:value":a[1]||(a[1]=o=>t.brand_id=o),"allow-clear":"","show-search":"",placeholder:"انتخاب کنید","filter-option":b,onChange:f},{default:l(()=>[(c(!0),m(h,null,C(g.value,o=>(c(),x(s,{key:o.id,value:o.id},{default:l(()=>[v(B(o.title),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1}),n(i,{label:"تعداد یافت شده"},{default:l(()=>[n(M,{value:p.value,disabled:""},null,8,["value"])]),_:1}),n(i,{label:"اجرت درصدی"},{default:l(()=>[n(N,{value:t.percent,"onUpdate:value":a[2]||(a[2]=o=>t.percent=o),class:"w-full",step:.1},null,8,["value"])]),_:1})]),n(U,{title:"آیا از اعمال تغییرات مطمئن هستید؟","ok-text":"بله","cancel-text":"خیر",onConfirm:L},{default:l(()=>[n(T,{type:"primary",class:"form-submit",loading:_.value},{default:l(()=>[...a[3]||(a[3]=[v("اعمال تغییرات",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})]),_:1})])}}});export{Hasdefault};
import{dasP,vasS,xasu,aasv,pasn,wasl,kas_,rass,yasT,oasd,mas$,Fasw,basC,easg,A,Mask,casx,tasB}from"./index-QQcP5-AC.js";constD={id:"page-user-list"},E={class:"grid-3"},z=P({__name:"ChangePercent",setup(j){consty=_([]),b=_([]),p=_("-"),f=_(!1),a=T({category_id:void0,brand_id:void0,percent:void0});functionh(t,e){varr,c;returnString(((c=(r=e==null?void0:e.children)==null?void0:r[0])==null?void0:c.children)??"").toLowerCase().indexOf(t.toLowerCase())>=0}asyncfunctionm(){try{const{data:t}=awaitu.post("change/percent/count",{category_id:a.category_id,brand_id:a.brand_id});p.value=t.count}catch(t){console.log("e ==> ",t)}}asyncfunctionL(){if(a.percent===void0||a.percent===null){A.error("لطفا اجرت درصدی را وارد کنید");return}try{f.value=!0;const{data:t}=awaitu.post("change/all",{percent:a.percent,category_id:a.category_id,brand_id:a.brand_id});k.success({title:"تغییرات با موفقیت اعمال شد",okText:"بستن"}),p.value=t.updated}catch(t){k.success({title:"خطا در اعمال تغییرات"}),console.log(t)}finally{f.value=!1}}returnS(async()=>{try{const[t,e]=awaitPromise.all([u.get("brand"),u.post("category")]);y.value=t.data,b.value=(e.data.rows??e.data).map(r=>({id:r.id,title:r.type_str?`${r.title} (${r.type_str}) `:r.title}))}catch(t){console.log(t)}m()}),(t,e)=>{constr=s("a-select-option"),c=s("a-select"),i=s("a-form-item"),M=s("a-input"),N=s("a-input-number"),U=s("a-button"),V=s("a-popconfirm"),F=s("a-form"),O=s("a-card");returnd(),v("div",D,[n(O,{title:"تغییر درصد محصولات",class:"page-card"},{default:l(()=>[n(F,{layout:"vertical"},{default:l(()=>[$("div",E,[n(i,{label:"دسته بندی"},{default:l(()=>[n(c,{value:a.category_id,"onUpdate:value":e[0]||(e[0]=o=>a.category_id=o),"allow-clear":"","show-search":"",placeholder:"انتخاب کنید","filter-option":h,onChange:m},{default:l(()=>[(d(!0),v(w,null,C(b.value,o=>(d(),x(r,{key:o.id,value:o.id},{default:l(()=>[g(B(o.title),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1}),n(i,{label:"برند"},{default:l(()=>[n(c,{value:a.brand_id,"onUpdate:value":e[1]||(e[1]=o=>a.brand_id=o),"allow-clear":"","show-search":"",placeholder:"انتخاب کنید","filter-option":h,onChange:m},{default:l(()=>[(d(!0),v(w,null,C(y.value,o=>(d(),x(r,{key:o.id,value:o.id},{default:l(()=>[g(B(o.title),1)]),_:2},1032,["value"]))),128))]),_:1},8,["value"])]),_:1}),n(i,{label:"تعداد یافت شده"},{default:l(()=>[n(M,{value:p.value,disabled:""},null,8,["value"])]),_:1}),n(i,{label:"اجرت درصدی"},{default:l(()=>[n(N,{value:a.percent,"onUpdate:value":e[2]||(e[2]=o=>a.percent=o),class:"w-full",step:.1},null,8,["value"])]),_:1})]),n(V,{title:"آیا از اعمال تغییرات مطمئن هستید؟","ok-text":"بله","cancel-text":"خیر",onConfirm:L},{default:l(()=>[n(U,{type:"primary",class:"form-submit",loading:f.value},{default:l(()=>[...e[3]||(e[3]=[g("اعمال تغییرات",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})]),_:1})])}}});export{zasdefault};
import{dasm,oask,aasw,sasu,ras_}from"./index-D8WOpuoz.js";constp=" › ",g=m({__name:"CityTreeSelect",props:{value:{},cities:{},parentPicker:{type:Boolean},excludeId:{},placeholder:{}},emits:["update:value","change"],setup(o,{emit:h}){constc=o,r=h,d=u(()=>{conste=newSet(c.cities.map(n=>n.id)),l=newMap;for(constnofc.cities){constt=n.parent&&e.has(n.parent)&&n.parent!==n.id?n.parent:null;l.has(t)||l.set(t,[]),l.get(t).push(n)}consta=newSet,i=n=>(l.get(n)??[]).filter(t=>!a.has(t.id)&&!(c.parentPicker&&t.id===c.excludeId)).map(t=>(a.add(t.id),{id:t.id,title:t.title,children:i(t.id)}));returni(null)});functions(e,l,a=""){returne.flatMap(i=>{constn=[...l,i.title],t=n.join(p);return[{value:i.id,label:t,search:a?`${a}${p}${t}`:t},...s(i.children,n,a)]})}constv=u(()=>c.parentPicker?s(d.value,[]):d.value.map(e=>e.children.length?{key:e.id,label:e.title,options:s(e.children,[],e.title)}:{value:e.id,label:e.title,search:e.title}));functionf(e){r("update:value",e??void0),r("change",e??void0)}return(e,l)=>{consta=_("a-select");returnk(),w(a,{value:o.value??void0,options:v.value,"show-search":"","allow-clear":"","option-filter-prop":"search",placeholder:o.placeholder??"شهر را انتخاب کنید",class:"w-full","onUpdate:value":f},null,8,["value","options","placeholder"])}}});export{gas_};
import{dasm,oasv,casg,gasw,rask}from"./index-QQcP5-AC.js";const_=m({__name:"CityTreeSelect",props:{value:{},cities:{},parentPicker:{type:Boolean},excludeId:{},placeholder:{}},emits:["update:value","change"],setup(i,{emit:u}){constn=i,d=u,h=w(()=>{constl=newSet(n.cities.map(e=>e.id)),o=newMap;for(consteofn.cities){consta=e.parent&&l.has(e.parent)&&e.parent!==e.id?e.parent:null;o.has(a)||o.set(a,[]),o.get(a).push(e)}constr=newSet,p=(e,a)=>(o.get(e)??[]).filter(t=>!r.has(t.id)&&!(n.parentPicker&&t.id===n.excludeId)).map(t=>{r.add(t.id);constc=p(t.id,a+1),s={value:t.id,key:t.id,title:t.title};returnc.length&&(s.children=c),!n.parentPicker&&a===0&&c.length&&(s.disabled=!0),s});returnp(null,0)});functionf(l){d("update:value",l??void0),d("change",l??void0)}return(l,o)=>{constr=k("a-tree-select");returnv(),g(r,{value:i.value??void0,"tree-data":h.value,"show-search":"","allow-clear":"","tree-default-expand-all":"","tree-node-filter-prop":"title","dropdown-style":{maxHeight:"400px",overflow:"auto"},placeholder:i.placeholder??"شهر را انتخاب کنید",class:"w-full","onUpdate:value":f},null,8,["value","tree-data","placeholder"])}}});export{_};
import{_,basc,case,waso,fasl,rass,oasu}from"./index-D8WOpuoz.js";constp={},d={class:"full-page-msg"};functioni(r,t){consta=s("a-button"),n=s("a-result");returnu(),c("div",d,[e(n,{status:"404",title:"صفحه مورد نظر یافت نشد","sub-title":"آدرسی که وارد کردهاید وجود ندارد."},{extra:o(()=>[e(a,{type:"primary",onClick:t[0]||(t[0]=f=>r.$router.push("/"))},{default:o(()=>[...t[1]||(t[1]=[l("بازگشت به خانه",-1)])]),_:1})]),_:1})])}constx=_(p,[["render",i],["__scopeId","data-v-82a95474"]]);export{xasdefault};
import{_,aasl,pase,waso,easc,rass,oasp}from"./index-QQcP5-AC.js";constu={},d={class:"full-page-msg"};functioni(r,t){consta=s("a-button"),n=s("a-result");returnp(),l("div",d,[e(n,{status:"404",title:"صفحه مورد نظر یافت نشد","sub-title":"آدرسی که وارد کردهاید وجود ندارد."},{extra:o(()=>[e(a,{type:"primary",onClick:t[0]||(t[0]=m=>r.$router.push("/"))},{default:o(()=>[...t[1]||(t[1]=[c("بازگشت به خانه",-1)])]),_:1})]),_:1})])}constx=_(u,[["render",i],["__scopeId","data-v-82a95474"]]);export{xasdefault};
import{_,basc,case,waso,fasl,rass,oasu}from"./index-D8WOpuoz.js";constp={},d={class:"full-page-msg"};functioni(r,t){consta=s("a-button"),n=s("a-result");returnu(),c("div",d,[e(n,{status:"500",title:"خطای سرور","sub-title":"مشکلی در سرور رخ داده است. لطفاً بعداً تلاش کنید."},{extra:o(()=>[e(a,{type:"primary",onClick:t[0]||(t[0]=f=>r.$router.push("/"))},{default:o(()=>[...t[1]||(t[1]=[l("بازگشت به خانه",-1)])]),_:1})]),_:1})])}constx=_(p,[["render",i],["__scopeId","data-v-1080291a"]]);export{xasdefault};
import{_,aasl,pase,waso,easc,rass,oasp}from"./index-QQcP5-AC.js";constu={},d={class:"full-page-msg"};functioni(r,t){consta=s("a-button"),n=s("a-result");returnp(),l("div",d,[e(n,{status:"500",title:"خطای سرور","sub-title":"مشکلی در سرور رخ داده است. لطفاً بعداً تلاش کنید."},{extra:o(()=>[e(a,{type:"primary",onClick:t[0]||(t[0]=m=>r.$router.push("/"))},{default:o(()=>[...t[1]||(t[1]=[c("بازگشت به خانه",-1)])]),_:1})]),_:1})])}constx=_(u,[["render",i],["__scopeId","data-v-1080291a"]]);export{xasdefault};
import{dasr,vasc,xasn,aasi,jasm,oasd}from"./index-QQcP5-AC.js";import{uas_}from"./auth-CP16SSo9.js";constl={style:{"min-height":"100vh",display:"grid","place-items":"center"}},h=r({__name:"IamCallback",setup(p){constt=m(),o=_();returnc(async()=>{conste=newURLSearchParams(window.location.search);if(sessionStorage.removeItem("gb_admin_iam_sso_checked"),e.get("error")||!e.get("code")||!e.get("state")){sessionStorage.setItem("gb_admin_iam_sso_skip","1"),awaitt.replace("/auth/login");return}try{const{data:s}=awaitn.post("admin/auth/sso/callback",{code:e.get("code"),state:e.get("state")}),a={...s};deletea.roles,deletea.ut,o.inject(a),awaitt.replace("/dashboard/ecommerce")}catch{sessionStorage.setItem("gb_admin_iam_sso_skip","1"),awaitt.replace("/auth/login")}}),(e,s)=>(d(),i("div",l,"در حال ورود..."))}});export{hasdefault};
import{dasr,xasc,yasn,basi,lasm,oasd}from"./index-D8WOpuoz.js";import{uas_}from"./auth-CRH9VIb7.js";constl={style:{"min-height":"100vh",display:"grid","place-items":"center"}},h=r({__name:"IamCallback",setup(p){constt=m(),o=_();returnc(async()=>{conste=newURLSearchParams(window.location.search);if(sessionStorage.removeItem("gb_admin_iam_sso_checked"),e.get("error")||!e.get("code")||!e.get("state")){sessionStorage.setItem("gb_admin_iam_sso_skip","1"),awaitt.replace("/auth/login");return}try{const{data:s}=awaitn.post("admin/auth/sso/callback",{code:e.get("code"),state:e.get("state")}),a={...s};deletea.roles,deletea.ut,o.inject(a),awaitt.replace("/dashboard/ecommerce")}catch{sessionStorage.setItem("gb_admin_iam_sso_skip","1"),awaitt.replace("/auth/login")}}),(e,s)=>(d(),i("div",l,"در حال ورود..."))}});export{hasdefault};
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.