diff --git a/design/admin/01-login.html b/design/admin/01-login.html new file mode 100644 index 0000000..91f3b6b --- /dev/null +++ b/design/admin/01-login.html @@ -0,0 +1,51 @@ + + + + + + Login — iLED Admin + + + +
+
+ + + + + + +
+
+ + +
+
+ + +
+ + +
+

+ ← Mock gallery +

+
+
+ + + + diff --git a/design/admin/02-dashboard.html b/design/admin/02-dashboard.html new file mode 100644 index 0000000..695df2b --- /dev/null +++ b/design/admin/02-dashboard.html @@ -0,0 +1,291 @@ + + + + + + Dashboard — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+ + + + +
+
AK Anna K.
+
+
+ +
+ + + + + + +
+ + + + +
+
+
+

Needs action — orders

+ All +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OrderStatusHandled by
#ILED-1042Pending payment—Open
#ILED-1041Paid · pick + + AK + Anna K. + + Open
#ILED-1039Label missing + + MW + Marek W. + + Open
#ILED-1037Processing + + AK + Anna K. + + Open
+
+
+ +
+
+

Low stock

+ Inventory +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SKUQtyThreshold
+
Lampa LED Black
+
LED-BLK-01
+
25Restock
+
Lampa LED Gold
+
LED-GLD-01
+
45Restock
+
LED Strip 5m
+
LED-STR-5
+
15Restock
+
+
+
+ +
+
+
+

Orders — last 14 days

+ Count +
+
+
+ +
+
+
+ +
+
+

Pending reviews

+ Moderate +
+
+
+
+
+
Lampa LED Black
+
★★★★☆
+
“Światło jest świetne, montaż łatwy…”
+
+ Review +
+
+
+
+
+
+
LED Strip 5m
+
★★★☆☆
+
“Kolory OK, instrukcja mogłaby…”
+
+ Review +
+
+
+
+
+
+
+
+ + + + diff --git a/design/admin/03-products.html b/design/admin/03-products.html new file mode 100644 index 0000000..0ce3e88 --- /dev/null +++ b/design/admin/03-products.html @@ -0,0 +1,276 @@ + + + + + + Products — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+ + + +
+ +
+
+
+
+ ⌕ + +
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProductSKU / EANPriceStockReservedStatus
+
+
+
+
Lampa LED Black
+
Desk lamps · Bestseller
+
+
+
LED-BLK-01
5901234567890
299,00 zł2 WH1:1 WH2:11ActiveEdit
+
+
+
+
Lampa LED Gold
+
Desk lamps
+
+
+
LED-GLD-01
5901234567891
329,00 zł4 WH1:3 WH2:10ActiveEdit
+
+
+
+
LED Strip 5m
+
Strips · New
+
+
+
LED-STR-5
5901234567892
149,00 zł1 WH1:12ActiveEdit
+
+
+
+
Lampa LED White
+
Desk lamps
+
+
+
LED-WHT-01
5901234567893
289,00 zł24 WH1:18 WH2:63InactiveEdit
+
+ +
+
+ +
+
+
+

Warehouses

+ +
+
+ + + + + + + + + + + + + + + + + + + + +
NameCodeAddressProducts with stock
Warszawa CentralWH1ul. Przykładowa 1, Warszawa12
Kraków HubWH2ul. Magazynowa 8, Kraków9
+
+
+
+ +
+
+
+

Product groups

+ +
+
+ + + + + + + + + + + + + + + + + + +
NameSlugProducts
Desk lampsdesk-lamps8
Stripsstrips4
+
+
+

+ Prefer storefront structure? Manage Categories separately. +

+
+
+
+
+
+
+ + + diff --git a/design/admin/04-product-form.html b/design/admin/04-product-form.html new file mode 100644 index 0000000..ef57d20 --- /dev/null +++ b/design/admin/04-product-form.html @@ -0,0 +1,208 @@ + + + + + + Edit product — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+ + + + + +
+ +
+
+
+

Identity

+
+
+
+
+
+
+
+
+

Pricing & catalog

+
+
+
+
+ + +
+
+ + + Wired to Categories (new). +
+
+
+
+ + + +
+
+
+
+ +
+
+
+

Polish (PL)

+
+
+
+
+
+
+
+

English (EN)

+
+
+
+
+
+
DE / CS follow the same pattern (collapsed in mock).
+
+
+ +
+
+
+

Images

+ +
+
+
+
+
+
+ +
+
+
+ Add image + Drop file or paste URL +
+
+
+
+
+
+ +
+
+
+

Per warehouse

+
+ + + + + + + + + + + + + + + + +
WarehouseQuantityReservedAvailable
Warszawa Central (WH1)10
Kraków Hub (WH2)01
+
+
+
+
+ + + Overrides store default from Settings. +
+
+
+
+ +
+
+

Search & sharing

+
+
+
+
+
+
+
+
+
+ + + diff --git a/design/admin/05-orders.html b/design/admin/05-orders.html new file mode 100644 index 0000000..f3fb105 --- /dev/null +++ b/design/admin/05-orders.html @@ -0,0 +1,337 @@ + + + + + + Orders — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
+ AK + Anna K. +
+
+
+ +
+ + +
+ + +
+ +
+
+
+
+ + +
+ + + +
+ +
+ +
+ 0 selected + + + + Actions attributed to Anna K. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OrderCustomerTotalPaymentFulfillmentHandled byAge
+
#ILED-1042
+
17 Oct 10:24
+
anna.kowalska@mail.pl299,00 złPending——2hOpen
+
#ILED-1041
+
17 Oct 09:01
+
jan.nowak@mail.pl478,00 złPaidReady to pick + + AK + Anna K. + + 3hPick
+
#ILED-1039
+
16 Oct 18:40
+
m.wisniewska@mail.pl149,00 złPaidLabel missing + + MW + Marek W. + + 18hOpen
+
#ILED-1037
+
16 Oct 14:12
+
piotr.z@mail.pl628,00 złPaidProcessing + + AK + Anna K. + + 22hOpen
+
#ILED-1030
+
15 Oct 11:05
+
ola@mail.pl329,00 złPaidShipped + + MW + Marek W. + + 2dOpen
+
+ +
+
+ +
+
+
+ + +
+ + AK + Picking as Anna K. + + +
+
+ 2 orders selected + + +
+
+ +
+
+
+ +
+

#ILED-1041 · jan.nowak@mail.pl

+ 2 lines · DHL · claimed by Anna K. +
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
ProductQtyLocationCollected by
Lampa LED Black1WH1 · A-12—
LED Strip 5m1WH1 · B-03—
+
+
+ +
+
+
+ +
+

#ILED-1037 · piotr.z@mail.pl

+ 1 line · DPD · claimed by Anna K. +
+
+ +
+
+ + + + + + + + + + + +
ProductQtyLocationCollected by
Lampa LED Gold2WH1 · A-08 + + MW + Marek W. + +
+
+
+
+
+
+
+ + + diff --git a/design/admin/06-order-detail.html b/design/admin/06-order-detail.html new file mode 100644 index 0000000..f442789 --- /dev/null +++ b/design/admin/06-order-detail.html @@ -0,0 +1,252 @@ + + + + + + Order #ILED-1041 — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+
+
+

Line items

+
+ + + + + + + + + + + + + + + + + + + + + + +
ProductQtyPick fromUnitTotalPicked by
+
+
+
+
Lampa LED Black
+
LED-BLK-01
+
+
+
1 + + 299,00 zł299,00 zł—
+
+
+
+
LED Strip 5m
+
LED-STR-5
+
+
+
1 + + 149,00 zł149,00 zł—
+
+
+
Subtotal448,00 zł
+
Delivery30,00 zł
+
Discount−0,00 zł
+
Total (incl. VAT)478,00 zł
+
+
+ +
+

Internal note

+
+ +
+ Last edit: Anna K. · 17 Oct 09:12 + +
+
+
+
+ +
+
+

Status

+
+
+ + +
+ + +

Status changes are logged with your admin identity.

+
+
+ +
+

Customer & shipping

+
+
Jan Nowak
+ +
+48 600 100 200
+
+
+

Delivery

+
DHL Parcelshop
+
ul. Długa 12, 00-238 Warszawa
+
Locker / point: DHL-WAW-12
+
+
+

Shipment

+
No tracking yet
+
+ + +
+
+
+ +
+

Activity · who handled what

+
+
    +
  • +
    Claimed for picking
    +
    17 Oct · 09:10
    +
    + AK + Anna Kowalska +
    +
  • +
  • +
    Payment completed
    +
    17 Oct · 09:05 · PayU
    +
    + SYS + System +
    +
  • +
  • +
    Order placed
    +
    17 Oct · 09:01
    +
    + CUS + Customer +
    +
  • +
+
+
+
+
+
+
+
+ + + diff --git a/design/admin/07-promotions.html b/design/admin/07-promotions.html new file mode 100644 index 0000000..5312909 --- /dev/null +++ b/design/admin/07-promotions.html @@ -0,0 +1,197 @@ + + + + + + Promotions — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+
+
Active promotions
+
3
+
+
+
Redemptions (30d)
+
86
+
+
+
Discount given
+
4 120 zł
+
+
+ +
+
+
+
+ ⌕ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeWindowStatus
+
Autumn 10%
+
Min order 150 zł
+
Percent1–31 OctActive
+
Free shipping weekend
+
No min
+
Free shipping18–19 OctScheduled
+
Welcome −30 zł
+
Reward on paid order
+
FixedOngoingActive
+
+
+ +
+
+
+

Autumn 10%

+ +
+
+

Benefit

+
+
+
+
+
+
+

Requirements

+
+
+
+
+
+
+
+

Banner (storefront)

+
+
+
+
+ +
+
+

Codes

+ +
+
+
+
+
JESIEN10
+
Max 500 · used 214
+
+ Active +
+
+
+ + +
+
+
+
+
+
JESIEN10-VIP
+
Max 50 · used 12
+
+ Active +
+
+
+
+
+
+
+
+
+ + + diff --git a/design/admin/08-newsletter.html b/design/admin/08-newsletter.html new file mode 100644 index 0000000..0f1e2e6 --- /dev/null +++ b/design/admin/08-newsletter.html @@ -0,0 +1,194 @@ + + + + + + Newsletter — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+ + + +
+ +
+
+
+

Campaign composer

+
+
+ + +
+
+ + +
+
+ + + Leave empty to send immediately after confirm. +
+
+ + +
+
+
+
+

Preview

Inbox
+
+
+
From: newsletter@iled.pl
+
Jesienne nowości iLED — −10% z kodem JESIEN10
+

Witaj w iLED

+

+ Sprawdź nasze lampy LED i skorzystaj z kodu JESIEN10. +

+
+
+
+
+
+ +
+
+
+
+ ⌕ + +
+ +
+ 1 248 subscribers +
+
+ + + + + + + + + + + + + + + + + + + +
EmailJoinedStatus
anna.kowalska@mail.pl12 Sep 2026Active
jan.nowak@mail.pl03 Oct 2026Active
old.user@mail.pl01 Jan 2026Unsubscribed
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
SubjectSentRecipientsStatus
Back to school lighting01 Sep 20261 102Sent
Summer sale draft——Draft
Welcome series #1Scheduled 20 Oct1 248Scheduled
+
+
+
+
+
+
+ + + diff --git a/design/admin/09-reviews.html b/design/admin/09-reviews.html new file mode 100644 index 0000000..8d970c1 --- /dev/null +++ b/design/admin/09-reviews.html @@ -0,0 +1,181 @@ + + + + + + Reviews — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+
+ + +
+ ⌕ + +
+
+ 4 pending +
+ +
+
+ +
+
+
+
+
Lampa LED Black
+
Anna K. · 16 Oct 2026
+
+
★★★★☆
+
+

+ Światło jest świetne, montaż łatwy. Paczka doszła szybko. Polecam! +

+
+ + +
+
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+
LED Strip 5m
+
Marek W. · 15 Oct 2026
+
+
★★★☆☆
+
+

+ Kolory OK, instrukcja mogłaby być jaśniejsza. Działa jak trzeba. +

+
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+
Lampa LED Gold
+
Ola · 14 Oct 2026
+
+
★★★★★
+
+

+ Piękny kolor złota, idealna na biurko. Kupię drugą. +

+
+ + +
+
+
+
+ + +
+
+
+
+ + + diff --git a/design/admin/10-customers.html b/design/admin/10-customers.html new file mode 100644 index 0000000..41c37f1 --- /dev/null +++ b/design/admin/10-customers.html @@ -0,0 +1,200 @@ + + + + + + Customers — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+
+
+
+ ⌕ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CustomerOrdersLTVLast orderStatus
+
Jan Nowak
+
jan.nowak@mail.pl
+
52 140 zł17 OctActive
+
Anna Kowalska
+
anna.kowalska@mail.pl
+
2628 zł17 OctActive
+
Maria Wiśniewska
+
m.wisniewska@mail.pl
+
1149 zł16 OctActive
+
Spam Account
+
spam@example.com
+
00 zł—Blocked
+
+ +
+ +
+
+
+

Jan Nowak

+ +
+
+
Email
+
jan.nowak@mail.pl
+
Phone
+
+48 600 100 200
+
Joined
+
03 Mar 2025
+
+
+

Addresses

+
Billing
+
ul. Krótka 4 / 12, 00-001 Warszawa
+
Shipping default
+
Same as billing · prefers InPost
+
+
+

Notes

+ + +
+
+ +
+

Order history

+
+ + + + + + + + + + + + + + + + + + + +
OrderTotalStatus
#ILED-1041478,00 złReady to pick
#ILED-0988329,00 złCompleted
#ILED-0912598,00 złCompleted
+
+
+
+
+
+
+
+ + + diff --git a/design/admin/11-categories.html b/design/admin/11-categories.html new file mode 100644 index 0000000..db68a1c --- /dev/null +++ b/design/admin/11-categories.html @@ -0,0 +1,162 @@ + + + + + + Categories — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+
+
+

Tree

+ Drag reorder — mock +
+
+
+ ▾ +
+
Lamps
+
slug: lamps
+
+ 8 + On +
+
+
+ · +
+
Desk
+
lamps/desk
+
+ 5 + On +
+
+ · +
+
Floor
+
lamps/floor
+
+ 3 + On +
+
+
+ ▾ +
+
Strips
+
slug: strips
+
+ 4 + On +
+
+
+ · +
+
RGB
+
strips/rgb
+
+ 2 + On +
+
+ · +
+
White
+
strips/white
+
+ 2 + Off +
+
+
+
+ +
+
+

Edit · Lamps

+ +
+
+
+
+
+
+
+ +
+ + View products +
+
+
+
+
+
+
+ + + diff --git a/design/admin/12-inventory.html b/design/admin/12-inventory.html new file mode 100644 index 0000000..af20fc7 --- /dev/null +++ b/design/admin/12-inventory.html @@ -0,0 +1,238 @@ + + + + + + Inventory — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+ + + 3 SKUs at or below threshold + + + + 6 units reserved in open orders + +
+ +
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProductAvailableReservedThresholdWH1WH2
+
Lampa LED Black
+
LED-BLK-01
+
11511 + + Product +
+
Lampa LED Gold
+
LED-GLD-01
+
40531 + + Product +
+
LED Strip 5m
+
LED-STR-5
+
02510 + + Product +
+
+
+ + +
+

Quick adjust · Lampa LED Black

+
+
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ ⌕ + +
+
+ Available = stock − reserved +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SKUWH1 qtyWH2 qtyTotalReservedAvailable
LED-BLK-0111211
LED-GLD-0131404
LED-STR-51012−1†
LED-WHT-0118624321
+
+
+ † Negative available means oversell risk — fulfill from another warehouse or restock. +
+
+
+
+
+
+ + + diff --git a/design/admin/13-settings.html b/design/admin/13-settings.html new file mode 100644 index 0000000..b539ce1 --- /dev/null +++ b/design/admin/13-settings.html @@ -0,0 +1,248 @@ + + + + + + Settings — iLED Admin + + + + +
+ + +
+
+
+ + Mocks +
+
+
+
AK Anna K.
+
+
+ +
+ + +
+ + +
+
+

Store profile

+
+
+
+
+
+
+
+ + + + + + + + + + + + +
+
+
+
+
+ + + diff --git a/design/admin/14-analytics.html b/design/admin/14-analytics.html new file mode 100644 index 0000000..c51a884 --- /dev/null +++ b/design/admin/14-analytics.html @@ -0,0 +1,54 @@ + + + + + + Analytics — iLED Admin + + + +
+ +
+
+
+ +
+
Revenue
—
+
Orders
—
+
AOV
—
+
Units
—
+
+
+

Top products

+
Implemented in Angular admin at /analytics — top sellers, trend chart, channel breakdown.
+
+
+
+
+ + + diff --git a/design/admin/README.md b/design/admin/README.md new file mode 100644 index 0000000..82d3700 --- /dev/null +++ b/design/admin/README.md @@ -0,0 +1,45 @@ +# iLED Admin — K-design mocks (Phase 1) + +Interactive HTML mocks for the admin redesign. No build step. + +## Open + +1. Open [`index.html`](index.html) in a browser, or +2. From repo root: `start design/admin/index.html` (Windows) + +Shared assets: [`assets/admin-k.css`](assets/admin-k.css), [`assets/admin-k.js`](assets/admin-k.js). + +## Screens + +| File | Purpose | +|------|---------| +| `01-login.html` | Auth | +| `02-dashboard.html` | Action center (alerts, queues, KPIs) | +| `14-analytics.html` | Sales analytics (Angular `/analytics`) | +| `03-products.html` | Catalog + warehouses + groups | +| `04-product-form.html` | Sectioned product editor | +| `05-orders.html` | Order list + pick list | +| `06-order-detail.html` | Fulfillment detail | +| `07-promotions.html` | Campaigns & codes | +| `08-newsletter.html` | Composer / subscribers / campaigns | +| `09-reviews.html` | Moderation queue | +| `10-customers.html` | Customer CRM (new) | +| `11-categories.html` | Category tree (new) | +| `12-inventory.html` | Low stock & warehouse matrix (new) | +| `13-settings.html` | Store settings (new) | + +Shell reference: [`shell.html`](shell.html). + +## Visual direction + +- Cool gray background, white surfaces, hairline borders +- Accent `#FFBC39` only on CTAs, active nav, key alerts +- Dense tables, calm Inter scale for admin density +- Clean stroke SVG icons (sprite in `admin-k.js`) + +## Ops extras (mock) + +- **Bulk actions** on Orders: select rows → Collect / pick, Generate invoices, Generate labels +- **Multi-admin**: topbar shows signed-in admin; “Handled by” on lists; activity log on order detail; Team in Settings +- **Motion**: staggered page reveal, card lift, bulk-bar slide (respects `prefers-reduced-motion`) +- **Charts**: Chart.js animated bar chart on Dashboard (`cdn.jsdelivr.net` + `#ordersChart`) diff --git a/design/admin/assets/_shell-nav.html b/design/admin/assets/_shell-nav.html new file mode 100644 index 0000000..d88b61e --- /dev/null +++ b/design/admin/assets/_shell-nav.html @@ -0,0 +1,38 @@ + + diff --git a/design/admin/assets/admin-k.css b/design/admin/assets/admin-k.css new file mode 100644 index 0000000..c955ffc --- /dev/null +++ b/design/admin/assets/admin-k.css @@ -0,0 +1,1424 @@ +/* iLED Admin — K-design (Korean SaaS) tokens & components */ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --surface-muted: #f9fafb; + --text: #111827; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border: #e5e7eb; + --border-strong: #d1d5db; + --accent: #ffbc39; + --accent-hover: #f0a820; + --accent-soft: rgba(255, 188, 57, 0.14); + --accent-ink: #111827; + --focus: #ffbc39; + --success: #059669; + --success-soft: #ecfdf5; + --warning: #d97706; + --warning-soft: #fffbeb; + --danger: #dc2626; + --danger-soft: #fef2f2; + --info: #2563eb; + --info-soft: #eff6ff; + --shadow: 0 1px 2px rgba(17, 24, 39, 0.04), 0 1px 3px rgba(17, 24, 39, 0.06); + --shadow-md: 0 4px 12px rgba(17, 24, 39, 0.06); + --radius: 10px; + --radius-sm: 8px; + --radius-lg: 14px; + --nav-width: 240px; + --topbar-h: 56px; + --font: "Inter", ui-sans-serif, system-ui, sans-serif; + --ease: 160ms ease; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 15px; +} + +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: var(--bg); + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +input, +select, +textarea { + color: inherit; +} + +img { + max-width: 100%; + display: block; +} + +/* ——— Shell ——— */ +.app { + min-height: 100vh; + display: flex; +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: var(--nav-width); + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + z-index: 40; + transition: transform var(--ease); +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + height: var(--topbar-h); + padding: 0 20px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 15px; + letter-spacing: -0.01em; +} + +.sidebar-brand .mark { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--accent); + display: grid; + place-items: center; + font-size: 14px; +} + +.sidebar-nav { + flex: 1; + overflow: auto; + padding: 16px 12px 24px; +} + +.nav-group { + margin-bottom: 18px; +} + +.nav-group-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + padding: 4px 10px 8px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: var(--radius-sm); + font-size: 13.5px; + font-weight: 500; + color: var(--text-secondary); + transition: background var(--ease), color var(--ease); +} + +.nav-link:hover { + background: var(--surface-muted); + color: var(--text); +} + +.nav-link.is-active { + background: var(--accent-soft); + color: var(--text); +} + +.nav-link .ico, +.nav-link .icon { + width: 18px; + height: 18px; + opacity: 0.72; + flex-shrink: 0; +} + +.nav-link.is-active .ico, +.nav-link.is-active .icon { + opacity: 1; +} + +/* Lucide-style stroke icons */ +.ico { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; + display: block; +} + +.ico-sm { + width: 14px; + height: 14px; +} + +.ico-md { + width: 16px; + height: 16px; +} + +.sidebar-brand .mark { + color: var(--accent-ink); +} + +.sidebar-brand .mark .ico { + width: 16px; + height: 16px; + opacity: 1; +} + +.btn .ico { + width: 15px; + height: 15px; +} + +/* Admin identity */ +.admin-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 3px 10px 3px 3px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 12.5px; + font-weight: 600; +} + +.avatar { + width: 26px; + height: 26px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent-ink); + background: var(--accent); + flex-shrink: 0; +} + +.avatar-sm { + width: 22px; + height: 22px; + font-size: 9px; +} + +.avatar-muted { + background: #e5e7eb; + color: #4b5563; +} + +.handled-by { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: var(--text-secondary); + white-space: nowrap; +} + +.handled-by .name { + font-weight: 600; + color: var(--text); +} + +/* Bulk action bar */ +.bulk-bar { + display: none; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--accent-soft); +} + +.bulk-bar.is-visible { + display: flex; +} + +.bulk-bar .count { + font-size: 13px; + font-weight: 600; + margin-right: 4px; +} + +.activity-actor { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.sidebar-foot { + padding: 12px; + border-top: 1px solid var(--border); +} + +.main { + flex: 1; + margin-left: var(--nav-width); + min-width: 0; + display: flex; + flex-direction: column; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + height: var(--topbar-h); + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 24px; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.menu-btn { + display: none; + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius-sm); + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.locale { + display: flex; + gap: 2px; + padding: 3px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 999px; +} + +.locale button { + border: 0; + background: transparent; + padding: 4px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; +} + +.locale button.is-active { + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +.content { + padding: 28px 28px 48px; + max-width: 1280px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.page-header h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.page-purpose { + margin: 0; + color: var(--text-secondary); + font-size: 14px; +} + +.page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* ——— Overlay (mobile) ——— */ +.sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.35); + z-index: 35; +} + +body.nav-open .sidebar-backdrop { + display: block; +} + +/* ——— Surfaces ——— */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.card-pad { + padding: 18px 20px; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.card-head h2, +.card-title { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.grid { + display: grid; + gap: 16px; +} + +.grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +@media (max-width: 1100px) { + .grid-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .grid-4, + .grid-3, + .grid-2 { + grid-template-columns: 1fr; + } +} + +/* ——— KPI / alerts ——— */ +.alert-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} + +.alert-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + font-size: 12.5px; + font-weight: 600; + border: 1px solid var(--border); + background: var(--surface); + transition: border-color var(--ease), background var(--ease); +} + +.alert-pill:hover { + border-color: var(--border-strong); +} + +.alert-pill .dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.alert-pill.warn { + background: var(--warning-soft); + border-color: #fde68a; +} + +.alert-pill.danger { + background: var(--danger-soft); + border-color: #fecaca; +} + +.alert-pill.info { + background: var(--info-soft); + border-color: #bfdbfe; +} + +.alert-pill.ok { + background: var(--success-soft); + border-color: #a7f3d0; +} + +.kpi { + padding: 18px 20px; +} + +.kpi-label { + font-size: 12.5px; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 8px; +} + +.kpi-value { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; +} + +.kpi-meta { + margin-top: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.kpi a:hover .kpi-value { + color: var(--accent-hover); +} + +/* ——— Buttons ——— */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + transition: background var(--ease), border-color var(--ease), box-shadow var(--ease); + white-space: nowrap; +} + +.btn:focus-visible, +.nav-link:focus-visible, +.input:focus-visible, +.select:focus-visible, +.textarea:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.btn-primary { + background: var(--accent); + color: var(--accent-ink); + border-color: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +.btn-secondary:hover { + background: var(--surface-muted); +} + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-secondary); +} + +.btn-ghost:hover { + background: var(--surface-muted); + color: var(--text); +} + +.btn-danger { + background: var(--danger-soft); + color: var(--danger); + border-color: #fecaca; +} + +.btn-sm { + height: 30px; + padding: 0 10px; + font-size: 12.5px; +} + +.btn-icon { + width: 36px; + padding: 0; +} + +/* ——— Forms ——— */ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field label { + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); +} + +.field-hint { + font-size: 12px; + color: var(--text-muted); +} + +.input, +.select, +.textarea { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + transition: border-color var(--ease), box-shadow var(--ease); +} + +.textarea { + height: auto; + min-height: 110px; + padding: 10px 12px; + resize: vertical; +} + +.input:hover, +.select:hover, +.textarea:hover { + border-color: var(--border-strong); +} + +.input:focus, +.select:focus, +.textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + outline: none; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.toolbar .input, +.toolbar .select { + width: auto; + min-width: 160px; +} + +.toolbar-spacer { + flex: 1; +} + +.search-wrap { + position: relative; +} + +.search-wrap .input { + padding-left: 34px; + min-width: 220px; +} + +.search-wrap .search-icon { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; + width: 14px; + height: 14px; +} + +/* ——— Tabs ——— */ +.tabs { + display: flex; + gap: 2px; + padding: 4px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius); + width: fit-content; + margin-bottom: 16px; +} + +.tab { + border: 0; + background: transparent; + padding: 7px 14px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; +} + +.tab.is-active { + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +.tab-panel { + display: none; +} + +.tab-panel.is-active { + display: block; +} + +/* ——— Table ——— */ +.table-wrap { + overflow: auto; +} + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +table.data th { + text-align: left; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); + white-space: nowrap; +} + +table.data td { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +table.data tr:last-child td { + border-bottom: 0; +} + +table.data tbody tr { + transition: background var(--ease); +} + +table.data tbody tr:hover { + background: var(--surface-muted); +} + +table.data tbody tr.is-selected { + background: var(--accent-soft); +} + +.cell-muted { + color: var(--text-secondary); + font-size: 12.5px; +} + +.cell-strong { + font-weight: 600; +} + +.thumb { + width: 40px; + height: 40px; + border-radius: 8px; + background: linear-gradient(145deg, #f3f4f6, #e5e7eb); + border: 1px solid var(--border); + object-fit: cover; +} + +.thumb-lg { + width: 56px; + height: 56px; +} + +.row-product { + display: flex; + align-items: center; + gap: 12px; +} + +.check { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +/* ——— Badges ——— */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; +} + +.badge-neutral { + background: #f3f4f6; + color: #4b5563; +} + +.badge-success { + background: var(--success-soft); + color: var(--success); +} + +.badge-warning { + background: var(--warning-soft); + color: var(--warning); +} + +.badge-danger { + background: var(--danger-soft); + color: var(--danger); +} + +.badge-info { + background: var(--info-soft); + color: var(--info); +} + +.badge-accent { + background: var(--accent-soft); + color: #92400e; +} + +/* ——— Chart (CSS bars) ——— */ +.chart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 140px; + padding: 8px 4px 0; +} + +.chart-bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + height: 100%; + justify-content: flex-end; +} + +.chart-bar .bar { + width: 100%; + max-width: 28px; + border-radius: 6px 6px 2px 2px; + background: var(--accent); + opacity: 0.85; + min-height: 4px; + transition: opacity var(--ease); +} + +.chart-bar:hover .bar { + opacity: 1; +} + +.chart-bar .lbl { + font-size: 10px; + color: var(--text-muted); +} + +/* ——— Login ——— */ +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(ellipse 80% 60% at 50% -10%, var(--accent-soft), transparent), + var(--bg); +} + +.login-card { + width: 100%; + max-width: 400px; + padding: 32px; +} + +.login-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-weight: 700; + font-size: 18px; +} + +.login-sub { + color: var(--text-secondary); + font-size: 14px; + margin: 0 0 24px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 14px; +} + +.form-error { + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--danger-soft); + color: var(--danger); + font-size: 13px; + border: 1px solid #fecaca; +} + +/* ——— States ——— */ +.state-box { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); +} + +.state-box strong { + display: block; + color: var(--text); + margin-bottom: 6px; +} + +.skeleton { + background: linear-gradient(90deg, #f3f4f6 25%, #ebecef 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: shimmer 1.2s infinite; + border-radius: 6px; + height: 14px; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* ——— Split / detail ——— */ +.split { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 16px; + align-items: start; +} + +.settings-layout { + grid-template-columns: 220px 1fr; +} + +@media (max-width: 960px) { + .split, + .settings-layout { + grid-template-columns: 1fr; + } +} + +.section-block { + padding: 18px 20px; + border-bottom: 1px solid var(--border); +} + +.section-block:last-child { + border-bottom: 0; +} + +.section-block h3 { + margin: 0 0 14px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-muted); +} + +.timeline { + list-style: none; + margin: 0; + padding: 0; +} + +.timeline li { + position: relative; + padding: 0 0 16px 18px; + border-left: 2px solid var(--border); + margin-left: 6px; +} + +.timeline li:last-child { + padding-bottom: 0; + border-left-color: transparent; +} + +.timeline li::before { + content: ""; + position: absolute; + left: -5px; + top: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--surface); +} + +.timeline .t-title { + font-weight: 600; + font-size: 13.5px; +} + +.timeline .t-meta { + font-size: 12px; + color: var(--text-muted); +} + +.progress { + height: 6px; + background: var(--surface-muted); + border-radius: 999px; + overflow: hidden; +} + +.progress > span { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; +} + +.stars { + color: var(--accent); + letter-spacing: 1px; + font-size: 13px; +} + +.preview-pane { + background: var(--surface-muted); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + padding: 20px; + min-height: 220px; +} + +.tree-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid transparent; +} + +.tree-item:hover { + background: var(--surface-muted); +} + +.tree-item.is-active { + background: var(--accent-soft); + border-color: #fde68a; +} + +.tree-children { + margin-left: 22px; + border-left: 1px solid var(--border); + padding-left: 8px; +} + +.settings-nav { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; +} + +.settings-nav a { + padding: 9px 12px; + border-radius: var(--radius-sm); + font-size: 13.5px; + font-weight: 500; + color: var(--text-secondary); +} + +.settings-nav a.is-active, +.settings-nav a:hover { + background: var(--surface-muted); + color: var(--text); +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-top: 1px solid var(--border); + font-size: 13px; + color: var(--text-secondary); +} + +.pagination-btns { + display: flex; + gap: 6px; +} + +.tag { + display: inline-flex; + padding: 2px 8px; + border-radius: 6px; + background: var(--surface-muted); + border: 1px solid var(--border); + font-size: 12px; + color: var(--text-secondary); +} + +.flex { + display: flex; +} + +.gap-8 { + gap: 8px; +} + +.gap-12 { + gap: 12px; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.mt-16 { + margin-top: 16px; +} + +.mt-20 { + margin-top: 20px; +} + +.mb-16 { + margin-bottom: 16px; +} + +.text-right { + text-align: right; +} + +.w-full { + width: 100%; +} + +.muted { + color: var(--text-secondary); +} + +.hidden { + display: none !important; +} + +/* Gallery index */ +.gallery { + max-width: 960px; + margin: 0 auto; + padding: 48px 24px 80px; +} + +.gallery h1 { + margin: 0 0 8px; + font-size: 28px; + letter-spacing: -0.02em; +} + +.gallery-lead { + color: var(--text-secondary); + margin: 0 0 32px; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.gallery-card { + display: block; + padding: 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + transition: border-color var(--ease), box-shadow var(--ease); +} + +.gallery-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); +} + +.gallery-card .num { + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.06em; +} + +.gallery-card h2 { + margin: 6px 0 4px; + font-size: 16px; +} + +.gallery-card p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.gallery-card .new { + margin-left: 6px; + font-size: 10px; + font-weight: 700; + color: #92400e; + background: var(--accent-soft); + padding: 2px 6px; + border-radius: 999px; + vertical-align: middle; +} + +/* Mobile */ +@media (max-width: 900px) { + .sidebar { + transform: translateX(-100%); + } + + body.nav-open .sidebar { + transform: translateX(0); + } + + .main { + margin-left: 0; + } + + .menu-btn { + display: inline-flex; + } + + .content { + padding: 20px 16px 40px; + } + + .topbar { + padding: 0 14px; + } +} + +/* ——— Premium motion ——— */ +@media (prefers-reduced-motion: no-preference) { + .reveal { + opacity: 0; + transform: translateY(12px); + animation: reveal-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + .reveal-delay-1 { animation-delay: 0.06s; } + .reveal-delay-2 { animation-delay: 0.12s; } + .reveal-delay-3 { animation-delay: 0.18s; } + .reveal-delay-4 { animation-delay: 0.24s; } + .reveal-delay-5 { animation-delay: 0.3s; } + + @keyframes reveal-up { + to { + opacity: 1; + transform: translateY(0); + } + } + + .card { + transition: box-shadow var(--ease), border-color var(--ease), transform var(--ease); + } + + .card:hover { + box-shadow: var(--shadow-md); + } + + .kpi { + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow var(--ease); + } + + a.kpi:hover, + .kpi.interactive:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + .btn { + transition: background var(--ease), border-color var(--ease), box-shadow var(--ease), transform 140ms ease; + } + + .btn:active { + transform: scale(0.98); + } + + .nav-link { + transition: background 180ms ease, color 180ms ease, padding-left 180ms ease; + } + + .nav-link.is-active { + padding-left: 14px; + } + + .bulk-bar { + animation: bulk-in 0.28s cubic-bezier(0.22, 1, 0.36, 1); + } + + @keyframes bulk-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .sidebar { + transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .sidebar-backdrop { + animation: fade-in 0.2s ease; + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + table.data tbody tr { + transition: background 160ms ease; + } + + .alert-pill { + transition: transform 160ms ease, border-color var(--ease), background var(--ease); + } + + .alert-pill:hover { + transform: translateY(-1px); + } +} + +.chart-canvas-wrap { + position: relative; + height: 200px; + width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + animation: none; + } +} diff --git a/design/admin/assets/admin-k.js b/design/admin/assets/admin-k.js new file mode 100644 index 0000000..d488a41 --- /dev/null +++ b/design/admin/assets/admin-k.js @@ -0,0 +1,245 @@ +(function () { + "use strict"; + + function qs(sel, root) { + return (root || document).querySelector(sel); + } + + function qsa(sel, root) { + return Array.prototype.slice.call((root || document).querySelectorAll(sel)); + } + + /* Inject clean stroke icon sprite once */ + if (!qs("#admin-icon-sprite")) { + var sprite = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + sprite.id = "admin-icon-sprite"; + sprite.setAttribute("aria-hidden", "true"); + sprite.style.cssText = + "position:absolute;width:0;height:0;overflow:hidden"; + sprite.innerHTML = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join(""); + document.body.insertBefore(sprite, document.body.firstChild); + } + + /* Mobile nav */ + var menuBtn = qs("[data-menu-toggle]"); + var closeBtns = qsa("[data-menu-close]"); + + function openNav() { + document.body.classList.add("nav-open"); + } + + function closeNav() { + document.body.classList.remove("nav-open"); + } + + if (menuBtn) { + menuBtn.addEventListener("click", openNav); + } + closeBtns.forEach(function (btn) { + btn.addEventListener("click", closeNav); + }); + + /* Tabs */ + qsa("[data-tabs]").forEach(function (root) { + var tabs = qsa(".tab", root); + var host = root.closest("[data-tabs-host]") || root.parentElement; + var panels = qsa(".tab-panel", host); + + tabs.forEach(function (tab) { + tab.addEventListener("click", function () { + var id = tab.getAttribute("data-tab"); + tabs.forEach(function (t) { + t.classList.toggle("is-active", t === tab); + }); + panels.forEach(function (p) { + p.classList.toggle("is-active", p.getAttribute("data-panel") === id); + }); + }); + }); + }); + + /* Locale switcher */ + qsa(".locale").forEach(function (group) { + qsa("button", group).forEach(function (btn) { + btn.addEventListener("click", function () { + qsa("button", group).forEach(function (b) { + b.classList.remove("is-active"); + }); + btn.classList.add("is-active"); + }); + }); + }); + + /* Row select + bulk bar */ + function updateBulkBar(scope) { + var root = scope || document; + var bars = qsa("[data-bulk-bar]", root); + bars.forEach(function (bar) { + var card = bar.closest(".card") || document; + var checked = qsa( + 'tbody input[type="checkbox"]:checked', + card + ).length; + bar.classList.toggle("is-visible", checked > 0); + var countEl = qs("[data-bulk-count]", bar); + if (countEl) { + countEl.textContent = + checked + (checked === 1 ? " selected" : " selected"); + } + }); + } + + qsa("[data-select-all]").forEach(function (master) { + master.addEventListener("change", function () { + var table = master.closest("table") || master.closest(".card"); + qsa('tbody input[type="checkbox"]', table).forEach(function (cb) { + cb.checked = master.checked; + var tr = cb.closest("tr"); + if (tr) tr.classList.toggle("is-selected", master.checked); + }); + updateBulkBar(master.closest(".card")); + }); + }); + + qsa('table.data tbody input[type="checkbox"]').forEach(function (cb) { + cb.addEventListener("change", function () { + var tr = cb.closest("tr"); + if (tr) tr.classList.toggle("is-selected", cb.checked); + updateBulkBar(cb.closest(".card")); + }); + }); + + updateBulkBar(); + + /* Settings section nav */ + function activateSettings(href) { + if (!href || href.charAt(0) !== "#") return; + qsa("[data-settings-nav] a").forEach(function (a) { + a.classList.toggle("is-active", a.getAttribute("href") === href); + }); + qsa("[data-settings-section]").forEach(function (sec) { + sec.classList.toggle("hidden", "#" + sec.id !== href); + }); + } + + qsa("[data-settings-nav] a").forEach(function (link) { + link.addEventListener("click", function (e) { + var href = link.getAttribute("href"); + if (!href || href.charAt(0) !== "#") return; + e.preventDefault(); + activateSettings(href); + if (history.replaceState) { + history.replaceState(null, "", href); + } + }); + }); + + if (qsa("[data-settings-nav]").length && location.hash) { + activateSettings(location.hash); + } + + /* Stagger reveal on page load */ + qsa(".content > *").forEach(function (el, i) { + if (el.classList.contains("reveal")) return; + el.classList.add("reveal"); + if (i > 0 && i < 6) { + el.classList.add("reveal-delay-" + Math.min(i, 5)); + } + }); + + /* Chart.js dashboard chart */ + var chartCanvas = qs("#ordersChart"); + if (chartCanvas && typeof Chart !== "undefined") { + new Chart(chartCanvas, { + type: "bar", + data: { + labels: [ + "04", "05", "06", "07", "08", "09", "10", + "11", "12", "13", "14", "15", "16", "17", + ], + datasets: [ + { + label: "Orders", + data: [4, 6, 5, 8, 7, 10, 9, 5, 7, 11, 9, 13, 10, 14], + backgroundColor: "rgba(255, 188, 57, 0.85)", + hoverBackgroundColor: "rgba(240, 168, 32, 1)", + borderRadius: 6, + borderSkipped: false, + maxBarThickness: 28, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 900, easing: "easeOutQuart" }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#111827", + padding: 10, + cornerRadius: 8, + displayColors: false, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { + color: "#9ca3af", + font: { size: 10, family: "Inter" }, + }, + border: { display: false }, + }, + y: { + beginAtZero: true, + grid: { color: "#f3f4f6" }, + ticks: { + color: "#9ca3af", + font: { size: 11, family: "Inter" }, + precision: 0, + }, + border: { display: false }, + }, + }, + }, + }); + } + + /* Dashboard state demos */ + if (location.search.indexOf("empty=1") !== -1) { + qsa("[data-demo-content]").forEach(function (el) { + el.classList.add("hidden"); + }); + qsa("[data-demo-empty]").forEach(function (el) { + el.classList.remove("hidden"); + }); + } + + if (location.search.indexOf("loading=1") !== -1) { + qsa("[data-demo-content]").forEach(function (el) { + el.classList.add("hidden"); + }); + qsa("[data-demo-loading]").forEach(function (el) { + el.classList.remove("hidden"); + }); + } +})(); diff --git a/design/admin/index.html b/design/admin/index.html new file mode 100644 index 0000000..7117476 --- /dev/null +++ b/design/admin/index.html @@ -0,0 +1,93 @@ + + + + + + iLED Admin — K-design mocks + + + + + + + diff --git a/design/admin/shell.html b/design/admin/shell.html new file mode 100644 index 0000000..9733783 --- /dev/null +++ b/design/admin/shell.html @@ -0,0 +1,74 @@ + + + + + + Shell reference — iLED Admin + + + + +
+ +
+
+
+ + Shell reference +
+
+
+
AK Anna K.
+
+
+
+ +
+

Use this layout on every authenticated page. Nav groups: Ops, Catalog, People, Marketing, System.

+
+
+
+
+ + + diff --git a/src/admin/package-lock.json b/src/admin/package-lock.json index cf04a1d..dd30a88 100644 --- a/src/admin/package-lock.json +++ b/src/admin/package-lock.json @@ -16,6 +16,7 @@ "@angular/router": "^21.0.0", "@lucide/angular": "^1.23.0", "@tailwindcss/postcss": "^4.3.2", + "chart.js": "^4.5.1", "postcss": "^8.5.16", "rxjs": "~7.8.0", "tailwindcss": "^4.3.2", @@ -2094,6 +2095,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -4785,6 +4792,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/src/admin/package.json b/src/admin/package.json index c9d4ca9..e1eaa84 100644 --- a/src/admin/package.json +++ b/src/admin/package.json @@ -31,6 +31,7 @@ "@angular/router": "^21.0.0", "@lucide/angular": "^1.23.0", "@tailwindcss/postcss": "^4.3.2", + "chart.js": "^4.5.1", "postcss": "^8.5.16", "rxjs": "~7.8.0", "tailwindcss": "^4.3.2", diff --git a/src/admin/src/app/app.routes.ts b/src/admin/src/app/app.routes.ts index 6234344..5b7bdb7 100644 --- a/src/admin/src/app/app.routes.ts +++ b/src/admin/src/app/app.routes.ts @@ -21,10 +21,17 @@ export const routes: Routes = [ { path: 'dashboard', loadComponent: () => - import('./features/admin-pages.component').then( + import('./features/dashboard/dashboard.component').then( (m) => m.DashboardComponent, ), }, + { + path: 'analytics', + loadComponent: () => + import('./features/analytics/analytics-admin.component').then( + (m) => m.AnalyticsAdminComponent, + ), + }, { path: 'products', loadComponent: () => @@ -39,6 +46,34 @@ export const routes: Routes = [ (m) => m.OrdersAdminComponent, ), }, + { + path: 'inventory', + loadComponent: () => + import('./features/inventory/inventory-admin.component').then( + (m) => m.InventoryAdminComponent, + ), + }, + { + path: 'categories', + loadComponent: () => + import('./features/categories/categories-admin.component').then( + (m) => m.CategoriesAdminComponent, + ), + }, + { + path: 'customers', + loadComponent: () => + import('./features/customers/customers-admin.component').then( + (m) => m.CustomersAdminComponent, + ), + }, + { + path: 'settings', + loadComponent: () => + import('./features/settings/settings-admin.component').then( + (m) => m.SettingsAdminComponent, + ), + }, { path: 'promotions', loadComponent: () => diff --git a/src/admin/src/app/core/i18n/locale-switcher.component.ts b/src/admin/src/app/core/i18n/locale-switcher.component.ts index f239849..ee4c52c 100644 --- a/src/admin/src/app/core/i18n/locale-switcher.component.ts +++ b/src/admin/src/app/core/i18n/locale-switcher.component.ts @@ -8,27 +8,25 @@ import { TranslatePipe } from './translate.pipe'; standalone: true, imports: [TranslatePipe], template: ` - - + `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class LocaleSwitcherComponent { readonly i18n = inject(I18nService); - handleChange(event: Event): void { - const value = (event.target as HTMLSelectElement).value as LocaleCode; - this.i18n.setLocale(value); + handleSelect(code: LocaleCode): void { + this.i18n.setLocale(code); } } diff --git a/src/admin/src/app/core/i18n/translations/cs.ts b/src/admin/src/app/core/i18n/translations/cs.ts index aded945..4a98c7e 100644 --- a/src/admin/src/app/core/i18n/translations/cs.ts +++ b/src/admin/src/app/core/i18n/translations/cs.ts @@ -5,6 +5,7 @@ export const CS_TRANSLATIONS: TranslationMap = { 'a11y.langSwitcher': 'Vyberte jazyk', 'a11y.mainNav': 'Navigace administrace', 'nav.dashboard': 'Dashboard', + 'nav.analytics': 'Analytika', 'nav.products': 'Produkty', 'nav.orders': 'Objednávky', 'nav.promotions': 'Akce', @@ -14,22 +15,35 @@ export const CS_TRANSLATIONS: TranslationMap = { 'nav.logout': 'Odhlásit se', 'nav.openMenu': 'Otevřít menu', 'nav.closeMenu': 'Zavřít menu', + 'nav.groupOps': 'Provoz', + 'nav.groupCatalog': 'Katalog', + 'nav.groupPeople': 'Zákazníci', + 'nav.groupMarketing': 'Marketing', + 'nav.groupSystem': 'Systém', + 'nav.inventory': 'Sklad', + 'nav.categories': 'Kategorie', + 'nav.customers': 'Zákazníci', + 'nav.settings': 'Nastavení', + 'common.open': 'Otevřít', 'common.search': 'Hledat', 'common.apply': 'Použít', 'common.all': 'Vše', 'common.page': 'Stránka', + 'common.showing': 'Zobrazeno {{from}}–{{to}} z {{total}}', 'common.prev': 'Předchozí', 'common.next': 'Další', 'common.close': 'Zavřít', 'common.saved': 'Uloženo', 'common.deleted': 'Smazáno', 'auth.loginTitle': 'Přihlášení admin', + 'auth.subtitle': 'Přihlaste se pro správu katalogu, objednávek a marketingu.', 'auth.email': 'E-mail', 'auth.password': 'Heslo', 'auth.login': 'Přihlásit se', 'auth.errorRole': 'Jsou vyžadována oprávnění administrátora.', 'auth.errorLogin': 'Přihlášení selhalo.', 'dashboard.title': 'Dashboard', + 'dashboard.purpose': 'Co dnes vyžaduje pozornost — objednávky, sklad a recenze.', 'dashboard.ordersToday': 'Objednávky dnes', 'dashboard.revenueToday': 'Tržby dnes', 'dashboard.lowStock': 'Nízký stav', @@ -37,10 +51,22 @@ export const CS_TRANSLATIONS: TranslationMap = { 'dashboard.chartTitle': 'Nové objednávky (14 dní)', 'dashboard.chartOrders': 'Objednávky', 'dashboard.errorLoad': 'Nepodařilo se načíst dashboard', + 'dashboard.alertLowStock': 'SKU s nízkým stavem', + 'dashboard.alertPending': 'čeká na platbu', + 'orders.bulkSelected': 'Vybráno {count}', + 'orders.bulkPick': 'Kompletovat / pick', + 'orders.bulkInvoices': 'Generovat faktury', + 'orders.bulkLabels': 'Generovat štítky', + 'orders.bulkLabelsSoon': 'Hromadné štítky vygenerují přepravní štítky pro výběr.', + 'orders.selectAll': 'Vybrat vše', + 'orders.selectRow': 'Vybrat objednávku', 'products.title': 'Produkty', 'products.add': 'Přidat produkt', 'products.edit': 'Upravit', + 'products.editTitle': 'Upravit produkt', + 'products.formPurpose': 'Sekční editor pro rychlejší práci s katalogem.', 'products.save': 'Uložit', + 'products.saveProduct': 'Uložit produkt', 'products.cancel': 'Zrušit', 'products.name': 'Název', 'products.slug': 'Slug', @@ -54,12 +80,20 @@ export const CS_TRANSLATIONS: TranslationMap = { 'products.active': 'Aktivní', 'products.shortDesc': 'Krátký popis', 'products.description': 'Popis', - 'products.tabProducts': 'Seznam produktů', + 'products.tabProducts': 'Produkty', 'products.tabWarehouses': 'Sklady', - 'products.tabGroups': 'Kategorie produktů', + 'products.tabGroups': 'Skupiny', + 'products.tabBasics': 'Základ', + 'products.tabLocales': 'Jazyky', + 'products.tabMedia': 'Média', + 'products.tabStock': 'Sklad', + 'products.tabChannels': 'Kanály', + 'products.tabParams': 'Parametry', 'products.model': 'Model', 'products.ean': 'EAN', 'products.group': 'Skupina', + 'products.category': 'Kategorie', + 'products.categoryHint': 'Propojeno s kategoriemi.', 'products.noGroup': '— bez skupiny —', 'products.productCode': 'Kód produktu', 'products.lowestPrice': 'Nejnižší cena 30 dní', @@ -69,20 +103,26 @@ export const CS_TRANSLATIONS: TranslationMap = { 'products.close': 'Zavřít', 'products.loading': 'Načítání…', 'products.sectionBasic': 'Základní údaje', + 'products.sectionIdentity': 'Identita', + 'products.sectionPricing': 'Cena a katalog', 'products.sectionNames': 'Názvy', 'products.sectionShortDesc': 'Krátké popisy', 'products.sectionDesc': 'Plné popisy', 'products.sectionParams': 'Parametry', - 'products.sectionStock': 'Skladové zásoby', + 'products.sectionStock': 'Podle skladu', 'products.sectionImages': 'Obrázky', + 'products.locale.pl': 'Polština (PL)', + 'products.locale.en': 'Angličtina (EN)', + 'products.locale.de': 'Němčina (DE)', + 'products.locale.cs': 'Čeština (CS)', 'products.image': 'Obrázek', 'products.imageUrl': 'URL obrázku', 'products.imageAlt': 'Alternativní text', 'products.addImage': 'Přidat obrázek', 'products.primaryImage': 'Hlavní', 'products.imageHint': 'Nahrát soubor (převeden na WebP) nebo zadat URL / cestu obchodu', - 'products.uploadImage': 'Nahrát obrázek', - 'products.uploadHint': 'JPG, PNG, GIF, WebP, BMP — max. 10 MB, konverze na WebP', + 'products.uploadImage': 'Nahrát', + 'products.uploadHint': 'Přetáhněte soubor nebo vložte URL — JPG, PNG, WebP, max. 10 MB', 'products.uploading': 'Nahrávání…', 'products.errorUpload': 'Nepodařilo se nahrát obrázek', 'products.noImages': 'Žádné obrázky', @@ -97,19 +137,24 @@ export const CS_TRANSLATIONS: TranslationMap = { 'products.errorLoad': 'Nepodařilo se načíst produkt', 'products.errorList': 'Nepodařilo se načíst produkty', 'products.errorSave': 'Nepodařilo se uložit produkt', - 'products.searchPlaceholder': 'Název, slug, model, SKU…', - 'products.activeOnly': 'Jen aktivní', - 'products.inactiveOnly': 'Jen neaktivní', + 'products.searchPlaceholder': 'Hledat název, SKU, EAN…', + 'products.allStatuses': 'Všechny stavy', + 'products.allGroups': 'Všechny skupiny', + 'products.allCategories': 'Všechny kategorie', + 'products.sortName': 'Řadit: Název', + 'products.sortPrice': 'Řadit: Cena', + 'products.sortStock': 'Řadit: Sklad', + 'products.colProduct': 'Produkt', + 'products.colSkuEan': 'SKU / EAN', + 'products.colStock': 'Sklad', + 'products.colReserved': 'Rezervováno', + 'products.colStatus': 'Stav', + 'products.groupsHint': 'Preferujete strukturu obchodu? Spravujte', + 'products.activeOnly': 'Aktivní', + 'products.inactiveOnly': 'Neaktivní', 'products.lowStockOnly': 'Nízký stav', 'products.inactive': 'Neaktivní', 'products.empty': 'Žádné produkty', - 'categories.title': 'Kategorie', - 'categories.name': 'Název', - 'categories.slug': 'Slug', - 'categories.add': 'Přidat kategorii', - 'categories.empty': 'Žádné kategorie', - 'categories.errorLoad': 'Nepodařilo se načíst kategorie', - 'categories.errorSave': 'Nepodařilo se uložit kategorii', 'banner.title': 'Promo banner', 'banner.text': 'Text banneru', 'banner.code': 'Zvýrazněný kód', @@ -237,12 +282,22 @@ export const CS_TRANSLATIONS: TranslationMap = { 'promotions.codesFor': 'Kódy vybrané akce', 'promotions.codeOptional': 'Kód (volitelně)', 'promotions.maxUsages': 'Max. použití', - 'promotions.generateCode': 'Vygenerovat kód', + 'promotions.generateCode': 'Generovat', + 'promotions.copy': 'Kopírovat', + 'promotions.codeCopied': 'Kód zkopírován', 'promotions.noCodes': 'Zatím žádné kódy', + 'promotions.searchPlaceholder': 'Hledat akce…', + 'promotions.window': 'Období', + 'promotions.validFrom': 'Platné od', + 'promotions.validTo': 'Platné do', + 'promotions.detailPrompt': 'Vyberte akci k úpravě', + 'promotions.kpiActive': 'Aktivní akce', + 'promotions.kpiRedemptions': 'Uplatnění (stránka)', + 'promotions.kpiCodes': 'Kódy (stránka)', 'promotions.sectionBenefit': 'Výhoda', 'promotions.sectionRequirements': 'Požadavky', 'promotions.sectionLimits': 'Limity použití', - 'promotions.sectionBanner': 'Banner', + 'promotions.sectionBanner': 'Banner (obchod)', 'promotions.sectionReward': 'Odměna po nákupu', 'promotions.showInBanner': 'Zobrazit v banneru obchodu', 'promotions.issueReward': 'Vydat jednorázový kód po zaplacení', @@ -258,8 +313,27 @@ export const CS_TRANSLATIONS: TranslationMap = { 'newsletter.subject': 'Předmět', 'newsletter.body': 'HTML obsah', 'newsletter.send': 'Odeslat kampaň', + 'newsletter.sendTo': 'Odeslat', 'newsletter.subscribers': 'Odběratelé', 'newsletter.campaigns': 'Kampaně', + 'newsletter.tabCompose': 'Sestavit', + 'newsletter.composer': 'Editor kampaně', + 'newsletter.preview': 'Náhled', + 'newsletter.from': 'Od', + 'newsletter.schedule': 'Plán (volitelné)', + 'newsletter.scheduleHint': + 'Nechte prázdné pro odeslání hned po potvrzení.', + 'newsletter.saveDraft': 'Uložit koncept', + 'newsletter.draftSaved': 'Koncept uložen', + 'newsletter.export': 'Export odběratelů', + 'newsletter.searchEmail': 'Hledat e-mail…', + 'newsletter.searchCampaign': 'Hledat kampaně…', + 'newsletter.joined': 'Připojen', + 'newsletter.sent': 'Odesláno', + 'newsletter.unsubscribed': 'Odhlášen', + 'newsletter.status.sent': 'Odesláno', + 'newsletter.status.draft': 'Koncept', + 'newsletter.status.scheduled': 'Naplánováno', 'newsletter.confirmSend': 'Odeslat tuto kampaň všem aktivním odběratelům?', 'newsletter.sentOk': 'Kampaň odeslána {{count}} odběratelům', @@ -270,6 +344,12 @@ export const CS_TRANSLATIONS: TranslationMap = { 'reviews.title': 'Recenze', 'reviews.approve': 'Schválit', 'reviews.reject': 'Zamítnout', + 'reviews.approveReply': 'Schválit + odpovědět', + 'reviews.bulkApprove': 'Schválit výběr', + 'reviews.allStars': 'Všechny hvězdy', + 'reviews.searchPlaceholder': 'Hledat obsah nebo autora…', + 'reviews.replyLabel': 'Veřejná odpověď (volitelné)', + 'reviews.replyPlaceholder': 'Děkujeme za recenzi…', 'reviews.status': 'Stav', 'reviews.status.pending': 'Čekající', 'reviews.status.approved': 'Schválené', @@ -277,4 +357,169 @@ export const CS_TRANSLATIONS: TranslationMap = { 'reviews.empty': 'Žádné recenze', 'reviews.errorLoad': 'Nepodařilo se načíst recenze', 'reviews.errorSave': 'Nepodařilo se aktualizovat recenzi', + 'customers.title': 'Zákazníci', + 'customers.purpose': 'Najděte kupující a prohlédněte historii objednávek.', + 'customers.searchPlaceholder': 'E-mail nebo jméno…', + 'customers.loading': 'Načítání zákazníků…', + 'customers.empty': 'Zatím žádní zákazníci z objednávek.', + 'customers.errorLoad': 'Nepodařilo se načíst zákazníky', + 'customers.colCustomer': 'Zákazník', + 'customers.colOrders': 'Objednávky', + 'customers.colLtv': 'LTV', + 'customers.colLastOrder': 'Poslední', + 'customers.colOrder': 'Objednávka', + 'customers.colTotal': 'Celkem', + 'customers.showing': 'Zobrazeno', + 'customers.email': 'E-mail', + 'customers.orderHistory': 'Historie objednávek', + 'customers.selectPrompt': 'Vyberte zákazníka pro detaily.', + 'categories.title': 'Kategorie', + 'categories.purpose': + 'Struktura katalogu. Produkty přiřadíte ve formuláři produktu.', + 'categories.add': 'Přidat kategorii', + 'categories.addRoot': 'Přidat kořenovou kategorii', + 'categories.addChild': 'Přidat podkategorii', + 'categories.list': 'Kategorie', + 'categories.tree': 'Strom', + 'categories.create': 'Nová kategorie', + 'categories.createRoot': 'Nová kořenová kategorie', + 'categories.createChild': 'Nová podkategorie', + 'categories.edit': 'Upravit kategorii', + 'categories.name': 'Název', + 'categories.slug': 'Slug', + 'categories.description': 'Popis', + 'categories.parent': 'Nadřazená', + 'categories.root': '— Kořen (bez rodiče) —', + 'categories.rootHint': + 'Kořen pro kategorii nejvyšší úrovně. Podkategorie patří pod rodiče.', + 'categories.toggle': 'Rozbalit nebo sbalit', + 'categories.viewProducts': 'Produkty', + 'categories.loading': 'Načítání kategorií…', + 'categories.empty': + 'Žádné kategorie. Přidejte kořenovou kategorii a začněte strom.', + 'categories.errorLoad': 'Nepodařilo se načíst kategorie', + 'categories.errorSave': 'Nepodařilo se uložit kategorii', + 'categories.errorDelete': 'Nepodařilo se smazat kategorii', + 'categories.errorValidation': 'Název a slug jsou povinné', + 'inventory.title': 'Sklad', + 'inventory.purpose': 'Stav skladu — nejdřív alerty, pak matice.', + 'inventory.thresholdLink': 'Výchozí práh', + 'inventory.alertLow': 'SKU na/pod prahem', + 'inventory.alertReserved': 'ks rezervovaných v otevřených objednávkách', + 'inventory.tabAlerts': 'Nízký stav', + 'inventory.tabMatrix': 'Matice skladů', + 'inventory.loading': 'Načítání skladu…', + 'inventory.noLowStock': 'Žádná SKU s nízkým stavem.', + 'inventory.colProduct': 'Produkt', + 'inventory.colAvailable': 'Dostupné', + 'inventory.colReserved': 'Rezervováno', + 'inventory.colThreshold': 'Práh', + 'inventory.restock': 'Doplnit', + 'inventory.product': 'Produkt', + 'inventory.adjust': 'Upravit', + 'inventory.searchPlaceholder': 'Filtrovat SKU…', + 'inventory.availableHint': 'Dostupné = stav − rezervace', + 'inventory.quickAdjust': 'Rychlá úprava', + 'inventory.warehouse': 'Sklad', + 'inventory.newQty': 'Nové množství', + 'inventory.reason': 'Důvod', + 'inventory.reasonPlaceholder': 'Dodávka přijata…', + 'inventory.errorLoad': 'Nepodařilo se načíst sklad', + 'inventory.errorSave': 'Nepodařilo se aktualizovat stav', + 'settings.title': 'Nastavení', + 'settings.purpose': + 'Konfigurace obchodu — lokálně, dokud nebudou API nastavení.', + 'settings.save': 'Uložit změny', + 'settings.navStore': 'Profil obchodu', + 'settings.navLocales': 'Jazyky a měna', + 'settings.navInventory': 'Sklad', + 'settings.navShipping': 'Doprava', + 'settings.navIntegrations': 'Integrace', + 'settings.navEmail': 'E-mail', + 'settings.navAccount': 'Můj účet', + 'settings.integrationsTitle': 'Marketplace a faktury', + 'settings.integrationsHint': + 'Nastavení Allegro, Amazon a iFirma. Sync objednávek a auto-faktury použijí tato nastavení po zapnutí konektorů.', + 'settings.secretSaved': 'uloženo', + 'settings.allegroClientId': 'Allegro client ID', + 'settings.allegroClientSecret': 'Allegro client secret', + 'settings.allegroSellerId': 'Allegro seller ID', + 'settings.amazonSellerId': 'Amazon seller ID', + 'settings.amazonMarketplaceId': 'Amazon marketplace ID', + 'settings.amazonAccessKey': 'Amazon access key', + 'settings.amazonSecretKey': 'Amazon secret key', + 'settings.ifirmaLogin': 'iFirma login', + 'settings.ifirmaApiKey': 'iFirma API klíč', + 'settings.ifirmaInvoiceSeries': 'Řada faktur', + 'settings.ifirmaAutoInvoice': 'Automaticky vystavovat faktury pro zaplacené objednávky', + 'analytics.title': 'Analytika', + 'analytics.purpose': + 'Co se prodává, tržby vs předchozí období a mix kanálů.', + 'analytics.days7': 'Posledních 7 dní', + 'analytics.days30': 'Posledních 30 dní', + 'analytics.days90': 'Posledních 90 dní', + 'analytics.allChannels': 'Všechny kanály', + 'analytics.kpiRevenue': 'Tržby', + 'analytics.kpiOrders': 'Objednávky', + 'analytics.kpiAov': 'Prům. hodnota objednávky', + 'analytics.kpiUnits': 'Prodané kusy', + 'analytics.vsPrevious': 'vs předchozí období', + 'analytics.trendTitle': 'Tržby a objednávky', + 'analytics.byChannel': 'Podle kanálu', + 'analytics.channel': 'Kanál', + 'analytics.topProducts': 'Nejlepší produkty', + 'analytics.topProductsHint': 'Podle tržeb ve zvoleném období', + 'analytics.colProduct': 'Produkt', + 'analytics.colUnits': 'Kusy', + 'analytics.colShare': 'Podíl', + 'analytics.empty': 'Žádná data prodeje v tomto období.', + 'analytics.errorLoad': 'Nepodařilo se načíst analytiku', + 'orders.channel': 'Kanál', + 'orders.allChannels': 'Všechny kanály', + 'orders.source.shop': 'Obchod', + 'orders.source.allegro': 'Allegro', + 'orders.source.amazon': 'Amazon', + 'orders.source.manual': 'Ruční', + 'orders.sourceShort.shop': 'Obchod', + 'orders.sourceShort.allegro': 'Allegro', + 'orders.sourceShort.amazon': 'Amazon', + 'orders.sourceShort.manual': 'Ruční', + 'orders.carrierShort.inPost': 'InPost', + 'orders.carrierShort.dhl': 'DHL', + 'orders.carrierShort.dpd': 'DPD', + 'orders.serviceShort.parcelLocker': 'Box', + 'orders.serviceShort.courier': 'Kurýr', + 'products.sectionChannels': 'Ceny kanálů', + 'products.channelPricesHint': + 'Cena pro každý shop (storefront, Allegro, Amazon). Zapněte kanál, když se má nabídka synchronizovat.', + 'products.channel': 'Kanál', + 'products.externalSku': 'Externí SKU', + 'products.externalOffer': 'Nabídka / ASIN', + 'settings.storeProfile': 'Profil obchodu', + 'settings.storeName': 'Název obchodu', + 'settings.supportEmail': 'Support e-mail', + 'settings.storefrontUrl': 'URL obchodu', + 'settings.legalName': 'Obchodní název', + 'settings.localesTitle': 'Jazyky a měna', + 'settings.defaultLocale': 'Výchozí jazyk', + 'settings.currency': 'Měna', + 'settings.enabledLocales': 'Povolené jazyky', + 'settings.inventoryTitle': 'Výchozí skladu', + 'settings.lowStockThreshold': 'Výchozí práh nízkého stavu', + 'settings.lowStockHint': + 'Když produkt nemá vlastní práh. Řídí alerty Dashboardu a Skladu.', + 'settings.shippingTitle': 'Dopravci', + 'settings.carrier': 'Dopravce', + 'settings.enabled': 'Zapnuto', + 'settings.mode': 'Režim', + 'settings.sandbox': 'Sandbox', + 'settings.shippingHint': + 'Přihlašovací údaje zůstávají v konfiguraci serveru — zde jen flagy viditelnosti.', + 'settings.emailTitle': 'E-mail / newsletter', + 'settings.fromName': 'Jméno odesílatele', + 'settings.fromAddress': 'Adresa odesílatele', + 'settings.replyTo': 'Reply-to', + 'settings.accountTitle': 'Můj účet', + 'settings.accountHint': + 'Účet používá přihlášenou admin session. Změna hesla přijde s API nastavení.', }; diff --git a/src/admin/src/app/core/i18n/translations/de.ts b/src/admin/src/app/core/i18n/translations/de.ts index dd4d3e4..cd8fba1 100644 --- a/src/admin/src/app/core/i18n/translations/de.ts +++ b/src/admin/src/app/core/i18n/translations/de.ts @@ -5,6 +5,7 @@ export const DE_TRANSLATIONS: TranslationMap = { 'a11y.langSwitcher': 'Sprache wählen', 'a11y.mainNav': 'Admin-Navigation', 'nav.dashboard': 'Dashboard', + 'nav.analytics': 'Analysen', 'nav.products': 'Produkte', 'nav.orders': 'Bestellungen', 'nav.promotions': 'Aktionen', @@ -14,22 +15,35 @@ export const DE_TRANSLATIONS: TranslationMap = { 'nav.logout': 'Abmelden', 'nav.openMenu': 'Menü öffnen', 'nav.closeMenu': 'Menü schließen', + 'nav.groupOps': 'Betrieb', + 'nav.groupCatalog': 'Katalog', + 'nav.groupPeople': 'Kunden', + 'nav.groupMarketing': 'Marketing', + 'nav.groupSystem': 'System', + 'nav.inventory': 'Lager', + 'nav.categories': 'Kategorien', + 'nav.customers': 'Kunden', + 'nav.settings': 'Einstellungen', + 'common.open': 'Öffnen', 'common.search': 'Suchen', 'common.apply': 'Anwenden', 'common.all': 'Alle', 'common.page': 'Seite', + 'common.showing': 'Angezeigt {{from}}–{{to}} von {{total}}', 'common.prev': 'Zurück', 'common.next': 'Weiter', 'common.close': 'Schließen', 'common.saved': 'Gespeichert', 'common.deleted': 'Gelöscht', 'auth.loginTitle': 'Admin-Anmeldung', + 'auth.subtitle': 'Anmelden, um Katalog, Bestellungen und Marketing zu verwalten.', 'auth.email': 'E-Mail', 'auth.password': 'Passwort', 'auth.login': 'Anmelden', 'auth.errorRole': 'Administratorrechte erforderlich.', 'auth.errorLogin': 'Anmeldung fehlgeschlagen.', 'dashboard.title': 'Dashboard', + 'dashboard.purpose': 'Was heute Aufmerksamkeit braucht — Bestellungen, Bestand, Bewertungen.', 'dashboard.ordersToday': 'Bestellungen heute', 'dashboard.revenueToday': 'Umsatz heute', 'dashboard.lowStock': 'Niedriger Bestand', @@ -37,10 +51,22 @@ export const DE_TRANSLATIONS: TranslationMap = { 'dashboard.chartTitle': 'Neue Bestellungen (14 Tage)', 'dashboard.chartOrders': 'Bestellungen', 'dashboard.errorLoad': 'Dashboard konnte nicht geladen werden', + 'dashboard.alertLowStock': 'SKU mit niedrigem Bestand', + 'dashboard.alertPending': 'warten auf Zahlung', + 'orders.bulkSelected': '{count} ausgewählt', + 'orders.bulkPick': 'Kommissionieren', + 'orders.bulkInvoices': 'Rechnungen erzeugen', + 'orders.bulkLabels': 'Labels erzeugen', + 'orders.bulkLabelsSoon': 'Massen-Labels erzeugen Versandetiketten für die Auswahl.', + 'orders.selectAll': 'Alle auswählen', + 'orders.selectRow': 'Bestellung auswählen', 'products.title': 'Produkte', 'products.add': 'Produkt hinzufügen', 'products.edit': 'Bearbeiten', + 'products.editTitle': 'Produkt bearbeiten', + 'products.formPurpose': 'Sektionierter Editor für schnellere Katalogarbeit.', 'products.save': 'Speichern', + 'products.saveProduct': 'Produkt speichern', 'products.cancel': 'Abbrechen', 'products.name': 'Name', 'products.slug': 'Slug', @@ -54,35 +80,49 @@ export const DE_TRANSLATIONS: TranslationMap = { 'products.active': 'Aktiv', 'products.shortDesc': 'Kurzbeschreibung', 'products.description': 'Beschreibung', - 'products.tabProducts': 'Produktliste', + 'products.tabProducts': 'Produkte', 'products.tabWarehouses': 'Lager', - 'products.tabGroups': 'Produktkategorien', + 'products.tabGroups': 'Gruppen', + 'products.tabBasics': 'Basics', + 'products.tabLocales': 'Sprachen', + 'products.tabMedia': 'Medien', + 'products.tabStock': 'Bestand', + 'products.tabChannels': 'Kanäle', + 'products.tabParams': 'Parameter', 'products.model': 'Modell', 'products.ean': 'EAN', 'products.group': 'Gruppe', + 'products.category': 'Kategorie', + 'products.categoryHint': 'Mit Kategorien verknüpft.', 'products.noGroup': '— keine Gruppe —', 'products.productCode': 'Produktcode', 'products.lowestPrice': 'Niedrigster Preis 30 Tage', 'products.deliveryHours': 'Lieferzeit (h)', 'products.bestseller': 'Bestseller', - 'products.newArrival': 'Neuheit', + 'products.newArrival': 'Neu', 'products.close': 'Schließen', 'products.loading': 'Laden…', 'products.sectionBasic': 'Grunddaten', + 'products.sectionIdentity': 'Identität', + 'products.sectionPricing': 'Preis & Katalog', 'products.sectionNames': 'Namen', 'products.sectionShortDesc': 'Kurzbeschreibungen', 'products.sectionDesc': 'Vollständige Beschreibungen', 'products.sectionParams': 'Parameter', - 'products.sectionStock': 'Lagerbestände', + 'products.sectionStock': 'Pro Lager', 'products.sectionImages': 'Bilder', + 'products.locale.pl': 'Polnisch (PL)', + 'products.locale.en': 'Englisch (EN)', + 'products.locale.de': 'Deutsch (DE)', + 'products.locale.cs': 'Tschechisch (CS)', 'products.image': 'Bild', 'products.imageUrl': 'Bild-URL', 'products.imageAlt': 'Alt-Text', 'products.addImage': 'Bild hinzufügen', 'products.primaryImage': 'Hauptbild', 'products.imageHint': 'Datei hochladen (zu WebP konvertiert) oder URL / Shop-Pfad eingeben', - 'products.uploadImage': 'Bild hochladen', - 'products.uploadHint': 'JPG, PNG, GIF, WebP, BMP — max. 10 MB, Konvertierung zu WebP', + 'products.uploadImage': 'Hochladen', + 'products.uploadHint': 'Datei ablegen oder URL einfügen — JPG, PNG, WebP, max. 10 MB', 'products.uploading': 'Wird hochgeladen…', 'products.errorUpload': 'Bild konnte nicht hochgeladen werden', 'products.noImages': 'Keine Bilder', @@ -97,19 +137,24 @@ export const DE_TRANSLATIONS: TranslationMap = { 'products.errorLoad': 'Produkt konnte nicht geladen werden', 'products.errorList': 'Produkte konnten nicht geladen werden', 'products.errorSave': 'Produkt konnte nicht gespeichert werden', - 'products.searchPlaceholder': 'Name, Slug, Modell, SKU…', - 'products.activeOnly': 'Nur aktive', - 'products.inactiveOnly': 'Nur inaktive', + 'products.searchPlaceholder': 'Name, SKU, EAN suchen…', + 'products.allStatuses': 'Alle Status', + 'products.allGroups': 'Alle Gruppen', + 'products.allCategories': 'Alle Kategorien', + 'products.sortName': 'Sort: Name', + 'products.sortPrice': 'Sort: Preis', + 'products.sortStock': 'Sort: Bestand', + 'products.colProduct': 'Produkt', + 'products.colSkuEan': 'SKU / EAN', + 'products.colStock': 'Bestand', + 'products.colReserved': 'Reserviert', + 'products.colStatus': 'Status', + 'products.groupsHint': 'Shop-Struktur bevorzugt? Verwalten Sie', + 'products.activeOnly': 'Aktiv', + 'products.inactiveOnly': 'Inaktiv', 'products.lowStockOnly': 'Niedriger Bestand', 'products.inactive': 'Inaktiv', 'products.empty': 'Keine Produkte', - 'categories.title': 'Kategorien', - 'categories.name': 'Name', - 'categories.slug': 'Slug', - 'categories.add': 'Kategorie hinzufügen', - 'categories.empty': 'Keine Kategorien', - 'categories.errorLoad': 'Kategorien konnten nicht geladen werden', - 'categories.errorSave': 'Kategorie konnte nicht gespeichert werden', 'banner.title': 'Promo-Banner', 'banner.text': 'Bannertext', 'banner.code': 'Hervorgehobener Code', @@ -237,12 +282,22 @@ export const DE_TRANSLATIONS: TranslationMap = { 'promotions.codesFor': 'Codes der ausgewählten Aktion', 'promotions.codeOptional': 'Code (optional)', 'promotions.maxUsages': 'Max. Nutzungen', - 'promotions.generateCode': 'Code erzeugen', + 'promotions.generateCode': 'Erzeugen', + 'promotions.copy': 'Kopieren', + 'promotions.codeCopied': 'Code kopiert', 'promotions.noCodes': 'Noch keine Codes', + 'promotions.searchPlaceholder': 'Aktionen suchen…', + 'promotions.window': 'Zeitraum', + 'promotions.validFrom': 'Gültig ab', + 'promotions.validTo': 'Gültig bis', + 'promotions.detailPrompt': 'Aktion zum Bearbeiten wählen', + 'promotions.kpiActive': 'Aktive Aktionen', + 'promotions.kpiRedemptions': 'Einlösungen (Seite)', + 'promotions.kpiCodes': 'Codes (Seite)', 'promotions.sectionBenefit': 'Vorteil', 'promotions.sectionRequirements': 'Voraussetzungen', 'promotions.sectionLimits': 'Nutzungslimits', - 'promotions.sectionBanner': 'Banner', + 'promotions.sectionBanner': 'Banner (Shop)', 'promotions.sectionReward': 'Belohnung nach Kauf', 'promotions.showInBanner': 'Im Shop-Banner zeigen', 'promotions.issueReward': 'Einmalcode nach bezahlter Bestellung', @@ -258,8 +313,27 @@ export const DE_TRANSLATIONS: TranslationMap = { 'newsletter.subject': 'Betreff', 'newsletter.body': 'HTML-Inhalt', 'newsletter.send': 'Kampagne senden', + 'newsletter.sendTo': 'Senden an', 'newsletter.subscribers': 'Abonnenten', 'newsletter.campaigns': 'Kampagnen', + 'newsletter.tabCompose': 'Verfassen', + 'newsletter.composer': 'Kampagnen-Editor', + 'newsletter.preview': 'Vorschau', + 'newsletter.from': 'Von', + 'newsletter.schedule': 'Zeitplan (optional)', + 'newsletter.scheduleHint': + 'Leer lassen, um nach Bestätigung sofort zu senden.', + 'newsletter.saveDraft': 'Entwurf speichern', + 'newsletter.draftSaved': 'Entwurf gespeichert', + 'newsletter.export': 'Abonnenten exportieren', + 'newsletter.searchEmail': 'E-Mail suchen…', + 'newsletter.searchCampaign': 'Kampagnen suchen…', + 'newsletter.joined': 'Beigetreten', + 'newsletter.sent': 'Gesendet', + 'newsletter.unsubscribed': 'Abgemeldet', + 'newsletter.status.sent': 'Gesendet', + 'newsletter.status.draft': 'Entwurf', + 'newsletter.status.scheduled': 'Geplant', 'newsletter.confirmSend': 'Diese Kampagne an alle aktiven Abonnenten senden?', 'newsletter.sentOk': 'Kampagne an {{count}} Abonnenten gesendet', @@ -270,6 +344,12 @@ export const DE_TRANSLATIONS: TranslationMap = { 'reviews.title': 'Bewertungen', 'reviews.approve': 'Genehmigen', 'reviews.reject': 'Ablehnen', + 'reviews.approveReply': 'Genehmigen + antworten', + 'reviews.bulkApprove': 'Auswahl genehmigen', + 'reviews.allStars': 'Alle Sterne', + 'reviews.searchPlaceholder': 'Inhalt oder Autor suchen…', + 'reviews.replyLabel': 'Öffentliche Antwort (optional)', + 'reviews.replyPlaceholder': 'Danke für Ihre Bewertung…', 'reviews.status': 'Status', 'reviews.status.pending': 'Ausstehend', 'reviews.status.approved': 'Genehmigt', @@ -277,4 +357,169 @@ export const DE_TRANSLATIONS: TranslationMap = { 'reviews.empty': 'Keine Bewertungen', 'reviews.errorLoad': 'Bewertungen konnten nicht geladen werden', 'reviews.errorSave': 'Bewertung konnte nicht aktualisiert werden', + 'customers.title': 'Kunden', + 'customers.purpose': 'Käufer finden und Bestellhistorie sehen.', + 'customers.searchPlaceholder': 'E-Mail oder Name…', + 'customers.loading': 'Kunden werden geladen…', + 'customers.empty': 'Noch keine Kunden aus Bestellungen.', + 'customers.errorLoad': 'Kunden konnten nicht geladen werden', + 'customers.colCustomer': 'Kunde', + 'customers.colOrders': 'Bestellungen', + 'customers.colLtv': 'LTV', + 'customers.colLastOrder': 'Letzte', + 'customers.colOrder': 'Bestellung', + 'customers.colTotal': 'Summe', + 'customers.showing': 'Angezeigt', + 'customers.email': 'E-Mail', + 'customers.orderHistory': 'Bestellhistorie', + 'customers.selectPrompt': 'Kunde auswählen für Details.', + 'categories.title': 'Kategorien', + 'categories.purpose': + 'Katalogstruktur. Produkte im Produktformular zuweisen.', + 'categories.add': 'Kategorie hinzufügen', + 'categories.addRoot': 'Stammkategorie hinzufügen', + 'categories.addChild': 'Unterkategorie', + 'categories.list': 'Kategorien', + 'categories.tree': 'Baum', + 'categories.create': 'Kategorie erstellen', + 'categories.createRoot': 'Neue Stammkategorie', + 'categories.createChild': 'Neue Unterkategorie', + 'categories.edit': 'Kategorie bearbeiten', + 'categories.name': 'Name', + 'categories.slug': 'Slug', + 'categories.description': 'Beschreibung', + 'categories.parent': 'Übergeordnet', + 'categories.root': '— Stamm (kein Parent) —', + 'categories.rootHint': + 'Stamm für eine Top-Level-Kategorie. Kinder hängen unter einem Parent.', + 'categories.toggle': 'Ein- oder ausklappen', + 'categories.viewProducts': 'Produkte', + 'categories.loading': 'Kategorien werden geladen…', + 'categories.empty': + 'Keine Kategorien. Stammkategorie anlegen, um den Baum zu starten.', + 'categories.errorLoad': 'Kategorien konnten nicht geladen werden', + 'categories.errorSave': 'Kategorie konnte nicht gespeichert werden', + 'categories.errorDelete': 'Kategorie konnte nicht gelöscht werden', + 'categories.errorValidation': 'Name und Slug sind erforderlich', + 'inventory.title': 'Lager', + 'inventory.purpose': 'Bestand über Lager — zuerst Alarme, dann Matrix.', + 'inventory.thresholdLink': 'Standard-Schwelle', + 'inventory.alertLow': 'SKUs auf/unter Schwelle', + 'inventory.alertReserved': 'Einheiten in offenen Bestellungen reserviert', + 'inventory.tabAlerts': 'Niedriger Bestand', + 'inventory.tabMatrix': 'Lager-Matrix', + 'inventory.loading': 'Lager wird geladen…', + 'inventory.noLowStock': 'Keine SKUs mit niedrigem Bestand.', + 'inventory.colProduct': 'Produkt', + 'inventory.colAvailable': 'Verfügbar', + 'inventory.colReserved': 'Reserviert', + 'inventory.colThreshold': 'Schwelle', + 'inventory.restock': 'Auffüllen', + 'inventory.product': 'Produkt', + 'inventory.adjust': 'Anpassen', + 'inventory.searchPlaceholder': 'SKU filtern…', + 'inventory.availableHint': 'Verfügbar = Bestand − reserviert', + 'inventory.quickAdjust': 'Schnellanpassung', + 'inventory.warehouse': 'Lager', + 'inventory.newQty': 'Neue Menge', + 'inventory.reason': 'Grund', + 'inventory.reasonPlaceholder': 'Lieferung erhalten…', + 'inventory.errorLoad': 'Lager konnte nicht geladen werden', + 'inventory.errorSave': 'Bestand konnte nicht aktualisiert werden', + 'settings.title': 'Einstellungen', + 'settings.purpose': + 'Shop-Konfiguration — lokal gespeichert bis Settings-APIs existieren.', + 'settings.save': 'Änderungen speichern', + 'settings.navStore': 'Shop-Profil', + 'settings.navLocales': 'Sprachen & Währung', + 'settings.navInventory': 'Lager', + 'settings.navShipping': 'Versand', + 'settings.navIntegrations': 'Integrationen', + 'settings.navEmail': 'E-Mail', + 'settings.navAccount': 'Mein Konto', + 'settings.integrationsTitle': 'Marktplätze & Rechnungen', + 'settings.integrationsHint': + 'Allegro-, Amazon- und iFirma-Zugangsdaten. Sync und Auto-Rechnung nutzen diese Einstellungen, sobald die Connectoren aktiv sind.', + 'settings.secretSaved': 'gespeichert', + 'settings.allegroClientId': 'Allegro Client-ID', + 'settings.allegroClientSecret': 'Allegro Client-Secret', + 'settings.allegroSellerId': 'Allegro Verkäufer-ID', + 'settings.amazonSellerId': 'Amazon Seller-ID', + 'settings.amazonMarketplaceId': 'Amazon Marketplace-ID', + 'settings.amazonAccessKey': 'Amazon Access Key', + 'settings.amazonSecretKey': 'Amazon Secret Key', + 'settings.ifirmaLogin': 'iFirma Login', + 'settings.ifirmaApiKey': 'iFirma API-Schlüssel', + 'settings.ifirmaInvoiceSeries': 'Rechnungsserie', + 'settings.ifirmaAutoInvoice': 'Rechnungen für bezahlte Bestellungen automatisch erstellen', + 'analytics.title': 'Analysen', + 'analytics.purpose': + 'Was verkauft sich, Umsatz vs. Vorperiode und Kanalmix.', + 'analytics.days7': 'Letzte 7 Tage', + 'analytics.days30': 'Letzte 30 Tage', + 'analytics.days90': 'Letzte 90 Tage', + 'analytics.allChannels': 'Alle Kanäle', + 'analytics.kpiRevenue': 'Umsatz', + 'analytics.kpiOrders': 'Bestellungen', + 'analytics.kpiAov': 'Ø Bestellwert', + 'analytics.kpiUnits': 'Verkaufte Stück', + 'analytics.vsPrevious': 'vs Vorperiode', + 'analytics.trendTitle': 'Umsatz & Bestellungen', + 'analytics.byChannel': 'Nach Kanal', + 'analytics.channel': 'Kanal', + 'analytics.topProducts': 'Top-Produkte', + 'analytics.topProductsHint': 'Nach Umsatz im Zeitraum', + 'analytics.colProduct': 'Produkt', + 'analytics.colUnits': 'Stück', + 'analytics.colShare': 'Anteil', + 'analytics.empty': 'Keine Verkaufsdaten in diesem Zeitraum.', + 'analytics.errorLoad': 'Analysen konnten nicht geladen werden', + 'orders.channel': 'Kanal', + 'orders.allChannels': 'Alle Kanäle', + 'orders.source.shop': 'Shop', + 'orders.source.allegro': 'Allegro', + 'orders.source.amazon': 'Amazon', + 'orders.source.manual': 'Manuell', + 'orders.sourceShort.shop': 'Shop', + 'orders.sourceShort.allegro': 'Allegro', + 'orders.sourceShort.amazon': 'Amazon', + 'orders.sourceShort.manual': 'Manuell', + 'orders.carrierShort.inPost': 'InPost', + 'orders.carrierShort.dhl': 'DHL', + 'orders.carrierShort.dpd': 'DPD', + 'orders.serviceShort.parcelLocker': 'Paketstation', + 'orders.serviceShort.courier': 'Kurier', + 'products.sectionChannels': 'Kanalpreise', + 'products.channelPricesHint': + 'Preis pro Shop (Storefront, Allegro, Amazon). Kanal aktivieren, wenn das Angebot syncen soll.', + 'products.channel': 'Kanal', + 'products.externalSku': 'Externe SKU', + 'products.externalOffer': 'Angebot / ASIN', + 'settings.storeProfile': 'Shop-Profil', + 'settings.storeName': 'Shop-Name', + 'settings.supportEmail': 'Support-E-Mail', + 'settings.storefrontUrl': 'Shop-URL', + 'settings.legalName': 'Firmenname', + 'settings.localesTitle': 'Sprachen & Währung', + 'settings.defaultLocale': 'Standardsprache', + 'settings.currency': 'Währung', + 'settings.enabledLocales': 'Aktivierte Sprachen', + 'settings.inventoryTitle': 'Lager-Standards', + 'settings.lowStockThreshold': 'Standard-Schwelle niedriger Bestand', + 'settings.lowStockHint': + 'Wenn kein Produkt-Override. Steuert Dashboard- und Lager-Alarme.', + 'settings.shippingTitle': 'Versanddienstleister', + 'settings.carrier': 'Dienstleister', + 'settings.enabled': 'Aktiv', + 'settings.mode': 'Modus', + 'settings.sandbox': 'Sandbox', + 'settings.shippingHint': + 'Zugangsdaten bleiben in der Server-Konfiguration — hier nur Sichtbarkeitsflags.', + 'settings.emailTitle': 'E-Mail / Newsletter', + 'settings.fromName': 'Absendername', + 'settings.fromAddress': 'Absenderadresse', + 'settings.replyTo': 'Antwort an', + 'settings.accountTitle': 'Mein Konto', + 'settings.accountHint': + 'Konto nutzt die eingeloggte Admin-Session. Passwortänderung kommt mit Settings-APIs.', }; diff --git a/src/admin/src/app/core/i18n/translations/en.ts b/src/admin/src/app/core/i18n/translations/en.ts index 2bb3fbb..f92fd99 100644 --- a/src/admin/src/app/core/i18n/translations/en.ts +++ b/src/admin/src/app/core/i18n/translations/en.ts @@ -5,6 +5,7 @@ export const EN_TRANSLATIONS: TranslationMap = { 'a11y.langSwitcher': 'Choose language', 'a11y.mainNav': 'Admin navigation', 'nav.dashboard': 'Dashboard', + 'nav.analytics': 'Analytics', 'nav.products': 'Products', 'nav.orders': 'Orders', 'nav.promotions': 'Promotions', @@ -14,22 +15,35 @@ export const EN_TRANSLATIONS: TranslationMap = { 'nav.logout': 'Sign out', 'nav.openMenu': 'Open menu', 'nav.closeMenu': 'Close menu', + 'nav.groupOps': 'Ops', + 'nav.groupCatalog': 'Catalog', + 'nav.groupPeople': 'People', + 'nav.groupMarketing': 'Marketing', + 'nav.groupSystem': 'System', + 'nav.inventory': 'Inventory', + 'nav.categories': 'Categories', + 'nav.customers': 'Customers', + 'nav.settings': 'Settings', + 'common.open': 'Open', 'common.search': 'Search', 'common.apply': 'Apply', 'common.all': 'All', 'common.page': 'Page', + 'common.showing': 'Showing {{from}}–{{to}} of {{total}}', 'common.prev': 'Previous', 'common.next': 'Next', 'common.close': 'Close', 'common.saved': 'Saved', 'common.deleted': 'Deleted', 'auth.loginTitle': 'Admin login', + 'auth.subtitle': 'Sign in to manage catalog, orders, and marketing.', 'auth.email': 'Email', 'auth.password': 'Password', 'auth.login': 'Sign in', 'auth.errorRole': 'Administrator permissions required.', 'auth.errorLogin': 'Login failed.', 'dashboard.title': 'Dashboard', + 'dashboard.purpose': 'What needs attention today — orders, stock, and reviews.', 'dashboard.ordersToday': 'Orders today', 'dashboard.revenueToday': 'Revenue today', 'dashboard.lowStock': 'Low stock', @@ -37,10 +51,46 @@ export const EN_TRANSLATIONS: TranslationMap = { 'dashboard.chartTitle': 'New orders (14 days)', 'dashboard.chartOrders': 'Orders', 'dashboard.errorLoad': 'Failed to load dashboard', + 'dashboard.alertLowStock': 'low-stock SKUs', + 'dashboard.alertPending': 'awaiting payment', + 'dashboard.alertReviews': 'reviews to moderate', + 'dashboard.viewOrders': 'View orders', + 'dashboard.openPickList': 'Open pick list', + 'dashboard.pendingFulfillment': 'Pending fulfillment', + 'dashboard.pendingFulfillmentHint': 'Needs attention', + 'dashboard.lowStockHint': 'Threshold ≤ 5', + 'dashboard.needsAction': 'Needs action — orders', + 'dashboard.pendingReviews': 'Pending reviews', + 'dashboard.moderate': 'Moderate', + 'dashboard.restock': 'Restock', + 'dashboard.noActionOrders': 'No orders need action right now.', + 'dashboard.noLowStock': 'Stock levels look healthy.', + 'dashboard.noPendingReviews': 'No reviews waiting.', + 'orders.purpose': + 'Fulfill and resolve — select many to pick, invoice, or label in one go.', + 'orders.order': 'Order', + 'orders.bulkSelected': '{count} selected', + 'orders.bulkPick': 'Collect / pick', + 'orders.bulkInvoices': 'Generate invoices', + 'orders.bulkLabels': 'Generate labels', + 'orders.bulkLabelsSoon': 'Bulk labels will generate shipping labels for the selection.', + 'orders.bulkLabelsEmpty': 'Select orders first', + 'orders.bulkLabelsDone': + 'Labels: {{ok}} created, {{fail}} failed, {{skip}} skipped', + 'orders.bulkLabelsError': 'Bulk label generation failed', + 'orders.selectAll': 'Select all', + 'orders.selectRow': 'Select order', 'products.title': 'Products', + 'products.purpose': 'Manage sellable catalog and warehouses.', + 'promotions.purpose': 'Run campaigns and promo codes without confusion.', + 'newsletter.purpose': 'Communicate with subscribers — compose, preview, send safely.', + 'reviews.purpose': 'Queue-first moderation — protect trust on the storefront.', 'products.add': 'Add product', 'products.edit': 'Edit', + 'products.editTitle': 'Edit product', + 'products.formPurpose': 'Sectioned editor for faster catalog edits.', 'products.save': 'Save', + 'products.saveProduct': 'Save product', 'products.cancel': 'Cancel', 'products.name': 'Name', 'products.slug': 'Slug', @@ -54,35 +104,49 @@ export const EN_TRANSLATIONS: TranslationMap = { 'products.active': 'Active', 'products.shortDesc': 'Short description', 'products.description': 'Description', - 'products.tabProducts': 'Product list', + 'products.tabProducts': 'Products', 'products.tabWarehouses': 'Warehouses', - 'products.tabGroups': 'Product categories', + 'products.tabGroups': 'Groups', + 'products.tabBasics': 'Basics', + 'products.tabLocales': 'Locales', + 'products.tabMedia': 'Media', + 'products.tabStock': 'Stock', + 'products.tabChannels': 'Channels', + 'products.tabParams': 'Parameters', 'products.model': 'Model', 'products.ean': 'EAN', 'products.group': 'Group', + 'products.category': 'Category', + 'products.categoryHint': 'Wired to Categories.', 'products.noGroup': '— no group —', 'products.productCode': 'Product code', - 'products.lowestPrice': 'Lowest price 30 days', - 'products.deliveryHours': 'Delivery time (h)', + 'products.lowestPrice': '30-day lowest price', + 'products.deliveryHours': 'Delivery hours', 'products.bestseller': 'Bestseller', - 'products.newArrival': 'New arrival', + 'products.newArrival': 'New', 'products.close': 'Close', 'products.loading': 'Loading…', 'products.sectionBasic': 'Basic info', + 'products.sectionIdentity': 'Identity', + 'products.sectionPricing': 'Pricing & catalog', 'products.sectionNames': 'Names', 'products.sectionShortDesc': 'Short descriptions', 'products.sectionDesc': 'Full descriptions', 'products.sectionParams': 'Parameters', - 'products.sectionStock': 'Warehouse stock', + 'products.sectionStock': 'Per warehouse', 'products.sectionImages': 'Images', + 'products.locale.pl': 'Polish (PL)', + 'products.locale.en': 'English (EN)', + 'products.locale.de': 'German (DE)', + 'products.locale.cs': 'Czech (CS)', 'products.image': 'Image', 'products.imageUrl': 'Image URL', 'products.imageAlt': 'Alt text', 'products.addImage': 'Add image', 'products.primaryImage': 'Primary', 'products.imageHint': 'Upload a file (converted to WebP) or enter a URL / shop path', - 'products.uploadImage': 'Upload image', - 'products.uploadHint': 'JPG, PNG, GIF, WebP, BMP — max 10 MB, converted to WebP', + 'products.uploadImage': 'Upload', + 'products.uploadHint': 'Drop file or paste URL — JPG, PNG, WebP, max 10 MB', 'products.uploading': 'Uploading…', 'products.errorUpload': 'Failed to upload image', 'products.noImages': 'No images', @@ -97,19 +161,24 @@ export const EN_TRANSLATIONS: TranslationMap = { 'products.errorLoad': 'Failed to load product', 'products.errorList': 'Failed to load products', 'products.errorSave': 'Failed to save product', - 'products.searchPlaceholder': 'Name, slug, model, SKU…', - 'products.activeOnly': 'Active only', - 'products.inactiveOnly': 'Inactive only', + 'products.searchPlaceholder': 'Search name, SKU, EAN…', + 'products.allStatuses': 'All statuses', + 'products.allGroups': 'All groups', + 'products.allCategories': 'All categories', + 'products.sortName': 'Sort: Name', + 'products.sortPrice': 'Sort: Price', + 'products.sortStock': 'Sort: Stock', + 'products.colProduct': 'Product', + 'products.colSkuEan': 'SKU / EAN', + 'products.colStock': 'Stock', + 'products.colReserved': 'Reserved', + 'products.colStatus': 'Status', + 'products.groupsHint': 'Prefer storefront structure? Manage', + 'products.activeOnly': 'Active', + 'products.inactiveOnly': 'Inactive', 'products.lowStockOnly': 'Low stock', 'products.inactive': 'Inactive', 'products.empty': 'No products', - 'categories.title': 'Categories', - 'categories.name': 'Name', - 'categories.slug': 'Slug', - 'categories.add': 'Add category', - 'categories.empty': 'No categories', - 'categories.errorLoad': 'Failed to load categories', - 'categories.errorSave': 'Failed to save category', 'banner.title': 'Promo banner', 'banner.text': 'Banner text', 'banner.code': 'Highlighted code', @@ -223,7 +292,7 @@ export const EN_TRANSLATIONS: TranslationMap = { 'discounts.errorLoad': 'Failed to load discounts', 'discounts.errorSave': 'Failed to save discount', 'promotions.title': 'Promotions', - 'promotions.add': 'Add promotion', + 'promotions.add': 'New promotion', 'promotions.edit': 'Edit promotion', 'promotions.name': 'Name', 'promotions.typeLabel': 'Type', @@ -234,15 +303,26 @@ export const EN_TRANSLATIONS: TranslationMap = { 'promotions.errorLoad': 'Failed to load promotions', 'promotions.errorSave': 'Failed to save promotion', 'promotions.codeCreated': 'Code created (copied)', - 'promotions.codesFor': 'Codes for selected promotion', + 'promotions.codeCopied': 'Code copied', + 'promotions.codesFor': 'Codes', 'promotions.codeOptional': 'Code (optional)', 'promotions.maxUsages': 'Max uses', - 'promotions.generateCode': 'Generate code', + 'promotions.generateCode': 'Generate', + 'promotions.copy': 'Copy', 'promotions.noCodes': 'No codes yet', + 'promotions.searchPlaceholder': 'Search promotions…', + 'promotions.window': 'Window', + 'promotions.validFrom': 'Valid from', + 'promotions.validTo': 'Valid to', + 'promotions.detailPrompt': 'Select a promotion to edit', + 'promotions.kpiActive': 'Active promotions', + 'promotions.kpiRedemptions': 'Redemptions (30d)', + 'promotions.kpiCodes': 'Codes issued', + 'promotions.kpiDiscount': 'Discount given (30d)', 'promotions.sectionBenefit': 'Benefit', 'promotions.sectionRequirements': 'Requirements', 'promotions.sectionLimits': 'Usage limits', - 'promotions.sectionBanner': 'Banner', + 'promotions.sectionBanner': 'Banner (storefront)', 'promotions.sectionReward': 'Reward after purchase', 'promotions.showInBanner': 'Show in site banner', 'promotions.issueReward': 'Issue one-time code after paid order', @@ -251,15 +331,35 @@ export const EN_TRANSLATIONS: TranslationMap = { 'promotions.rewardDays': 'Valid days', 'promotions.maxTotal': 'Max total redemptions', 'promotions.maxPerCustomer': 'Max per customer', - 'promotions.type.percent': 'Percentage', - 'promotions.type.fixed': 'Fixed amount', + 'promotions.type.percent': 'Percent', + 'promotions.type.fixed': 'Fixed', 'promotions.type.freeShip': 'Free shipping', 'newsletter.title': 'Newsletter', 'newsletter.subject': 'Subject', 'newsletter.body': 'HTML body', 'newsletter.send': 'Send campaign', + 'newsletter.sendTo': 'Send to', 'newsletter.subscribers': 'Subscribers', 'newsletter.campaigns': 'Campaigns', + 'newsletter.tabCompose': 'Compose', + 'newsletter.composer': 'Campaign composer', + 'newsletter.preview': 'Preview', + 'newsletter.from': 'From', + 'newsletter.schedule': 'Schedule (optional)', + 'newsletter.scheduleHint': + 'Leave empty to send immediately after confirm.', + 'newsletter.saveDraft': 'Save draft', + 'newsletter.draftSaved': 'Draft saved', + 'newsletter.export': 'Export subscribers', + 'newsletter.searchEmail': 'Search email…', + 'newsletter.searchCampaign': 'Search campaigns…', + 'newsletter.joined': 'Joined', + 'newsletter.sent': 'Sent', + 'newsletter.recipients': 'Recipients', + 'newsletter.unsubscribed': 'Unsubscribed', + 'newsletter.status.sent': 'Sent', + 'newsletter.status.draft': 'Draft', + 'newsletter.status.scheduled': 'Scheduled', 'newsletter.confirmSend': 'Send this campaign to all active subscribers?', 'newsletter.sentOk': 'Campaign sent to {{count}} subscribers', @@ -270,6 +370,12 @@ export const EN_TRANSLATIONS: TranslationMap = { 'reviews.title': 'Reviews', 'reviews.approve': 'Approve', 'reviews.reject': 'Reject', + 'reviews.approveReply': 'Approve + reply', + 'reviews.bulkApprove': 'Bulk approve', + 'reviews.allStars': 'All stars', + 'reviews.searchPlaceholder': 'Search content or author…', + 'reviews.replyLabel': 'Public reply (optional)', + 'reviews.replyPlaceholder': 'Thanks for your review…', 'reviews.status': 'Status', 'reviews.status.pending': 'Pending', 'reviews.status.approved': 'Approved', @@ -277,4 +383,169 @@ export const EN_TRANSLATIONS: TranslationMap = { 'reviews.empty': 'No reviews', 'reviews.errorLoad': 'Failed to load reviews', 'reviews.errorSave': 'Failed to update review', + 'customers.title': 'Customers', + 'customers.purpose': 'Find buyers and see order history.', + 'customers.searchPlaceholder': 'Email or name…', + 'customers.loading': 'Loading customers…', + 'customers.empty': 'No customers from orders yet.', + 'customers.errorLoad': 'Failed to load customers', + 'customers.colCustomer': 'Customer', + 'customers.colOrders': 'Orders', + 'customers.colLtv': 'LTV', + 'customers.colLastOrder': 'Last order', + 'customers.colOrder': 'Order', + 'customers.colTotal': 'Total', + 'customers.showing': 'Showing', + 'customers.email': 'Email', + 'customers.orderHistory': 'Order history', + 'customers.selectPrompt': 'Select a customer to see details.', + 'categories.title': 'Categories', + 'categories.purpose': + 'Structure the storefront catalog. Assign products from the product form.', + 'categories.add': 'Add category', + 'categories.addRoot': 'Add root category', + 'categories.addChild': 'Add child', + 'categories.list': 'Categories', + 'categories.tree': 'Tree', + 'categories.create': 'Create category', + 'categories.createRoot': 'New root category', + 'categories.createChild': 'New child category', + 'categories.edit': 'Edit category', + 'categories.name': 'Name', + 'categories.slug': 'Slug', + 'categories.description': 'Description', + 'categories.parent': 'Parent', + 'categories.root': '— Root (no parent) —', + 'categories.rootHint': + 'Leave as Root for a top-level category. Children nest under a parent.', + 'categories.toggle': 'Expand or collapse', + 'categories.viewProducts': 'View products', + 'categories.loading': 'Loading categories…', + 'categories.empty': 'No categories yet. Add a root category to start the tree.', + 'categories.errorLoad': 'Failed to load categories', + 'categories.errorSave': 'Failed to save category', + 'categories.errorDelete': 'Failed to delete category', + 'categories.errorValidation': 'Name and slug are required', + 'inventory.title': 'Inventory', + 'inventory.purpose': 'Stock health across warehouses — alerts first, then matrix.', + 'inventory.thresholdLink': 'Default threshold', + 'inventory.alertLow': 'SKUs at or below threshold', + 'inventory.alertReserved': 'units reserved in open orders', + 'inventory.tabAlerts': 'Low stock', + 'inventory.tabMatrix': 'Warehouse matrix', + 'inventory.loading': 'Loading inventory…', + 'inventory.noLowStock': 'No low-stock SKUs right now.', + 'inventory.colProduct': 'Product', + 'inventory.colAvailable': 'Available', + 'inventory.colReserved': 'Reserved', + 'inventory.colThreshold': 'Threshold', + 'inventory.restock': 'Restock', + 'inventory.product': 'Product', + 'inventory.adjust': 'Adjust', + 'inventory.searchPlaceholder': 'Filter SKU…', + 'inventory.availableHint': 'Available = stock − reserved', + 'inventory.quickAdjust': 'Quick adjust', + 'inventory.warehouse': 'Warehouse', + 'inventory.newQty': 'New quantity', + 'inventory.reason': 'Reason', + 'inventory.reasonPlaceholder': 'Delivery received…', + 'inventory.errorLoad': 'Failed to load inventory', + 'inventory.errorSave': 'Failed to update stock', + 'settings.title': 'Settings', + 'settings.purpose': 'Store ops config — profile, locales, thresholds, carriers.', + 'settings.save': 'Save changes', + 'settings.errorLoad': 'Failed to load settings', + 'settings.errorSave': 'Failed to save settings', + 'settings.navStore': 'Store profile', + 'settings.navLocales': 'Locales & currency', + 'settings.navInventory': 'Inventory', + 'settings.navShipping': 'Shipping', + 'settings.navIntegrations': 'Integrations', + 'settings.navEmail': 'Email', + 'settings.navAccount': 'My account', + 'settings.integrationsTitle': 'Marketplace & invoicing', + 'settings.integrationsHint': + 'Configure Allegro, Amazon, and iFirma credentials. Order sync and auto-invoicing use these settings once connectors are live.', + 'settings.secretSaved': 'saved', + 'settings.allegroClientId': 'Allegro client ID', + 'settings.allegroClientSecret': 'Allegro client secret', + 'settings.allegroSellerId': 'Allegro seller ID', + 'settings.amazonSellerId': 'Amazon seller ID', + 'settings.amazonMarketplaceId': 'Amazon marketplace ID', + 'settings.amazonAccessKey': 'Amazon access key', + 'settings.amazonSecretKey': 'Amazon secret key', + 'settings.ifirmaLogin': 'iFirma login', + 'settings.ifirmaApiKey': 'iFirma API key', + 'settings.ifirmaInvoiceSeries': 'Invoice series', + 'settings.ifirmaAutoInvoice': 'Auto-issue invoices for paid orders', + 'analytics.title': 'Analytics', + 'analytics.purpose': + 'What sells, expected revenue vs previous period, and channel mix.', + 'analytics.days7': 'Last 7 days', + 'analytics.days30': 'Last 30 days', + 'analytics.days90': 'Last 90 days', + 'analytics.allChannels': 'All channels', + 'analytics.kpiRevenue': 'Revenue', + 'analytics.kpiOrders': 'Orders', + 'analytics.kpiAov': 'Avg. order value', + 'analytics.kpiUnits': 'Units sold', + 'analytics.vsPrevious': 'vs previous period', + 'analytics.trendTitle': 'Revenue & orders', + 'analytics.byChannel': 'By channel', + 'analytics.channel': 'Channel', + 'analytics.topProducts': 'Top products', + 'analytics.topProductsHint': 'By revenue in selected period', + 'analytics.colProduct': 'Product', + 'analytics.colUnits': 'Units', + 'analytics.colShare': 'Share', + 'analytics.empty': 'No sales data for this period.', + 'analytics.errorLoad': 'Failed to load analytics', + 'orders.channel': 'Channel', + 'orders.allChannels': 'All channels', + 'orders.source.shop': 'Shop', + 'orders.source.allegro': 'Allegro', + 'orders.source.amazon': 'Amazon', + 'orders.source.manual': 'Manual', + 'orders.sourceShort.shop': 'Shop', + 'orders.sourceShort.allegro': 'Allegro', + 'orders.sourceShort.amazon': 'Amazon', + 'orders.sourceShort.manual': 'Manual', + 'orders.carrierShort.inPost': 'InPost', + 'orders.carrierShort.dhl': 'DHL', + 'orders.carrierShort.dpd': 'DPD', + 'orders.serviceShort.parcelLocker': 'Locker', + 'orders.serviceShort.courier': 'Courier', + 'products.sectionChannels': 'Channel pricing', + 'products.channelPricesHint': + 'Set a price per shop (storefront, Allegro, Amazon). Enable a channel when the listing should sync.', + 'products.channel': 'Channel', + 'products.externalSku': 'External SKU', + 'products.externalOffer': 'Offer / ASIN', + 'settings.storeProfile': 'Store profile', + 'settings.storeName': 'Store name', + 'settings.supportEmail': 'Support email', + 'settings.storefrontUrl': 'Storefront URL', + 'settings.legalName': 'Legal company name', + 'settings.localesTitle': 'Locales & currency', + 'settings.defaultLocale': 'Default locale', + 'settings.currency': 'Currency', + 'settings.enabledLocales': 'Enabled locales', + 'settings.inventoryTitle': 'Inventory defaults', + 'settings.lowStockThreshold': 'Default low-stock threshold', + 'settings.lowStockHint': + 'Used when a product has no override. Drives Dashboard & Inventory alerts.', + 'settings.shippingTitle': 'Shipping carriers', + 'settings.carrier': 'Carrier', + 'settings.enabled': 'Enabled', + 'settings.mode': 'Mode', + 'settings.sandbox': 'Sandbox', + 'settings.shippingHint': + 'Credentials stay in server config — this UI only stores visibility flags locally.', + 'settings.emailTitle': 'Email / newsletter', + 'settings.fromName': 'From name', + 'settings.fromAddress': 'From address', + 'settings.replyTo': 'Reply-to', + 'settings.accountTitle': 'My account', + 'settings.accountHint': + 'Account management uses the signed-in admin session. Password change comes with settings APIs.', }; diff --git a/src/admin/src/app/core/i18n/translations/pl.ts b/src/admin/src/app/core/i18n/translations/pl.ts index 5edde03..1f7a977 100644 --- a/src/admin/src/app/core/i18n/translations/pl.ts +++ b/src/admin/src/app/core/i18n/translations/pl.ts @@ -5,6 +5,7 @@ export const PL_TRANSLATIONS: TranslationMap = { 'a11y.langSwitcher': 'Wybierz język', 'a11y.mainNav': 'Nawigacja panelu', 'nav.dashboard': 'Dashboard', + 'nav.analytics': 'Analityka', 'nav.products': 'Produkty', 'nav.orders': 'Zamówienia', 'nav.promotions': 'Promocje', @@ -14,22 +15,35 @@ export const PL_TRANSLATIONS: TranslationMap = { 'nav.logout': 'Wyloguj', 'nav.openMenu': 'Otwórz menu', 'nav.closeMenu': 'Zamknij menu', + 'nav.groupOps': 'Operacje', + 'nav.groupCatalog': 'Katalog', + 'nav.groupPeople': 'Klienci', + 'nav.groupMarketing': 'Marketing', + 'nav.groupSystem': 'System', + 'nav.inventory': 'Magazyn', + 'nav.categories': 'Kategorie', + 'nav.customers': 'Klienci', + 'nav.settings': 'Ustawienia', + 'common.open': 'Otwórz', 'common.search': 'Szukaj', 'common.apply': 'Zastosuj', 'common.all': 'Wszystkie', 'common.page': 'Strona', + 'common.showing': 'Pokazano {{from}}–{{to}} z {{total}}', 'common.prev': 'Poprzednia', 'common.next': 'Następna', 'common.close': 'Zamknij', 'common.saved': 'Zapisano', 'common.deleted': 'Usunięto', 'auth.loginTitle': 'Logowanie admin', + 'auth.subtitle': 'Zaloguj się, aby zarządzać katalogiem, zamówieniami i marketingiem.', 'auth.email': 'Email', 'auth.password': 'Hasło', 'auth.login': 'Zaloguj się', 'auth.errorRole': 'Brak uprawnień administratora.', 'auth.errorLogin': 'Błąd logowania.', 'dashboard.title': 'Dashboard', + 'dashboard.purpose': 'Co wymaga uwagi dziś — zamówienia, stany i opinie.', 'dashboard.ordersToday': 'Zamówienia dziś', 'dashboard.revenueToday': 'Przychód dziś', 'dashboard.lowStock': 'Niski stan', @@ -37,10 +51,32 @@ export const PL_TRANSLATIONS: TranslationMap = { 'dashboard.chartTitle': 'Nowe zamówienia (14 dni)', 'dashboard.chartOrders': 'Zamówienia', 'dashboard.errorLoad': 'Nie udało się wczytać pulpitu', + 'dashboard.alertLowStock': 'SKU z niskim stanem', + 'dashboard.alertPending': 'oczekuje na płatność', + 'orders.bulkSelected': 'Wybrano {count}', + 'orders.bulkPick': 'Kompletuj / pick', + 'orders.bulkInvoices': 'Generuj faktury', + 'orders.bulkLabels': 'Generuj etykiety', + 'orders.bulkLabelsEmpty': 'Najpierw zaznacz zamówienia', + 'orders.bulkLabelsDone': + 'Etykiety: {{ok}} utworzone, {{fail}} błędy, {{skip}} pominięte', + 'orders.bulkLabelsError': 'Nie udało się wygenerować etykiet', + 'orders.bulkLabelsSoon': 'Masowe etykiety wygenerują etykiety wysyłkowe dla zaznaczenia.', + 'orders.selectAll': 'Zaznacz wszystkie', + 'orders.selectRow': 'Zaznacz zamówienie', 'products.title': 'Produkty', + 'products.purpose': 'Zarządzaj katalogiem i magazynami.', + 'promotions.purpose': 'Kampanie i kody rabatowe w jednym miejscu.', + 'newsletter.purpose': 'Komunikacja z subskrybentami — treść, podgląd, wysyłka.', + 'reviews.purpose': 'Kolejka moderacji opinii.', + 'orders.purpose': + 'Realizacja zamówień — zaznacz wiele, by kompletować, fakturować lub etykietować.', 'products.add': 'Dodaj produkt', 'products.edit': 'Edytuj', + 'products.editTitle': 'Edytuj produkt', + 'products.formPurpose': 'Edytor sekcyjny do szybszej pracy z katalogiem.', 'products.save': 'Zapisz', + 'products.saveProduct': 'Zapisz produkt', 'products.cancel': 'Anuluj', 'products.name': 'Nazwa', 'products.slug': 'Slug', @@ -54,12 +90,20 @@ export const PL_TRANSLATIONS: TranslationMap = { 'products.active': 'Aktywny', 'products.shortDesc': 'Krótki opis', 'products.description': 'Opis', - 'products.tabProducts': 'Lista produktów', + 'products.tabProducts': 'Produkty', 'products.tabWarehouses': 'Magazyny', - 'products.tabGroups': 'Kategorie produktów', + 'products.tabGroups': 'Grupy', + 'products.tabBasics': 'Podstawy', + 'products.tabLocales': 'Języki', + 'products.tabMedia': 'Media', + 'products.tabStock': 'Stany', + 'products.tabChannels': 'Kanały', + 'products.tabParams': 'Parametry', 'products.model': 'Model', 'products.ean': 'EAN', 'products.group': 'Grupa', + 'products.category': 'Kategoria', + 'products.categoryHint': 'Powiązane z kategoriami.', 'products.noGroup': '— brak grupy —', 'products.productCode': 'Kod produktu', 'products.lowestPrice': 'Najniższa cena 30 dni', @@ -69,20 +113,26 @@ export const PL_TRANSLATIONS: TranslationMap = { 'products.close': 'Zamknij', 'products.loading': 'Ładowanie…', 'products.sectionBasic': 'Dane podstawowe', + 'products.sectionIdentity': 'Identyfikacja', + 'products.sectionPricing': 'Cena i katalog', 'products.sectionNames': 'Nazwy', 'products.sectionShortDesc': 'Krótkie opisy', 'products.sectionDesc': 'Pełne opisy', 'products.sectionParams': 'Parametry', - 'products.sectionStock': 'Stany magazynowe', + 'products.sectionStock': 'Per magazyn', 'products.sectionImages': 'Zdjęcia', + 'products.locale.pl': 'Polski (PL)', + 'products.locale.en': 'Angielski (EN)', + 'products.locale.de': 'Niemiecki (DE)', + 'products.locale.cs': 'Czeski (CS)', 'products.image': 'Zdjęcie', 'products.imageUrl': 'URL zdjęcia', 'products.imageAlt': 'Tekst alternatywny', 'products.addImage': 'Dodaj zdjęcie', 'products.primaryImage': 'Główne', 'products.imageHint': 'Prześlij plik (konwertowany do WebP) lub podaj URL / ścieżkę sklepu', - 'products.uploadImage': 'Prześlij zdjęcie', - 'products.uploadHint': 'JPG, PNG, GIF, WebP, BMP — max 10 MB, konwersja do WebP', + 'products.uploadImage': 'Prześlij', + 'products.uploadHint': 'Upuść plik lub wklej URL — JPG, PNG, WebP, max 10 MB', 'products.uploading': 'Przesyłanie…', 'products.errorUpload': 'Nie udało się przesłać zdjęcia', 'products.noImages': 'Brak zdjęć', @@ -97,19 +147,24 @@ export const PL_TRANSLATIONS: TranslationMap = { 'products.errorLoad': 'Nie udało się wczytać produktu', 'products.errorList': 'Nie udało się wczytać produktów', 'products.errorSave': 'Nie udało się zapisać produktu', - 'products.searchPlaceholder': 'Nazwa, slug, model, SKU…', - 'products.activeOnly': 'Tylko aktywne', - 'products.inactiveOnly': 'Tylko nieaktywne', + 'products.searchPlaceholder': 'Szukaj nazwy, SKU, EAN…', + 'products.allStatuses': 'Wszystkie statusy', + 'products.allGroups': 'Wszystkie grupy', + 'products.allCategories': 'Wszystkie kategorie', + 'products.sortName': 'Sortuj: Nazwa', + 'products.sortPrice': 'Sortuj: Cena', + 'products.sortStock': 'Sortuj: Stan', + 'products.colProduct': 'Produkt', + 'products.colSkuEan': 'SKU / EAN', + 'products.colStock': 'Stan', + 'products.colReserved': 'Zarezerwowane', + 'products.colStatus': 'Status', + 'products.groupsHint': 'Preferujesz strukturę sklepu? Zarządzaj', + 'products.activeOnly': 'Aktywne', + 'products.inactiveOnly': 'Nieaktywne', 'products.lowStockOnly': 'Niski stan', 'products.inactive': 'Nieaktywny', 'products.empty': 'Brak produktów', - 'categories.title': 'Kategorie', - 'categories.name': 'Nazwa', - 'categories.slug': 'Slug', - 'categories.add': 'Dodaj kategorię', - 'categories.empty': 'Brak kategorii', - 'categories.errorLoad': 'Nie udało się wczytać kategorii', - 'categories.errorSave': 'Nie udało się zapisać kategorii', 'banner.title': 'Baner promo', 'banner.text': 'Tekst banera', 'banner.code': 'Podświetlony kod', @@ -223,7 +278,7 @@ export const PL_TRANSLATIONS: TranslationMap = { 'discounts.errorLoad': 'Nie udało się wczytać rabatów', 'discounts.errorSave': 'Nie udało się zapisać rabatu', 'promotions.title': 'Promocje', - 'promotions.add': 'Dodaj promocję', + 'promotions.add': 'Nowa promocja', 'promotions.edit': 'Edytuj promocję', 'promotions.name': 'Nazwa', 'promotions.typeLabel': 'Typ', @@ -234,15 +289,26 @@ export const PL_TRANSLATIONS: TranslationMap = { 'promotions.errorLoad': 'Nie udało się wczytać promocji', 'promotions.errorSave': 'Nie udało się zapisać promocji', 'promotions.codeCreated': 'Kod utworzony (skopiowany)', - 'promotions.codesFor': 'Kody wybranej promocji', + 'promotions.codeCopied': 'Kod skopiowany', + 'promotions.codesFor': 'Kody', 'promotions.codeOptional': 'Kod (opcjonalnie)', 'promotions.maxUsages': 'Max użyć', - 'promotions.generateCode': 'Generuj kod', + 'promotions.generateCode': 'Generuj', + 'promotions.copy': 'Kopiuj', 'promotions.noCodes': 'Brak kodów', + 'promotions.searchPlaceholder': 'Szukaj promocji…', + 'promotions.window': 'Okres', + 'promotions.validFrom': 'Ważne od', + 'promotions.validTo': 'Ważne do', + 'promotions.detailPrompt': 'Wybierz promocję do edycji', + 'promotions.kpiActive': 'Aktywne promocje', + 'promotions.kpiRedemptions': 'Użycia (30 dni)', + 'promotions.kpiCodes': 'Wydane kody', + 'promotions.kpiDiscount': 'Udzielone rabaty (30 dni)', 'promotions.sectionBenefit': 'Korzyść', 'promotions.sectionRequirements': 'Wymagania', 'promotions.sectionLimits': 'Limity użyć', - 'promotions.sectionBanner': 'Baner', + 'promotions.sectionBanner': 'Baner (sklep)', 'promotions.sectionReward': 'Nagroda po zakupie', 'promotions.showInBanner': 'Pokaż w banerze sklepu', 'promotions.issueReward': 'Wystaw jednorazowy kod po opłaceniu', @@ -252,14 +318,33 @@ export const PL_TRANSLATIONS: TranslationMap = { 'promotions.maxTotal': 'Max użyć łącznie', 'promotions.maxPerCustomer': 'Max na klienta', 'promotions.type.percent': 'Procent', - 'promotions.type.fixed': 'Kwota stała', + 'promotions.type.fixed': 'Kwota', 'promotions.type.freeShip': 'Darmowa dostawa', 'newsletter.title': 'Newsletter', 'newsletter.subject': 'Temat', 'newsletter.body': 'Treść HTML', 'newsletter.send': 'Wyślij kampanię', + 'newsletter.sendTo': 'Wyślij do', 'newsletter.subscribers': 'Subskrybenci', 'newsletter.campaigns': 'Kampanie', + 'newsletter.tabCompose': 'Treść', + 'newsletter.composer': 'Kompozytor kampanii', + 'newsletter.preview': 'Podgląd', + 'newsletter.from': 'Od', + 'newsletter.schedule': 'Harmonogram (opcjonalnie)', + 'newsletter.scheduleHint': + 'Zostaw puste, aby wysłać od razu po potwierdzeniu.', + 'newsletter.saveDraft': 'Zapisz szkic', + 'newsletter.draftSaved': 'Szkic zapisany', + 'newsletter.export': 'Eksport subskrybentów', + 'newsletter.searchEmail': 'Szukaj email…', + 'newsletter.searchCampaign': 'Szukaj kampanii…', + 'newsletter.joined': 'Dołączył', + 'newsletter.sent': 'Wysłano', + 'newsletter.unsubscribed': 'Wypisany', + 'newsletter.status.sent': 'Wysłana', + 'newsletter.status.draft': 'Szkic', + 'newsletter.status.scheduled': 'Zaplanowana', 'newsletter.confirmSend': 'Wysłać tę kampanię do wszystkich aktywnych subskrybentów?', 'newsletter.sentOk': 'Kampania wysłana do {{count}} subskrybentów', @@ -270,6 +355,12 @@ export const PL_TRANSLATIONS: TranslationMap = { 'reviews.title': 'Opinie', 'reviews.approve': 'Zatwierdź', 'reviews.reject': 'Odrzuć', + 'reviews.approveReply': 'Zatwierdź + odpowiedź', + 'reviews.bulkApprove': 'Zatwierdź zaznaczone', + 'reviews.allStars': 'Wszystkie gwiazdki', + 'reviews.searchPlaceholder': 'Szukaj treści lub autora…', + 'reviews.replyLabel': 'Odpowiedź publiczna (opcjonalnie)', + 'reviews.replyPlaceholder': 'Dziękujemy za opinię…', 'reviews.status': 'Status', 'reviews.status.pending': 'Oczekujące', 'reviews.status.approved': 'Zatwierdzone', @@ -277,4 +368,172 @@ export const PL_TRANSLATIONS: TranslationMap = { 'reviews.empty': 'Brak opinii', 'reviews.errorLoad': 'Nie udało się wczytać opinii', 'reviews.errorSave': 'Nie udało się zaktualizować opinii', + 'customers.title': 'Klienci', + 'customers.purpose': 'Szukaj kupujących i przeglądaj historię zamówień.', + 'customers.searchPlaceholder': 'Email lub nazwisko…', + 'customers.loading': 'Ładowanie klientów…', + 'customers.empty': 'Brak klientów z zamówień.', + 'customers.errorLoad': 'Nie udało się wczytać klientów', + 'customers.colCustomer': 'Klient', + 'customers.colOrders': 'Zamówienia', + 'customers.colLtv': 'LTV', + 'customers.colLastOrder': 'Ostatnie', + 'customers.colOrder': 'Zamówienie', + 'customers.colTotal': 'Suma', + 'customers.showing': 'Widoczne', + 'customers.email': 'Email', + 'customers.orderHistory': 'Historia zamówień', + 'customers.selectPrompt': 'Wybierz klienta, aby zobaczyć szczegóły.', + 'categories.title': 'Kategorie', + 'categories.purpose': + 'Struktura katalogu sklepu. Produkty przypiszesz w formularzu produktu.', + 'categories.add': 'Dodaj kategorię', + 'categories.addRoot': 'Dodaj kategorię główną', + 'categories.addChild': 'Dodaj podkategorię', + 'categories.list': 'Kategorie', + 'categories.tree': 'Drzewo', + 'categories.create': 'Nowa kategoria', + 'categories.createRoot': 'Nowa kategoria główna', + 'categories.createChild': 'Nowa podkategoria', + 'categories.edit': 'Edycja kategorii', + 'categories.name': 'Nazwa', + 'categories.slug': 'Slug', + 'categories.description': 'Opis', + 'categories.parent': 'Rodzic', + 'categories.root': '— Główna (bez rodzica) —', + 'categories.rootHint': + 'Zostaw Główna dla kategorii najwyższego poziomu. Podkategorie zagnieżdżają się pod rodzicem.', + 'categories.toggle': 'Rozwiń lub zwiń', + 'categories.viewProducts': 'Produkty', + 'categories.loading': 'Ładowanie kategorii…', + 'categories.empty': + 'Brak kategorii. Dodaj kategorię główną, aby zacząć drzewo.', + 'categories.errorLoad': 'Nie udało się wczytać kategorii', + 'categories.errorSave': 'Nie udało się zapisać kategorii', + 'categories.errorDelete': 'Nie udało się usunąć kategorii', + 'categories.errorValidation': 'Nazwa i slug są wymagane', + 'inventory.title': 'Magazyn', + 'inventory.purpose': + 'Stan magazynowy — najpierw alerty, potem macierz magazynów.', + 'inventory.thresholdLink': 'Domyślny próg', + 'inventory.alertLow': 'SKU poniżej lub na progu', + 'inventory.alertReserved': 'szt. zarezerwowanych w otwartych zamówieniach', + 'inventory.tabAlerts': 'Niski stan', + 'inventory.tabMatrix': 'Macierz magazynów', + 'inventory.loading': 'Ładowanie magazynu…', + 'inventory.noLowStock': 'Brak SKU z niskim stanem.', + 'inventory.colProduct': 'Produkt', + 'inventory.colAvailable': 'Dostępne', + 'inventory.colReserved': 'Zarezerwowane', + 'inventory.colThreshold': 'Próg', + 'inventory.restock': 'Uzupełnij', + 'inventory.product': 'Produkt', + 'inventory.adjust': 'Korekta', + 'inventory.searchPlaceholder': 'Filtruj SKU…', + 'inventory.availableHint': 'Dostępne = stan − rezerwacja', + 'inventory.quickAdjust': 'Szybka korekta', + 'inventory.warehouse': 'Magazyn', + 'inventory.newQty': 'Nowa ilość', + 'inventory.reason': 'Powód', + 'inventory.reasonPlaceholder': 'Dostawa otrzymana…', + 'inventory.errorLoad': 'Nie udało się wczytać magazynu', + 'inventory.errorSave': 'Nie udało się zaktualizować stanu', + 'settings.title': 'Ustawienia', + 'settings.purpose': + 'Konfiguracja sklepu — profil, języki, progi, przewoźnicy.', + 'settings.save': 'Zapisz zmiany', + 'settings.errorLoad': 'Nie udało się wczytać ustawień', + 'settings.errorSave': 'Nie udało się zapisać ustawień', + 'settings.navStore': 'Profil sklepu', + 'settings.navLocales': 'Języki i waluta', + 'settings.navInventory': 'Magazyn', + 'settings.navShipping': 'Wysyłka', + 'settings.navIntegrations': 'Integracje', + 'settings.navEmail': 'Email', + 'settings.navAccount': 'Moje konto', + 'settings.integrationsTitle': 'Marketplace i faktury', + 'settings.integrationsHint': + 'Konfiguracja Allegro, Amazon i iFirma. Synchronizacja zamówień i auto-faktury użyją tych ustawień po włączeniu konektorów.', + 'settings.secretSaved': 'zapisany', + 'settings.allegroClientId': 'Allegro client ID', + 'settings.allegroClientSecret': 'Allegro client secret', + 'settings.allegroSellerId': 'Allegro seller ID', + 'settings.amazonSellerId': 'Amazon seller ID', + 'settings.amazonMarketplaceId': 'Amazon marketplace ID', + 'settings.amazonAccessKey': 'Amazon access key', + 'settings.amazonSecretKey': 'Amazon secret key', + 'settings.ifirmaLogin': 'Login iFirma', + 'settings.ifirmaApiKey': 'Klucz API iFirma', + 'settings.ifirmaInvoiceSeries': 'Seria faktur', + 'settings.ifirmaAutoInvoice': 'Automatycznie wystawiaj faktury dla opłaconych zamówień', + 'analytics.title': 'Analityka', + 'analytics.purpose': + 'Co się sprzedaje, przychód vs poprzedni okres i mix kanałów.', + 'analytics.days7': 'Ostatnie 7 dni', + 'analytics.days30': 'Ostatnie 30 dni', + 'analytics.days90': 'Ostatnie 90 dni', + 'analytics.allChannels': 'Wszystkie kanały', + 'analytics.kpiRevenue': 'Przychód', + 'analytics.kpiOrders': 'Zamówienia', + 'analytics.kpiAov': 'Śr. wartość zamówienia', + 'analytics.kpiUnits': 'Sprzedane sztuki', + 'analytics.vsPrevious': 'vs poprzedni okres', + 'analytics.trendTitle': 'Przychód i zamówienia', + 'analytics.byChannel': 'Wg kanału', + 'analytics.channel': 'Kanał', + 'analytics.topProducts': 'Najlepsze produkty', + 'analytics.topProductsHint': 'Wg przychodu w wybranym okresie', + 'analytics.colProduct': 'Produkt', + 'analytics.colUnits': 'Sztuki', + 'analytics.colShare': 'Udział', + 'analytics.empty': 'Brak sprzedaży w tym okresie.', + 'analytics.errorLoad': 'Nie udało się wczytać analityki', + 'orders.channel': 'Kanał', + 'orders.allChannels': 'Wszystkie kanały', + 'orders.source.shop': 'Sklep', + 'orders.source.allegro': 'Allegro', + 'orders.source.amazon': 'Amazon', + 'orders.source.manual': 'Ręczne', + 'orders.sourceShort.shop': 'Sklep', + 'orders.sourceShort.allegro': 'Allegro', + 'orders.sourceShort.amazon': 'Amazon', + 'orders.sourceShort.manual': 'Ręczne', + 'orders.carrierShort.inPost': 'InPost', + 'orders.carrierShort.dhl': 'DHL', + 'orders.carrierShort.dpd': 'DPD', + 'orders.serviceShort.parcelLocker': 'Paczkomat', + 'orders.serviceShort.courier': 'Kurier', + 'products.sectionChannels': 'Ceny kanałów', + 'products.channelPricesHint': + 'Cena per sklep (storefront, Allegro, Amazon). Włącz kanał, gdy oferta ma się synchronizować.', + 'products.channel': 'Kanał', + 'products.externalSku': 'Zewnętrzne SKU', + 'products.externalOffer': 'Oferta / ASIN', + 'settings.storeProfile': 'Profil sklepu', + 'settings.storeName': 'Nazwa sklepu', + 'settings.supportEmail': 'Email wsparcia', + 'settings.storefrontUrl': 'URL sklepu', + 'settings.legalName': 'Nazwa firmy', + 'settings.localesTitle': 'Języki i waluta', + 'settings.defaultLocale': 'Domyślny język', + 'settings.currency': 'Waluta', + 'settings.enabledLocales': 'Włączone języki', + 'settings.inventoryTitle': 'Domyślne magazynu', + 'settings.lowStockThreshold': 'Domyślny próg niskiego stanu', + 'settings.lowStockHint': + 'Używany, gdy produkt nie ma własnego progu. Steruje alertami Dashboard i Magazyn.', + 'settings.shippingTitle': 'Przewoźnicy', + 'settings.carrier': 'Przewoźnik', + 'settings.enabled': 'Włączony', + 'settings.mode': 'Tryb', + 'settings.sandbox': 'Sandbox', + 'settings.shippingHint': + 'Dane dostępowe zostają w konfiguracji serwera — tu tylko flagi widoczności.', + 'settings.emailTitle': 'Email / newsletter', + 'settings.fromName': 'Nazwa nadawcy', + 'settings.fromAddress': 'Adres nadawcy', + 'settings.replyTo': 'Reply-to', + 'settings.accountTitle': 'Moje konto', + 'settings.accountHint': + 'Konto korzysta z sesji zalogowanego admina. Zmiana hasła pojawi się z API ustawień.', }; diff --git a/src/admin/src/app/features/admin-pages.component.ts b/src/admin/src/app/features/admin-pages.component.ts index af09343..3672535 100644 --- a/src/admin/src/app/features/admin-pages.component.ts +++ b/src/admin/src/app/features/admin-pages.component.ts @@ -1,20 +1,20 @@ -import { +import { ChangeDetectionStrategy, Component, + computed, inject, OnInit, signal, } from '@angular/core'; +import { DatePipe, LowerCasePipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; -import { I18nCurrencyPipe } from '../core/i18n/i18n-currency.pipe'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { LucideSearch } from '@lucide/angular'; import { TranslatePipe } from '../core/i18n/translate.pipe'; import { ApiService } from '../core/api.service'; import { I18nService } from '../core/i18n/i18n.service'; -import { AdminListToolbarComponent } from '../shared/admin-list-toolbar.component'; -import { AdminPaginationComponent } from '../shared/admin-pagination.component'; +import { AdminSkeletonComponent } from '../shared/admin-skeleton.component'; import { ToastService } from '../shared/toast.service'; -import { ChartPoint, OrdersChartComponent } from './orders-chart.component'; interface Paginated { items: T[]; @@ -23,241 +23,33 @@ interface Paginated { totalCount: number; } -@Component({ - selector: 'app-dashboard', - standalone: true, - imports: [I18nCurrencyPipe, TranslatePipe, OrdersChartComponent, RouterLink], - template: ` -

{{ 'dashboard.title' | translate }}

- @if (loading()) { -

- {{ 'products.loading' | translate }} -

- } - @if (error()) { -

{{ error() | translate }}

- } - @if (stats(); as s) { - - } - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DashboardComponent implements OnInit { - readonly #api = inject(ApiService); - readonly stats = signal(null); - readonly chart = signal([]); - readonly loading = signal(false); - readonly error = signal(''); +type NewsletterTab = 'compose' | 'subscribers' | 'campaigns'; - ngOnInit(): void { - this.loading.set(true); - this.#api.get('/admin/dashboard').subscribe({ - next: (s) => { - this.stats.set(s); - this.loading.set(false); - }, - error: () => { - this.loading.set(false); - this.error.set('dashboard.errorLoad'); - }, - }); - this.#api - .get('/admin/dashboard/orders-chart?days=14') - .subscribe({ - next: (c) => this.chart.set(c), - }); - } -} - -interface DashboardStats { - ordersToday: number; - revenueToday: number; - lowStockCount: number; - pendingOrders: number; -} - -@Component({ - selector: 'app-discounts-admin', - standalone: true, - imports: [ - FormsModule, - TranslatePipe, - AdminListToolbarComponent, - AdminPaginationComponent, - ], - templateUrl: './discounts-admin.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DiscountsAdminComponent implements OnInit { - readonly #api = inject(ApiService); - readonly #toast = inject(ToastService); - readonly codes = signal([]); - readonly loading = signal(false); - readonly totalCount = signal(0); - ruleName = ''; - code = ''; - value = 20; - minOrderAmount: number | null = null; - validFrom = ''; - validTo = ''; - search = ''; - filterActive: '' | 'true' | 'false' = ''; - page = 1; - readonly pageSize = 20; - - ngOnInit(): void { - this.load(); - } - - load(): void { - this.loading.set(true); - const params = new URLSearchParams({ - page: String(this.page), - pageSize: String(this.pageSize), - }); - if (this.search.trim()) { - params.set('search', this.search.trim()); - } - if (this.filterActive) { - params.set('isActive', this.filterActive); - } - - this.#api - .get>(`/marketing/discount-codes?${params}`) - .subscribe({ - next: (res) => { - this.codes.set(res.items); - this.totalCount.set(res.totalCount); - this.loading.set(false); - }, - error: () => { - this.loading.set(false); - this.#toast.error('discounts.errorLoad'); - }, - }); - } - - handleSearch(): void { - this.page = 1; - this.load(); - } - - handleFilterChange(): void { - this.page = 1; - this.load(); - } - - handlePrevPage(): void { - if (this.page > 1) { - this.page -= 1; - this.load(); - } - } - - handleNextPage(): void { - const pages = Math.max(1, Math.ceil(this.totalCount() / this.pageSize)); - if (this.page < pages) { - this.page += 1; - this.load(); - } - } - - create(): void { - this.#api - .post('/marketing/discount-rules', { - name: this.ruleName, - type: 0, - value: this.value, - minimumOrderAmount: this.minOrderAmount, - validFrom: this.validFrom || null, - validTo: this.validTo || null, - }) - .subscribe({ - next: (ruleId) => { - this.#api - .post('/marketing/discount-codes', { - discountRuleId: ruleId, - code: this.code, - maxUsages: 1000, - }) - .subscribe({ - next: () => { - this.ruleName = ''; - this.code = ''; - this.value = 20; - this.minOrderAmount = null; - this.validFrom = ''; - this.validTo = ''; - this.load(); - this.#toast.success('common.saved'); - }, - error: () => this.#toast.error('discounts.errorSave'), - }); - }, - error: () => this.#toast.error('discounts.errorSave'), - }); - } - - toggleActive(row: CodeRow): void { - this.#api - .put(`/marketing/discount-codes/${row.id}/active`, { - isActive: !row.isActive, - }) - .subscribe({ - next: () => { - this.load(); - this.#toast.success('common.saved'); - }, - error: () => this.#toast.error('discounts.errorSave'), - }); - } -} - -interface CodeRow { +interface SubRow { id: string; - code: string; - ruleName: string; - usageCount: number; - maxUsages: number | null; + email: string; isActive: boolean; + createdAt: string; +} + +interface CampRow { + id: string; + subject: string; + status: string; + sentAt: string | null; + createdAt?: string; + scheduledAt?: string | null; + recipientCount?: number; +} + +interface ReviewRow { + id: string; + productName: string; + authorName: string; + rating: number; + content: string; + status: string; + createdAt: string; } @Component({ @@ -266,8 +58,10 @@ interface CodeRow { imports: [ FormsModule, TranslatePipe, - AdminListToolbarComponent, - AdminPaginationComponent, + DatePipe, + LowerCasePipe, + LucideSearch, + AdminSkeletonComponent, ], templateUrl: './newsletter-admin.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -276,6 +70,8 @@ export class NewsletterAdminComponent implements OnInit { readonly #api = inject(ApiService); readonly #toast = inject(ToastService); readonly #i18n = inject(I18nService); + readonly #sanitizer = inject(DomSanitizer); + readonly subscribers = signal([]); readonly campaigns = signal([]); readonly subTotal = signal(0); @@ -283,17 +79,54 @@ export class NewsletterAdminComponent implements OnInit { readonly loadingSubs = signal(false); readonly loadingCamps = signal(false); readonly sending = signal(false); + readonly activeTab = signal('compose'); + subject = ''; - bodyHtml = ''; + bodyHtml = '

Witaj w iLED

\n

Sprawdź nasze lampy LED.

'; + scheduleAt = ''; subSearch = ''; campSearch = ''; + subFilterActive: '' | 'true' | 'false' = ''; subPage = 1; campPage = 1; readonly pageSize = 20; + readonly activeSubscriberCount = signal(0); + + readonly subPages = computed(() => + Math.max(1, Math.ceil(this.subTotal() / this.pageSize)), + ); + + readonly campPages = computed(() => + Math.max(1, Math.ceil(this.campTotal() / this.pageSize)), + ); + + readonly filteredSubscribers = computed(() => this.subscribers()); ngOnInit(): void { this.loadSubscribers(); this.loadCampaigns(); + this.loadSubscriberSummary(); + } + + loadSubscriberSummary(): void { + this.#api + .get<{ totalCount: number; activeCount: number; inactiveCount: number }>( + '/marketing/subscribers/summary', + ) + .subscribe({ + next: (res) => this.activeSubscriberCount.set(res.activeCount), + }); + } + + setTab(tab: NewsletterTab): void { + this.activeTab.set(tab); + } + + previewHtml(): SafeHtml { + const html = this.bodyHtml?.trim() + ? this.bodyHtml + : '

…

'; + return this.#sanitizer.bypassSecurityTrustHtml(html); } loadSubscribers(): void { @@ -306,6 +139,10 @@ export class NewsletterAdminComponent implements OnInit { params.set('search', this.subSearch.trim()); } + if (this.subFilterActive) { + params.set('isActive', this.subFilterActive); + } + this.#api .get>(`/marketing/subscribers?${params}`) .subscribe({ @@ -351,6 +188,11 @@ export class NewsletterAdminComponent implements OnInit { this.loadSubscribers(); } + handleSubFilterChange(): void { + this.subPage = 1; + this.loadSubscribers(); + } + handleCampSearch(): void { this.campPage = 1; this.loadCampaigns(); @@ -364,8 +206,7 @@ export class NewsletterAdminComponent implements OnInit { } handleSubNext(): void { - const pages = Math.max(1, Math.ceil(this.subTotal() / this.pageSize)); - if (this.subPage < pages) { + if (this.subPage < this.subPages()) { this.subPage += 1; this.loadSubscribers(); } @@ -379,64 +220,130 @@ export class NewsletterAdminComponent implements OnInit { } handleCampNext(): void { - const pages = Math.max(1, Math.ceil(this.campTotal() / this.pageSize)); - if (this.campPage < pages) { + if (this.campPage < this.campPages()) { this.campPage += 1; this.loadCampaigns(); } } + #campaignBody(): { + subject: string; + bodyHtml: string; + scheduledAt: string | null; + } { + return { + subject: this.subject.trim(), + bodyHtml: this.bodyHtml, + scheduledAt: this.scheduleAt + ? new Date(this.scheduleAt).toISOString() + : null, + }; + } + + saveDraft(): void { + if (!this.subject.trim()) { + this.#toast.error('newsletter.errorSend'); + return; + } + this.sending.set(true); + this.#api.post('/marketing/campaigns', this.#campaignBody()).subscribe({ + next: () => { + this.sending.set(false); + this.loadCampaigns(); + this.#toast.success('newsletter.draftSaved'); + this.activeTab.set('campaigns'); + }, + error: () => { + this.sending.set(false); + this.#toast.error('newsletter.errorSend'); + }, + }); + } + createCampaign(): void { + if (this.scheduleAt) { + this.saveDraft(); + return; + } + if (!window.confirm(this.#i18n.t('newsletter.confirmSend'))) { return; } this.sending.set(true); - this.#api - .post('/marketing/campaigns', { - subject: this.subject, - bodyHtml: this.bodyHtml, - }) - .subscribe({ - next: (id) => { - this.#api - .post<{ sent: number }>(`/marketing/campaigns/${id}/send`, {}) - .subscribe({ - next: (res) => { - this.sending.set(false); - this.subject = ''; - this.bodyHtml = ''; - this.loadCampaigns(); - this.#toast.success('newsletter.sentOk', { - count: res.sent, - }); - }, - error: () => { - this.sending.set(false); - this.#toast.error('newsletter.errorSend'); - }, - }); - }, - error: () => { - this.sending.set(false); - this.#toast.error('newsletter.errorSend'); - }, - }); + this.#api.post('/marketing/campaigns', this.#campaignBody()).subscribe({ + next: (id) => { + this.#api + .post<{ sent: number }>(`/marketing/campaigns/${id}/send`, {}) + .subscribe({ + next: (res) => { + this.sending.set(false); + this.subject = ''; + this.bodyHtml = ''; + this.scheduleAt = ''; + this.loadCampaigns(); + this.loadSubscriberSummary(); + this.activeTab.set('campaigns'); + this.#toast.success('newsletter.sentOk', { + count: res.sent, + }); + }, + error: () => { + this.sending.set(false); + this.#toast.error('newsletter.errorSend'); + }, + }); + }, + error: () => { + this.sending.set(false); + this.#toast.error('newsletter.errorSend'); + }, + }); } -} -interface SubRow { - id: string; - email: string; - isActive: boolean; - createdAt: string; -} + exportSubscribers(): void { + const rows = this.subscribers(); + if (rows.length === 0) { + this.#toast.error('newsletter.emptySubs'); + return; + } + const csv = [ + 'email,active,createdAt', + ...rows.map( + (s) => + `${s.email},${s.isActive ? '1' : '0'},${s.createdAt}`, + ), + ].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'subscribers.csv'; + a.click(); + URL.revokeObjectURL(url); + } -interface CampRow { - id: string; - subject: string; - status: string; - sentAt: string | null; + campaignBadge(status: string): string { + const s = (status || '').toLowerCase(); + if (s === 'sent' || s === '2') { + return 'badge badge-success'; + } + if (s === 'scheduled' || s === '1') { + return 'badge badge-info'; + } + return 'badge badge-neutral'; + } + + campaignStatusKey(status: string): string { + const s = (status || '').toLowerCase(); + if (s === 'sent' || s === '2') { + return 'newsletter.status.sent'; + } + if (s === 'scheduled' || s === '1') { + return 'newsletter.status.scheduled'; + } + return 'newsletter.status.draft'; + } } @Component({ @@ -445,8 +352,10 @@ interface CampRow { imports: [ FormsModule, TranslatePipe, - AdminListToolbarComponent, - AdminPaginationComponent, + DatePipe, + LowerCasePipe, + LucideSearch, + AdminSkeletonComponent, ], templateUrl: './reviews-admin.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -454,20 +363,50 @@ interface CampRow { export class ReviewsAdminComponent implements OnInit { readonly #api = inject(ApiService); readonly #toast = inject(ToastService); + readonly reviews = signal([]); readonly loading = signal(false); readonly totalCount = signal(0); + readonly selectedIds = signal>(new Set()); + readonly replies = signal>({}); + search = ''; status: 'pending' | 'approved' | 'rejected' | 'all' = 'pending'; + starFilter: '' | '5' | '4' | '3' | 'low' = ''; page = 1; readonly pageSize = 20; + readonly totalPages = computed(() => + Math.max(1, Math.ceil(this.totalCount() / this.pageSize)), + ); + + readonly pendingHint = computed(() => + this.status === 'pending' ? this.totalCount() : 0, + ); + + readonly visibleReviews = computed(() => { + const all = this.reviews(); + switch (this.starFilter) { + case '5': + return all.filter((r) => r.rating === 5); + case '4': + return all.filter((r) => r.rating === 4); + case '3': + return all.filter((r) => r.rating === 3); + case 'low': + return all.filter((r) => r.rating <= 2); + default: + return all; + } + }); + ngOnInit(): void { this.load(); } load(): void { this.loading.set(true); + this.selectedIds.set(new Set()); const params = new URLSearchParams({ page: String(this.page), pageSize: String(this.pageSize), @@ -500,6 +439,10 @@ export class ReviewsAdminComponent implements OnInit { this.load(); } + handleStarFilterChange(): void { + /* client-side filter */ + } + handlePrevPage(): void { if (this.page > 1) { this.page -= 1; @@ -508,16 +451,38 @@ export class ReviewsAdminComponent implements OnInit { } handleNextPage(): void { - const pages = Math.max(1, Math.ceil(this.totalCount() / this.pageSize)); - if (this.page < pages) { + if (this.page < this.totalPages()) { this.page += 1; this.load(); } } + toggleSelect(id: string, checked: boolean): void { + const next = new Set(this.selectedIds()); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + this.selectedIds.set(next); + } + + setReply(id: string, value: string): void { + this.replies.update((map) => ({ ...map, [id]: value })); + } + + stars(rating: number): string { + const filled = Math.max(0, Math.min(5, Math.round(rating))); + return '★'.repeat(filled) + '☆'.repeat(5 - filled); + } + approve(id: string, approved: boolean): void { + const reply = (this.replies()[id] || '').trim(); this.#api - .put(`/reviews/${id}/approve`, { isApproved: approved }) + .put(`/reviews/${id}/approve`, { + isApproved: approved, + publicReply: reply || null, + }) .subscribe({ next: () => { this.load(); @@ -526,13 +491,37 @@ export class ReviewsAdminComponent implements OnInit { error: () => this.#toast.error('reviews.errorSave'), }); } -} -interface ReviewRow { - id: string; - productName: string; - authorName: string; - rating: number; - content: string; - status: string; + bulkApprove(): void { + const ids = [...this.selectedIds()]; + if (ids.length === 0) { + return; + } + let done = 0; + let failed = 0; + for (const id of ids) { + this.#api + .put(`/reviews/${id}/approve`, { isApproved: true }) + .subscribe({ + next: () => { + done += 1; + if (done + failed === ids.length) { + this.load(); + if (failed === 0) { + this.#toast.success('common.saved'); + } else { + this.#toast.error('reviews.errorSave'); + } + } + }, + error: () => { + failed += 1; + if (done + failed === ids.length) { + this.load(); + this.#toast.error('reviews.errorSave'); + } + }, + }); + } + } } diff --git a/src/admin/src/app/features/analytics/analytics-admin.component.html b/src/admin/src/app/features/analytics/analytics-admin.component.html new file mode 100644 index 0000000..b13cb2e --- /dev/null +++ b/src/admin/src/app/features/analytics/analytics-admin.component.html @@ -0,0 +1,200 @@ + + +@if (loading()) { +
+ +
+
+ +
+
+ +
+
+
+ +
+
+} @else if (data(); as d) { +
+
+
+
{{ 'analytics.kpiRevenue' | translate }}
+
{{ d.current.revenue | i18nCurrency }}
+ @if (deltaPercent(d.current.revenue, d.previous.revenue); as delta) { +
+ {{ delta >= 0 ? '+' : '' }}{{ delta }}% + {{ 'analytics.vsPrevious' | translate }} +
+ } +
+
+
{{ 'analytics.kpiOrders' | translate }}
+
{{ d.current.orders }}
+ @if (deltaPercent(d.current.orders, d.previous.orders); as delta) { +
+ {{ delta >= 0 ? '+' : '' }}{{ delta }}% + {{ 'analytics.vsPrevious' | translate }} +
+ } +
+
+
{{ 'analytics.kpiAov' | translate }}
+
{{ d.current.averageOrderValue | i18nCurrency }}
+
+
+
{{ 'analytics.kpiUnits' | translate }}
+
{{ d.current.unitsSold }}
+
+
+ +
+
+
+

{{ 'analytics.trendTitle' | translate }}

+
+
+
+ +
+
+
+ +
+
+

{{ 'analytics.byChannel' | translate }}

+
+
+ @if (d.byChannel.length === 0) { +

{{ 'analytics.empty' | translate }}

+ } @else { + + + + + + + + + + @for (c of d.byChannel; track c.source) { + + + + + + } + +
{{ 'analytics.channel' | translate }}{{ 'analytics.kpiOrders' | translate }}{{ 'analytics.kpiRevenue' | translate }}
+ + {{ 'orders.source.' + c.source | translate }} + + {{ c.orders }}{{ c.revenue | i18nCurrency }}
+ } +
+
+
+ +
+
+

{{ 'analytics.topProducts' | translate }}

+ {{ 'analytics.topProductsHint' | translate }} +
+
+ @if (d.topProducts.length === 0) { +

{{ 'analytics.empty' | translate }}

+ } @else { + + + + + + + + + + + @for (p of d.topProducts; track p.productId + p.sku) { + + + + + + + } + +
{{ 'analytics.colProduct' | translate }}{{ 'analytics.colUnits' | translate }}{{ 'analytics.kpiRevenue' | translate }}{{ 'analytics.colShare' | translate }}
+
{{ p.productName }}
+ @if (p.sku) { +
{{ p.sku }}
+ } +
{{ p.unitsSold }}{{ p.revenue | i18nCurrency }} +
+
+
+
+ {{ p.sharePercent }}% +
+
+ } +
+
+
+} diff --git a/src/admin/src/app/features/analytics/analytics-admin.component.ts b/src/admin/src/app/features/analytics/analytics-admin.component.ts new file mode 100644 index 0000000..3613886 --- /dev/null +++ b/src/admin/src/app/features/analytics/analytics-admin.component.ts @@ -0,0 +1,223 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + inject, + OnDestroy, + OnInit, + signal, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + BarController, + BarElement, + CategoryScale, + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, +} from 'chart.js'; +import { ApiService } from '../../core/api.service'; +import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; +import { ToastService } from '../../shared/toast.service'; + +Chart.register( + BarController, + BarElement, + LineController, + LineElement, + PointElement, + CategoryScale, + LinearScale, + Tooltip, + Legend, +); + +export type OrderSourceFilter = '' | 'shop' | 'allegro' | 'amazon' | 'manual'; + +interface AnalyticsKpi { + revenue: number; + orders: number; + averageOrderValue: number; + unitsSold: number; +} + +interface AnalyticsTrendPoint { + date: string; + revenue: number; + orders: number; +} + +interface AnalyticsTopProduct { + productId: string; + productName: string; + sku?: string | null; + unitsSold: number; + revenue: number; + sharePercent: number; +} + +interface AnalyticsChannelBreakdown { + source: string; + orders: number; + revenue: number; +} + +interface AnalyticsDto { + current: AnalyticsKpi; + previous: AnalyticsKpi; + trend: AnalyticsTrendPoint[]; + topProducts: AnalyticsTopProduct[]; + byChannel: AnalyticsChannelBreakdown[]; +} + +@Component({ + selector: 'app-analytics-admin', + standalone: true, + imports: [ + FormsModule, + TranslatePipe, + I18nCurrencyPipe, + AdminSkeletonComponent, + ], + templateUrl: './analytics-admin.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnalyticsAdminComponent implements OnInit, OnDestroy { + readonly #api = inject(ApiService); + readonly #toast = inject(ToastService); + + readonly loading = signal(false); + readonly data = signal(null); + + days: 7 | 30 | 90 = 30; + source: OrderSourceFilter = ''; + + private readonly revenueCanvas = + viewChild>('revenueCanvas'); + private chart: Chart | null = null; + + constructor() { + effect(() => { + const dto = this.data(); + const canvas = this.revenueCanvas(); + if (!dto || !canvas) { + return; + } + this.#renderChart(canvas.nativeElement, dto.trend); + }); + } + + ngOnInit(): void { + this.load(); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + this.chart = null; + } + + load(): void { + this.loading.set(true); + const params = new URLSearchParams({ days: String(this.days) }); + if (this.source) { + params.set('source', this.source); + } + this.#api + .get(`/admin/analytics?${params.toString()}`) + .subscribe({ + next: (res) => { + this.data.set(res); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.#toast.error('analytics.errorLoad'); + }, + }); + } + + handleFilterChange(): void { + this.load(); + } + + deltaPercent(current: number, previous: number): number | null { + if (previous === 0) { + return current === 0 ? 0 : null; + } + return Math.round(((current - previous) / previous) * 1000) / 10; + } + + #renderChart( + canvas: HTMLCanvasElement, + trend: AnalyticsTrendPoint[], + ): void { + const labels = trend.map((p) => p.date.slice(5)); + const revenue = trend.map((p) => p.revenue); + const orders = trend.map((p) => p.orders); + + if (this.chart) { + this.chart.data.labels = labels; + this.chart.data.datasets[0].data = revenue; + this.chart.data.datasets[1].data = orders; + this.chart.update(); + return; + } + + this.chart = new Chart(canvas, { + type: 'bar', + data: { + labels, + datasets: [ + { + type: 'bar', + label: 'Revenue', + data: revenue, + backgroundColor: 'rgba(255, 188, 57, 0.55)', + borderColor: '#FFBC39', + borderWidth: 1, + yAxisID: 'y', + order: 2, + }, + { + type: 'line', + label: 'Orders', + data: orders, + borderColor: '#1f2937', + backgroundColor: '#1f2937', + tension: 0.25, + yAxisID: 'y1', + order: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + scales: { + y: { + position: 'left', + beginAtZero: true, + grid: { color: 'rgba(0,0,0,0.06)' }, + }, + y1: { + position: 'right', + beginAtZero: true, + grid: { drawOnChartArea: false }, + }, + }, + plugins: { + legend: { display: true, position: 'bottom' }, + }, + }, + }); + } +} diff --git a/src/admin/src/app/features/categories/categories-admin.component.html b/src/admin/src/app/features/categories/categories-admin.component.html new file mode 100644 index 0000000..63872ed --- /dev/null +++ b/src/admin/src/app/features/categories/categories-admin.component.html @@ -0,0 +1,232 @@ + + +@if (error()) { +
+ {{ error() | translate }} +
+} + +
+
+
+

{{ 'categories.tree' | translate }}

+ {{ categories().length }} +
+ @if (loading()) { +
+ +
+ } @else if (tree().length === 0) { +
{{ 'categories.empty' | translate }}
+ } @else { +
+ @for (root of tree(); track root.category.id) { +
+ @if (root.children.length > 0) { + + } @else { + · + } +
+
{{ root.category.name }}
+
slug: {{ root.category.slug }}
+
+ + {{ root.category.productCount ?? 0 }} + + +
+ + @if (root.children.length > 0 && !isCollapsed(root.category.id)) { +
+ @for (child of root.children; track child.category.id) { +
+ @if (child.children.length > 0) { + + } @else { + · + } +
+
{{ child.category.name }}
+
{{ child.category.slug }}
+
+ + {{ child.category.productCount ?? 0 }} + + +
+ + @if ( + child.children.length > 0 && !isCollapsed(child.category.id) + ) { +
+ @for (grand of child.children; track grand.category.id) { +
+ · +
+
{{ grand.category.name }}
+
{{ grand.category.slug }}
+
+ + {{ grand.category.productCount ?? 0 }} + +
+ } +
+ } + } +
+ } + } +
+ } +
+ +
+
+

+ @if (isCreating()) { + @if (editParentId) { + {{ 'categories.createChild' | translate }} + } @else { + {{ 'categories.createRoot' | translate }} + } + } @else { + {{ 'categories.edit' | translate }} + } +

+ @if (!isCreating() && selectedId()) { + + } +
+ @if (loading()) { +
+ +
+ } @else { +
+
+ + +
+
+ + +
+
+ + + {{ 'categories.rootHint' | translate }} +
+
+ + +
+
+ + + {{ 'categories.viewProducts' | translate }} + +
+
+ } +
+
diff --git a/src/admin/src/app/features/categories/categories-admin.component.ts b/src/admin/src/app/features/categories/categories-admin.component.ts new file mode 100644 index 0000000..99666a7 --- /dev/null +++ b/src/admin/src/app/features/categories/categories-admin.component.ts @@ -0,0 +1,279 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { LucidePlus } from '@lucide/angular'; +import { ApiService } from '../../core/api.service'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { CategoryDto } from '../products/product.models'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; +import { ToastService } from '../../shared/toast.service'; + +interface CategoryTreeNode { + category: CategoryDto; + children: CategoryTreeNode[]; +} + +@Component({ + selector: 'app-categories-admin', + standalone: true, + imports: [ + FormsModule, + TranslatePipe, + RouterLink, + LucidePlus, + AdminSkeletonComponent, + ], + templateUrl: './categories-admin.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CategoriesAdminComponent implements OnInit { + readonly #api = inject(ApiService); + readonly #toast = inject(ToastService); + + readonly categories = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly error = signal(''); + readonly selectedId = signal(null); + readonly isCreating = signal(false); + readonly collapsedIds = signal>(new Set()); + + readonly tree = computed(() => this.#buildTree(this.categories())); + + editName = ''; + editSlug = ''; + editDescription = ''; + editParentId: string | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(''); + this.#api.get('/categories').subscribe({ + next: (cats) => { + this.categories.set(cats); + this.loading.set(false); + const id = this.selectedId(); + if (id) { + const found = cats.find((c) => c.id === id); + if (found) { + this.#fillForm(found); + } else if (cats.length > 0 && !this.isCreating()) { + this.selectCategory(cats[0]); + } + } else if (cats.length > 0 && !this.isCreating()) { + const roots = this.#buildTree(cats); + this.selectCategory(roots[0]?.category ?? cats[0]); + } + }, + error: () => { + this.loading.set(false); + this.error.set('categories.errorLoad'); + }, + }); + } + + selectCategory(cat: CategoryDto): void { + this.isCreating.set(false); + this.selectedId.set(cat.id); + this.#fillForm(cat); + } + + startCreate(): void { + this.isCreating.set(true); + this.selectedId.set(null); + this.editName = ''; + this.editSlug = ''; + this.editDescription = ''; + this.editParentId = null; + } + + startCreateChild(parent: CategoryDto, event: Event): void { + event.stopPropagation(); + this.isCreating.set(true); + this.selectedId.set(null); + this.editName = ''; + this.editSlug = ''; + this.editDescription = ''; + this.editParentId = parent.id; + } + + toggleCollapse(id: string, event: Event): void { + event.stopPropagation(); + const next = new Set(this.collapsedIds()); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + this.collapsedIds.set(next); + } + + isCollapsed(id: string): boolean { + return this.collapsedIds().has(id); + } + + parentOptions(): CategoryDto[] { + const id = this.selectedId(); + const blocked = new Set(); + if (id) { + blocked.add(id); + this.#collectDescendantIds(id, blocked); + } + return this.categories() + .filter((c) => !blocked.has(c.id)) + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + } + + deleteSelected(): void { + const id = this.selectedId(); + if (!id || this.isCreating()) { + return; + } + this.saving.set(true); + this.#api.delete(`/categories/${id}`).subscribe({ + next: () => { + this.saving.set(false); + this.selectedId.set(null); + this.#toast.success('common.deleted'); + this.load(); + }, + error: () => { + this.saving.set(false); + this.#toast.error('categories.errorDelete'); + }, + }); + } + + handleNameChange(): void { + if (this.isCreating() && !this.editSlug.trim()) { + this.editSlug = this.#slugify(this.editName); + } + } + + save(): void { + const name = this.editName.trim(); + const slug = this.editSlug.trim().toLowerCase(); + if (!name || !slug) { + this.#toast.error('categories.errorValidation'); + return; + } + + this.saving.set(true); + const description = this.editDescription.trim() || null; + const parentCategoryId = this.editParentId || null; + + if (this.isCreating()) { + this.#api + .post('/categories', { + name, + slug, + description, + parentCategoryId, + }) + .subscribe({ + next: (id) => { + this.saving.set(false); + this.#toast.success('common.saved'); + this.isCreating.set(false); + this.selectedId.set(id); + this.load(); + }, + error: () => { + this.saving.set(false); + this.#toast.error('categories.errorSave'); + }, + }); + return; + } + + const id = this.selectedId(); + if (!id) { + this.saving.set(false); + return; + } + + this.#api + .put(`/categories/${id}`, { + name, + slug, + description, + parentCategoryId, + }) + .subscribe({ + next: () => { + this.saving.set(false); + this.#toast.success('common.saved'); + this.load(); + }, + error: () => { + this.saving.set(false); + this.#toast.error('categories.errorSave'); + }, + }); + } + + #buildTree(cats: CategoryDto[]): CategoryTreeNode[] { + const byId = new Map(cats.map((c) => [c.id, c])); + const childrenMap = new Map(); + + for (const cat of cats) { + const parentId = + cat.parentCategoryId && byId.has(cat.parentCategoryId) + ? cat.parentCategoryId + : null; + const list = childrenMap.get(parentId) ?? []; + list.push(cat); + childrenMap.set(parentId, list); + } + + const build = (parentId: string | null): CategoryTreeNode[] => { + const list = (childrenMap.get(parentId) ?? []) + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + return list.map((category) => ({ + category, + children: build(category.id), + })); + }; + + return build(null); + } + + #collectDescendantIds(id: string, into: Set): void { + for (const cat of this.categories()) { + if (cat.parentCategoryId === id && !into.has(cat.id)) { + into.add(cat.id); + this.#collectDescendantIds(cat.id, into); + } + } + } + + #fillForm(cat: CategoryDto): void { + this.editName = cat.name; + this.editSlug = cat.slug; + this.editDescription = cat.description ?? ''; + this.editParentId = cat.parentCategoryId ?? null; + } + + #slugify(value: string): string { + return value + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + } +} diff --git a/src/admin/src/app/features/customers/customers-admin.component.html b/src/admin/src/app/features/customers/customers-admin.component.html new file mode 100644 index 0000000..ec2593a --- /dev/null +++ b/src/admin/src/app/features/customers/customers-admin.component.html @@ -0,0 +1,169 @@ + + +@if (error()) { +
+ {{ error() | translate }} +
+} + +
+
+
+
+ + +
+
+ + @if (loading()) { +
+ +
+ } @else if (filtered().length === 0) { +
{{ 'customers.empty' | translate }}
+ } @else { +
+ + + + + + + + + + + @for (c of filtered(); track c.key) { + + + + + + + } + +
{{ 'customers.colCustomer' | translate }}{{ 'customers.colOrders' | translate }}{{ 'customers.colLtv' | translate }}{{ 'customers.colLastOrder' | translate }}
+
{{ displayName(c) }}
+
{{ c.email }}
+
{{ c.orderCount }}{{ c.ltv | i18nCurrency }} + {{ c.lastOrderAt | date: 'd MMM' }} +
+
+ + } +
+ +
+ @if (selected(); as cust) { +
+
+

{{ displayName(cust) }}

+ {{ initials(cust) }} +
+
+
{{ 'customers.email' | translate }}
+
{{ cust.email }}
+
+ {{ 'customers.colOrders' | translate }} +
+
{{ cust.orderCount }}
+
+ {{ 'customers.colLtv' | translate }} +
+
{{ cust.ltv | i18nCurrency }}
+
+ {{ 'customers.colLastOrder' | translate }} +
+
+ + #{{ cust.lastOrderNumber }} + + · {{ cust.lastOrderAt | date: 'd MMM y' }} +
+
+
+ +
+
+

{{ 'customers.orderHistory' | translate }}

+
+
+ + + + + + + + + + @for (o of cust.orders; track o.id) { + + + + + + } + +
{{ 'customers.colOrder' | translate }}{{ 'customers.colTotal' | translate }}{{ 'orders.status' | translate }}
+ + #{{ o.orderNumber }} + + {{ o.total | i18nCurrency }} + + {{ 'orders.status.' + o.status | translate }} + +
+
+
+ } @else { +
+ {{ 'customers.selectPrompt' | translate }} +
+ } +
+
diff --git a/src/admin/src/app/features/customers/customers-admin.component.ts b/src/admin/src/app/features/customers/customers-admin.component.ts new file mode 100644 index 0000000..43935e2 --- /dev/null +++ b/src/admin/src/app/features/customers/customers-admin.component.ts @@ -0,0 +1,231 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { LucideSearch } from '@lucide/angular'; +import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { ApiService } from '../../core/api.service'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; + +interface PaginatedCustomers { + items: AdminCustomerDto[]; + totalCount: number; + page: number; + pageSize: number; +} + +interface AdminCustomerDto { + email: string; + firstName: string; + lastName: string; + phone?: string | null; + orderCount: number; + ltv: number; + lastOrderAt: string; + lastOrderId: string; + lastOrderNumber: string; +} + +interface AdminCustomerDetailDto extends AdminCustomerDto { + shippingStreet?: string | null; + shippingCity?: string | null; + shippingPostalCode?: string | null; + shippingCountry?: string | null; + orders: Array<{ + id: string; + orderNumber: string; + status: string; + total: number; + createdAt: string; + }>; +} + +export interface CustomerRow { + key: string; + email: string; + firstName: string; + lastName: string; + phone?: string | null; + orderCount: number; + ltv: number; + lastOrderAt: string; + lastOrderId: string; + lastOrderNumber: string; + orders: AdminCustomerDetailDto['orders']; +} + +@Component({ + selector: 'app-customers-admin', + standalone: true, + imports: [ + FormsModule, + DatePipe, + TranslatePipe, + I18nCurrencyPipe, + RouterLink, + LucideSearch, + AdminSkeletonComponent, + ], + templateUrl: './customers-admin.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomersAdminComponent implements OnInit { + readonly #api = inject(ApiService); + + readonly customers = signal([]); + readonly loading = signal(false); + readonly error = signal(''); + readonly selectedKey = signal(null); + readonly totalCount = signal(0); + search = ''; + page = 1; + readonly pageSize = 20; + + readonly filtered = computed(() => this.customers()); + + readonly selected = computed(() => { + const key = this.selectedKey(); + if (!key) { + return null; + } + return this.customers().find((c) => c.key === key) ?? null; + }); + + readonly totalPages = computed(() => + Math.max(1, Math.ceil(this.totalCount() / this.pageSize)), + ); + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(''); + const params = new URLSearchParams({ + page: String(this.page), + pageSize: String(this.pageSize), + sortBy: 'lastOrder', + sortDir: 'desc', + }); + if (this.search.trim()) { + params.set('search', this.search.trim()); + } + + this.#api + .get(`/admin/customers?${params}`) + .subscribe({ + next: (res) => { + const rows = res.items.map((c) => this.#toRow(c)); + this.customers.set(rows); + this.totalCount.set(res.totalCount); + if (!this.selectedKey() && rows.length > 0) { + this.selectCustomer(rows[0]); + } else if (this.selectedKey()) { + const key = this.selectedKey()!; + const found = rows.find((r) => r.key === key); + if (found) { + this.selectCustomer(found); + } + } + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.error.set('customers.errorLoad'); + }, + }); + } + + handleSearch(): void { + this.page = 1; + this.load(); + } + + handlePrevPage(): void { + if (this.page > 1) { + this.page -= 1; + this.load(); + } + } + + handleNextPage(): void { + if (this.page < this.totalPages()) { + this.page += 1; + this.load(); + } + } + + selectCustomer(customer: CustomerRow): void { + this.selectedKey.set(customer.key); + this.#api + .get( + `/admin/customers/detail?email=${encodeURIComponent(customer.email)}`, + ) + .subscribe({ + next: (detail) => { + const row = this.#toRow(detail, detail.orders); + this.customers.update((list) => + list.map((c) => (c.key === row.key ? row : c)), + ); + }, + }); + } + + initials(customer: CustomerRow): string { + const a = customer.firstName?.charAt(0) ?? ''; + const b = customer.lastName?.charAt(0) ?? ''; + const value = `${a}${b}`.toUpperCase(); + return value || customer.email.charAt(0).toUpperCase(); + } + + displayName(customer: CustomerRow): string { + const name = `${customer.firstName} ${customer.lastName}`.trim(); + return name || customer.email; + } + + statusBadge(status: string): string { + switch (status) { + case 'pendingPayment': + return 'badge badge-warning'; + case 'paid': + case 'processing': + return 'badge badge-info'; + case 'shipped': + case 'delivered': + return 'badge badge-success'; + case 'cancelled': + case 'refunded': + return 'badge badge-danger'; + default: + return 'badge badge-neutral'; + } + } + + #toRow( + c: AdminCustomerDto, + orders: AdminCustomerDetailDto['orders'] = [], + ): CustomerRow { + return { + key: c.email.toLowerCase(), + email: c.email, + firstName: c.firstName, + lastName: c.lastName, + phone: c.phone, + orderCount: c.orderCount, + ltv: c.ltv, + lastOrderAt: c.lastOrderAt, + lastOrderId: c.lastOrderId, + lastOrderNumber: c.lastOrderNumber, + orders, + }; + } +} diff --git a/src/admin/src/app/features/dashboard/dashboard.component.html b/src/admin/src/app/features/dashboard/dashboard.component.html new file mode 100644 index 0000000..20700f9 --- /dev/null +++ b/src/admin/src/app/features/dashboard/dashboard.component.html @@ -0,0 +1,220 @@ + + +@if (loading()) { +
+ +
+ +
+
+
+ +
+
+ +
+
+
+} + +@if (error()) { + +} + +@if (stats(); as s) { + + + + +
+
+
+

{{ 'dashboard.needsAction' | translate }}

+ + {{ 'common.all' | translate }} + +
+
+ + + + + + + + + + @for (o of actionOrders(); track o.id) { + + + + + + } @empty { + + + + } + +
{{ 'orders.order' | translate }}{{ 'orders.status' | translate }}
+
{{ o.orderNumber }}
+
{{ o.customerEmail }}
+
+ + {{ 'orders.status.' + o.status | translate }} + + + + {{ 'common.open' | translate }} + +
+ {{ 'dashboard.noActionOrders' | translate }} +
+
+
+ +
+
+

{{ 'dashboard.lowStock' | translate }}

+ + {{ 'nav.inventory' | translate }} + +
+
+ + + + + + + + + + @for (p of lowStock(); track p.id) { + + + + + + } @empty { + + + + } + +
SKU{{ 'products.totalStock' | translate }}
+
{{ p.name }}
+
{{ productCode(p) }}
+
+ + {{ productStock(p) }} + + + + {{ 'dashboard.restock' | translate }} + +
+ {{ 'dashboard.noLowStock' | translate }} +
+
+
+
+ +
+ + +
+
+

{{ 'dashboard.pendingReviews' | translate }}

+ + {{ 'dashboard.moderate' | translate }} + +
+ @for (r of pendingReviews(); track r.id) { +
+
+
+
+
{{ r.productName || '—' }}
+
{{ stars(r.rating) }}
+
“{{ r.content }}”
+
+ + {{ 'dashboard.moderate' | translate }} + +
+
+ } @empty { +
+ {{ 'dashboard.noPendingReviews' | translate }} +
+ } +
+
+} diff --git a/src/admin/src/app/features/dashboard/dashboard.component.ts b/src/admin/src/app/features/dashboard/dashboard.component.ts new file mode 100644 index 0000000..a704eee --- /dev/null +++ b/src/admin/src/app/features/dashboard/dashboard.component.ts @@ -0,0 +1,157 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, + signal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { ApiService } from '../../core/api.service'; +import { ChartPoint, OrdersChartComponent } from '../orders-chart.component'; +import { + AdminOrder, + AdminOrdersResult, +} from '../orders/order.models'; +import { AdminProduct } from '../products/product.models'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; + +interface DashboardStats { + ordersToday: number; + revenueToday: number; + lowStockCount: number; + pendingOrders: number; +} + +interface PaginatedProducts { + items: AdminProduct[]; + totalCount: number; +} + +interface ReviewRow { + id: string; + productName: string; + authorName: string; + rating: number; + content: string; +} + +interface PaginatedReviews { + items: ReviewRow[]; + totalCount: number; +} + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [ + I18nCurrencyPipe, + TranslatePipe, + OrdersChartComponent, + RouterLink, + AdminSkeletonComponent, + ], + templateUrl: './dashboard.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardComponent implements OnInit { + readonly #api = inject(ApiService); + readonly stats = signal(null); + readonly chart = signal([]); + readonly actionOrders = signal([]); + readonly lowStock = signal([]); + readonly pendingReviews = signal([]); + readonly pendingReviewCount = signal(0); + readonly loading = signal(false); + readonly error = signal(''); + + ngOnInit(): void { + this.loading.set(true); + this.#api.get('/admin/dashboard').subscribe({ + next: (s) => { + this.stats.set(s); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.error.set('dashboard.errorLoad'); + }, + }); + + this.#api + .get('/admin/dashboard/orders-chart?days=14') + .subscribe({ + next: (c) => this.chart.set(c), + }); + + this.#api + .get('/orders/admin?page=1&pageSize=5&sortBy=date&sortDir=desc') + .subscribe({ + next: (res) => this.actionOrders.set(res.items), + }); + + this.#api + .get('/admin/products?page=1&pageSize=5&lowStock=true') + .subscribe({ + next: (res) => this.lowStock.set(res.items), + }); + + this.#api + .get('/reviews?page=1&pageSize=3&status=pending') + .subscribe({ + next: (res) => { + this.pendingReviews.set(res.items); + this.pendingReviewCount.set(res.totalCount); + }, + }); + } + + aov(stats: DashboardStats): number { + if (!stats.ordersToday) { + return 0; + } + return stats.revenueToday / stats.ordersToday; + } + + stars(rating: number): string { + const filled = Math.max(0, Math.min(5, Math.round(rating))); + return '★'.repeat(filled) + '☆'.repeat(5 - filled); + } + + statusBadge(status: string): string { + switch (status) { + case 'pendingPayment': + return 'badge badge-warning'; + case 'paid': + case 'processing': + return 'badge badge-info'; + case 'shipped': + case 'delivered': + return 'badge badge-success'; + case 'cancelled': + case 'refunded': + return 'badge badge-danger'; + default: + return 'badge badge-neutral'; + } + } + + stockBadge(qty: number): string { + if (qty <= 2) { + return 'badge badge-danger'; + } + if (qty <= 5) { + return 'badge badge-warning'; + } + return 'badge badge-neutral'; + } + + productCode(p: AdminProduct): string { + return p.model || p.slug || '—'; + } + + productStock(p: AdminProduct): number { + return p.totalStock ?? 0; + } +} diff --git a/src/admin/src/app/features/inventory/inventory-admin.component.html b/src/admin/src/app/features/inventory/inventory-admin.component.html new file mode 100644 index 0000000..78b3e94 --- /dev/null +++ b/src/admin/src/app/features/inventory/inventory-admin.component.html @@ -0,0 +1,246 @@ + + +@if (error()) { +
+ {{ error() | translate }} +
+} + +
+ + + {{ lowStockRows().length }} + {{ 'inventory.alertLow' | translate }} + + + + {{ reservedTotal() }} + {{ 'inventory.alertReserved' | translate }} + +
+ +
+ + +
+ +@if (loading()) { +
+ +
+} @else if (activeTab() === 'alerts') { +
+ @if (lowStockRows().length === 0) { +
{{ 'inventory.noLowStock' | translate }}
+ } @else { +
+ + + + + + + + @for (w of warehouses(); track w.id) { + + } + + + + + @for (row of lowStockRows(); track row.variantId) { + + + + + + @for (w of warehouses(); track w.id) { + + } + + + } + +
{{ 'inventory.colProduct' | translate }}{{ 'inventory.colAvailable' | translate }}{{ 'inventory.colReserved' | translate }}{{ 'inventory.colThreshold' | translate }}{{ w.code }}
+
{{ row.productName }}
+
{{ row.sku }}
+
+ + {{ row.available }} + + {{ row.reserved }}{{ threshold() }}{{ stockFor(row, w.id) }} + + + {{ 'inventory.product' | translate }} + +
+
+ } +
+} @else { +
+
+
+ + +
+
+ {{ 'inventory.availableHint' | translate }} +
+
+ + + + + + + @for (w of warehouses(); track w.id) { + + } + + + + + @for (row of filteredRows(); track row.variantId) { + + + + + @for (w of warehouses(); track w.id) { + + } + + + } + +
{{ 'inventory.colProduct' | translate }}{{ 'inventory.colAvailable' | translate }}{{ 'inventory.colReserved' | translate }}{{ w.code }}
+
{{ row.productName }}
+
{{ row.sku }}
+
+ + {{ row.available }} + + {{ row.reserved }}{{ stockFor(row, w.id) }} + +
+
+
+} + +@if (adjusting(); as row) { +
+
+

+ {{ 'inventory.quickAdjust' | translate }} · {{ row.productName }} +

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+} diff --git a/src/admin/src/app/features/inventory/inventory-admin.component.ts b/src/admin/src/app/features/inventory/inventory-admin.component.ts new file mode 100644 index 0000000..2d2e2fe --- /dev/null +++ b/src/admin/src/app/features/inventory/inventory-admin.component.ts @@ -0,0 +1,215 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { LucideSearch } from '@lucide/angular'; +import { ApiService } from '../../core/api.service'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { + AdminProduct, + WarehouseDto, + WarehouseQuantity, +} from '../products/product.models'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; +import { ToastService } from '../../shared/toast.service'; +import { LOW_STOCK_THRESHOLD_KEY } from '../settings/settings.storage'; + +interface PaginatedProducts { + items: AdminProduct[]; + totalCount: number; +} + +export interface InventoryRow { + productId: string; + productName: string; + variantId: string; + sku: string; + available: number; + reserved: number; + stocks: Record; +} + +type InventoryTab = 'alerts' | 'matrix'; + +@Component({ + selector: 'app-inventory-admin', + standalone: true, + imports: [ + FormsModule, + TranslatePipe, + RouterLink, + LucideSearch, + AdminSkeletonComponent, + ], + templateUrl: './inventory-admin.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InventoryAdminComponent implements OnInit { + readonly #api = inject(ApiService); + readonly #toast = inject(ToastService); + + readonly warehouses = signal([]); + readonly rows = signal([]); + readonly loading = signal(false); + readonly saving = signal(false); + readonly error = signal(''); + readonly activeTab = signal('alerts'); + readonly adjusting = signal(null); + + search = ''; + adjustWarehouseId = ''; + adjustQty = 0; + adjustReason = ''; + + readonly threshold = signal(this.#readThreshold()); + + readonly filteredRows = computed(() => { + const q = this.search.trim().toLowerCase(); + const all = this.rows(); + if (!q) { + return all; + } + return all.filter( + (r) => + r.productName.toLowerCase().includes(q) || + r.sku.toLowerCase().includes(q), + ); + }); + + readonly lowStockRows = computed(() => { + const t = this.threshold(); + return this.filteredRows().filter((r) => r.available <= t); + }); + + readonly reservedTotal = computed(() => + this.rows().reduce((sum, r) => sum + r.reserved, 0), + ); + + ngOnInit(): void { + this.#api.get('/admin/warehouses').subscribe({ + next: (w) => { + this.warehouses.set(w); + if (w.length > 0) { + this.adjustWarehouseId = w[0].id; + } + }, + }); + this.load(); + } + + setTab(tab: InventoryTab): void { + this.activeTab.set(tab); + } + + load(): void { + this.loading.set(true); + this.error.set(''); + this.threshold.set(this.#readThreshold()); + this.#api + .get('/admin/products?page=1&pageSize=200&sortBy=name') + .subscribe({ + next: (res) => { + this.rows.set(this.#flatten(res.items)); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.error.set('inventory.errorLoad'); + }, + }); + } + + stockFor(row: InventoryRow, warehouseId: string): number { + return row.stocks[warehouseId] ?? 0; + } + + stockBadge(qty: number): string { + const t = this.threshold(); + if (qty <= 0) { + return 'badge badge-danger'; + } + if (qty <= t) { + return 'badge badge-warning'; + } + return 'badge badge-neutral'; + } + + openAdjust(row: InventoryRow): void { + this.adjusting.set(row); + const wh = this.warehouses(); + this.adjustWarehouseId = wh[0]?.id ?? ''; + this.adjustQty = this.stockFor(row, this.adjustWarehouseId); + this.adjustReason = ''; + } + + cancelAdjust(): void { + this.adjusting.set(null); + } + + applyAdjust(): void { + const row = this.adjusting(); + if (!row || !this.adjustWarehouseId) { + return; + } + const qty = Math.max(0, Math.floor(Number(this.adjustQty) || 0)); + const quantities: WarehouseQuantity[] = this.warehouses().map((w) => ({ + warehouseId: w.id, + quantity: + w.id === this.adjustWarehouseId + ? qty + : this.stockFor(row, w.id), + })); + + this.saving.set(true); + this.#api + .put(`/products/variants/${row.variantId}/warehouse-stock`, { + quantities, + }) + .subscribe({ + next: () => { + this.saving.set(false); + this.#toast.success('common.saved'); + this.adjusting.set(null); + this.load(); + }, + error: () => { + this.saving.set(false); + this.#toast.error('inventory.errorSave'); + }, + }); + } + + #flatten(products: AdminProduct[]): InventoryRow[] { + const result: InventoryRow[] = []; + for (const p of products) { + for (const v of p.variants) { + const stocks: Record = {}; + for (const ws of v.warehouseStocks) { + stocks[ws.warehouseId] = ws.quantity; + } + result.push({ + productId: p.id, + productName: p.name, + variantId: v.id, + sku: v.sku, + available: Math.max(0, v.totalStock - v.reservedStock), + reserved: v.reservedStock, + stocks, + }); + } + } + return result.sort((a, b) => a.available - b.available); + } + + #readThreshold(): number { + const raw = localStorage.getItem(LOW_STOCK_THRESHOLD_KEY); + const n = raw ? Number(raw) : 5; + return Number.isFinite(n) && n >= 0 ? n : 5; + } +} diff --git a/src/admin/src/app/features/newsletter-admin.component.html b/src/admin/src/app/features/newsletter-admin.component.html index 7fc629e..b04d69c 100644 --- a/src/admin/src/app/features/newsletter-admin.component.html +++ b/src/admin/src/app/features/newsletter-admin.component.html @@ -1,140 +1,323 @@ -

{{ 'newsletter.title' | translate }}

- -

{{ 'newsletter.send' | translate }}

-
- - - -
- -

- {{ 'newsletter.subscribers' | translate }} ({{ subTotal() }}) -

- -
- - +