the first commit
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
.vscode/tasks.json
vendored
Normal file
42
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
301
API.md
Normal file
301
API.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# API Documentation #
|
||||||
|
|
||||||
|
Simple endpoint reference for the Marketplace Backoffice.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
### Get All Projects
|
||||||
|
```
|
||||||
|
GET /api/projects
|
||||||
|
|
||||||
|
Response:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "dexar",
|
||||||
|
"name": "dexar",
|
||||||
|
"displayName": "Dexar Marketplace",
|
||||||
|
"active": true,
|
||||||
|
"logoUrl": "https://..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
### Get Categories for Project
|
||||||
|
```
|
||||||
|
GET /api/projects/:projectId/categories
|
||||||
|
|
||||||
|
Response:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cat1",
|
||||||
|
"name": "Electronics",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 1,
|
||||||
|
"img": "https://...",
|
||||||
|
"projectId": "dexar",
|
||||||
|
"subcategories": [...]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Category
|
||||||
|
```
|
||||||
|
GET /api/categories/:categoryId
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "cat1",
|
||||||
|
"name": "Electronics",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 1,
|
||||||
|
"img": "https://...",
|
||||||
|
"projectId": "dexar",
|
||||||
|
"subcategories": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Category
|
||||||
|
```
|
||||||
|
POST /api/projects/:projectId/categories
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "New Category",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 10,
|
||||||
|
"img": "https://..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (created category object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Category
|
||||||
|
```
|
||||||
|
PATCH /api/categories/:categoryId
|
||||||
|
|
||||||
|
Body: (any field to update)
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"visible": false,
|
||||||
|
"priority": 5
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (updated category object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Category
|
||||||
|
```
|
||||||
|
DELETE /api/categories/:categoryId
|
||||||
|
|
||||||
|
Response: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subcategories
|
||||||
|
|
||||||
|
### Get Subcategories
|
||||||
|
```
|
||||||
|
GET /api/categories/:categoryId/subcategories
|
||||||
|
|
||||||
|
Response:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sub1",
|
||||||
|
"name": "Smartphones",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 1,
|
||||||
|
"img": "https://...",
|
||||||
|
"categoryId": "cat1",
|
||||||
|
"itemCount": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Subcategory
|
||||||
|
```
|
||||||
|
GET /api/subcategories/:subcategoryId
|
||||||
|
|
||||||
|
Response: (subcategory object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Subcategory
|
||||||
|
```
|
||||||
|
POST /api/categories/:categoryId/subcategories
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "New Subcategory",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 10
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (created subcategory object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Subcategory
|
||||||
|
```
|
||||||
|
PATCH /api/subcategories/:subcategoryId
|
||||||
|
|
||||||
|
Body: (any field)
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (updated subcategory object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Subcategory
|
||||||
|
```
|
||||||
|
DELETE /api/subcategories/:subcategoryId
|
||||||
|
|
||||||
|
Response: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
### Get Items (Paginated)
|
||||||
|
```
|
||||||
|
GET /api/subcategories/:subcategoryId/items?page=1&limit=20&search=phone&visible=true
|
||||||
|
|
||||||
|
Query Params:
|
||||||
|
- page: number (default: 1)
|
||||||
|
- limit: number (default: 20)
|
||||||
|
- search: string (optional - filters by name)
|
||||||
|
- visible: boolean (optional - filters by visibility)
|
||||||
|
- tags: string (optional - comma separated tags)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "item1",
|
||||||
|
"name": "iPhone 15 Pro",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 1,
|
||||||
|
"quantity": 50,
|
||||||
|
"price": 1299,
|
||||||
|
"currency": "USD",
|
||||||
|
"imgs": ["https://...", "https://..."],
|
||||||
|
"tags": ["new", "featured"],
|
||||||
|
"simpleDescription": "Latest iPhone...",
|
||||||
|
"description": [
|
||||||
|
{ "key": "Color", "value": "Black" },
|
||||||
|
{ "key": "Storage", "value": "256GB" }
|
||||||
|
],
|
||||||
|
"subcategoryId": "sub1",
|
||||||
|
"comments": [...]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 150,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Item
|
||||||
|
```
|
||||||
|
GET /api/items/:itemId
|
||||||
|
|
||||||
|
Response: (item object with all fields)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Item
|
||||||
|
```
|
||||||
|
POST /api/subcategories/:subcategoryId/items
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "New Product",
|
||||||
|
"visible": true,
|
||||||
|
"priority": 10,
|
||||||
|
"quantity": 100,
|
||||||
|
"price": 999,
|
||||||
|
"currency": "USD",
|
||||||
|
"imgs": ["https://..."],
|
||||||
|
"tags": ["new"],
|
||||||
|
"simpleDescription": "Product description",
|
||||||
|
"description": [
|
||||||
|
{ "key": "Size", "value": "Large" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (created item object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Item
|
||||||
|
```
|
||||||
|
PATCH /api/items/:itemId
|
||||||
|
|
||||||
|
Body: (any field to update)
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"price": 899,
|
||||||
|
"quantity": 80,
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: (updated item object)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Update only images**
|
||||||
|
```
|
||||||
|
PATCH /api/items/item123
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"imgs": ["https://new-image1.jpg", "https://new-image2.jpg"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Note: Send the complete array of images (not just changed ones)
|
||||||
|
Response: (updated item object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Item
|
||||||
|
```
|
||||||
|
DELETE /api/items/:itemId
|
||||||
|
|
||||||
|
Response: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Update Items
|
||||||
|
```
|
||||||
|
PATCH /api/items/bulk
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"itemIds": ["item1", "item2", "item3"],
|
||||||
|
"data": {
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upload
|
||||||
|
|
||||||
|
### Upload Image
|
||||||
|
```
|
||||||
|
POST /api/upload
|
||||||
|
|
||||||
|
Body: multipart/form-data
|
||||||
|
- image: File
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"url": "https://cdn.example.com/image.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All endpoints return JSON
|
||||||
|
- Use PATCH for partial updates
|
||||||
|
- Priority: lower numbers = appears first
|
||||||
|
- Currency codes: USD, EUR, RUB, GBP, UAH
|
||||||
|
- Images: array of URLs
|
||||||
|
- Description: array of key-value pairs
|
||||||
|
- Auto-save triggers PATCH with single field every 500ms
|
||||||
206
README.md
Normal file
206
README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Market BackOffice
|
||||||
|
|
||||||
|
A comprehensive Angular-based admin panel for managing multi-brand marketplace products, categories, and inventory.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📊 **Project Management** - Switch between multiple marketplace brands (Dexar, Novo, etc.)
|
||||||
|
- 📁 **Category Editor** - Create, edit, and organize categories with drag-and-drop priority
|
||||||
|
- 🏷️ **Subcategory Management** - Hierarchical category structure with subcategories
|
||||||
|
- 📦 **Item Editor** - Full-featured product editor with:
|
||||||
|
- Auto-save (500ms debounce)
|
||||||
|
- Image upload and management
|
||||||
|
- Rich text descriptions
|
||||||
|
- Variant management
|
||||||
|
- Tags, pricing, stock control
|
||||||
|
- Priority sorting
|
||||||
|
- 📋 **Items List** - Paginated item browsing with:
|
||||||
|
- Search functionality
|
||||||
|
- Visibility filters
|
||||||
|
- Bulk operations
|
||||||
|
- Quick edit access
|
||||||
|
- 🎨 **Material Design** - Modern Angular Material UI
|
||||||
|
- 🔄 **Mock Data Mode** - Development mode with mock data for testing
|
||||||
|
- 📱 **Responsive** - Works on desktop and tablet
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Angular 21** - Latest Angular with standalone components
|
||||||
|
- **Angular Material 21** - Material Design components
|
||||||
|
- **TypeScript** - Type-safe development
|
||||||
|
- **SCSS** - Modular styling
|
||||||
|
- **RxJS** - Reactive programming
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
See [API.md](API.md) for complete API documentation.
|
||||||
|
|
||||||
|
**Base URL**: Configured in `src/environments/environment.ts`
|
||||||
|
|
||||||
|
### Main Endpoints
|
||||||
|
- Projects: GET `/api/projects`
|
||||||
|
- Categories: GET, POST, PATCH, DELETE `/api/categories/*`
|
||||||
|
- Subcategories: GET, POST, PATCH, DELETE `/api/subcategories/*`
|
||||||
|
- Items: GET, POST, PATCH, DELETE `/api/items/*`
|
||||||
|
- Upload: POST `/api/upload` (multipart/form-data)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── components/
|
||||||
|
│ ├── confirm-dialog/ # Confirmation dialogs
|
||||||
|
│ └── create-dialog/ # Creation dialogs
|
||||||
|
├── models/
|
||||||
|
│ ├── project.model.ts # Project/Brand model
|
||||||
|
│ ├── category.model.ts # Category with subcategories
|
||||||
|
│ ├── item.model.ts # Product model
|
||||||
|
│ └── index.ts # Model exports
|
||||||
|
├── pages/
|
||||||
|
│ ├── projects-dashboard/ # Brand selection
|
||||||
|
│ ├── project-view/ # Category management
|
||||||
|
│ ├── category-editor/ # Category details editor
|
||||||
|
│ ├── subcategory-editor/ # Subcategory editor
|
||||||
|
│ ├── items-list/ # Item browsing
|
||||||
|
│ └── item-editor/ # Full item editor
|
||||||
|
├── services/
|
||||||
|
│ ├── api.service.ts # HTTP API integration
|
||||||
|
│ ├── mock-data.service.ts # Development mock data
|
||||||
|
│ └── index.ts # Service exports
|
||||||
|
└── environments/
|
||||||
|
├── environment.ts # Development config
|
||||||
|
└── environment.production.ts # Production config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# or
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# or
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/market-backOffice/` directory.
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Development (`src/environments/environment.ts`)
|
||||||
|
```typescript
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:3000/api', // Local backend
|
||||||
|
useMockData: true // Use mock data for development
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (`src/environments/environment.production.ts`)
|
||||||
|
```typescript
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: 'https://api.dexarmarket.ru/api', // Production backend
|
||||||
|
useMockData: false // Use real API
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
1. Node.js 18+ and npm installed
|
||||||
|
2. Backend API running and accessible
|
||||||
|
3. API URL configured in production environment
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Server
|
||||||
|
1. Upload contents of `dist/market-backOffice/` to your web server
|
||||||
|
2. Configure nginx or Apache to serve the files:
|
||||||
|
|
||||||
|
**Nginx Example:**
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name backoffice.dexarmarket.ru;
|
||||||
|
|
||||||
|
root /var/www/backoffice;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: Proxy API requests
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass https://api.dexarmarket.ru/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enable HTTPS with Let's Encrypt:
|
||||||
|
```bash
|
||||||
|
certbot --nginx -d backoffice.dexarmarket.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Features
|
||||||
|
|
||||||
|
### Auto-Save
|
||||||
|
- Item editor auto-saves changes after 500ms of inactivity
|
||||||
|
- Each field triggers individual PATCH requests
|
||||||
|
- Visual feedback for save operations
|
||||||
|
|
||||||
|
### Mock Data Mode
|
||||||
|
- Enable with `useMockData: true` in environment
|
||||||
|
- Simulates full API with realistic data
|
||||||
|
- Useful for frontend development without backend
|
||||||
|
|
||||||
|
### Material Design
|
||||||
|
- Consistent UI with Angular Material
|
||||||
|
- Themes can be customized in `src/styles.scss`
|
||||||
|
- Responsive components for all screen sizes
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with [Vitest](https://vitest.dev/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
# or
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Angular CLI Documentation](https://angular.dev/tools/cli)
|
||||||
|
- [Angular Material](https://material.angular.io/)
|
||||||
|
- [API Documentation](API.md)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions, contact the development team or check the main marketplace repository.
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
78
angular.json
Normal file
78
angular.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"market-backOffice": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1MB",
|
||||||
|
"maximumError": "2MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "8kB",
|
||||||
|
"maximumError": "16kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "market-backOffice:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "market-backOffice:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8691
package-lock.json
generated
Normal file
8691
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "market-back-office",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@10.9.2",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.1.0",
|
||||||
|
"@angular/cdk": "^21.1.0",
|
||||||
|
"@angular/common": "^21.0.0",
|
||||||
|
"@angular/compiler": "^21.0.0",
|
||||||
|
"@angular/core": "^21.0.0",
|
||||||
|
"@angular/forms": "^21.0.0",
|
||||||
|
"@angular/material": "^21.1.0",
|
||||||
|
"@angular/platform-browser": "^21.0.0",
|
||||||
|
"@angular/router": "^21.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.0.4",
|
||||||
|
"@angular/cli": "^21.0.4",
|
||||||
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
|
"jsdom": "^27.1.0",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
15
src/app/app.config.ts
Normal file
15
src/app/app.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideHttpClient()
|
||||||
|
]
|
||||||
|
};
|
||||||
BIN
src/app/app.html
Normal file
BIN
src/app/app.html
Normal file
Binary file not shown.
52
src/app/app.routes.ts
Normal file
52
src/app/app.routes.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { ProjectsDashboardComponent } from './pages/projects-dashboard/projects-dashboard.component';
|
||||||
|
import { ProjectViewComponent } from './pages/project-view/project-view.component';
|
||||||
|
import { CategoryEditorComponent } from './pages/category-editor/category-editor.component';
|
||||||
|
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
|
||||||
|
import { ItemsListComponent } from './pages/items-list/items-list.component';
|
||||||
|
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: ProjectsDashboardComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'project/:projectId',
|
||||||
|
component: ProjectViewComponent,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'category/:categoryId',
|
||||||
|
component: CategoryEditorComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subcategory/:subcategoryId',
|
||||||
|
component: SubcategoryEditorComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'items/:subcategoryId',
|
||||||
|
component: ItemsListComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'item/:itemId',
|
||||||
|
component: ItemEditorComponent
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'category/:categoryId',
|
||||||
|
component: CategoryEditorComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subcategory/:subcategoryId',
|
||||||
|
component: SubcategoryEditorComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'items/:subcategoryId',
|
||||||
|
component: ItemsListComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'item/:itemId',
|
||||||
|
component: ItemEditorComponent
|
||||||
|
}
|
||||||
|
];
|
||||||
0
src/app/app.scss
Normal file
0
src/app/app.scss
Normal file
23
src/app/app.spec.ts
Normal file
23
src/app/app.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', async () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
await fixture.whenStable();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, market-backOffice');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/app/app.ts
Normal file
12
src/app/app.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.scss'
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected readonly title = signal('market-backOffice');
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
|
export interface ConfirmDialogData {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
warning?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title>
|
||||||
|
@if (data.warning) {
|
||||||
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
|
}
|
||||||
|
{{ data.title }}
|
||||||
|
</h2>
|
||||||
|
<mat-dialog-content>
|
||||||
|
<p>{{ data.message }}</p>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()">
|
||||||
|
{{ data.cancelText || 'Cancel' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
[color]="data.warning ? 'warn' : 'primary'"
|
||||||
|
(click)="onConfirm()">
|
||||||
|
{{ data.confirmText || 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-content {
|
||||||
|
padding: 1rem 0;
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ConfirmDialogComponent {
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onConfirm(): void {
|
||||||
|
this.dialogRef.close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel(): void {
|
||||||
|
this.dialogRef.close(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/app/components/create-dialog/create-dialog.component.ts
Normal file
120
src/app/components/create-dialog/create-dialog.component.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Component, Inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
|
||||||
|
export interface CreateDialogData {
|
||||||
|
title: string;
|
||||||
|
type: 'category' | 'subcategory' | 'item';
|
||||||
|
fields: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'number' | 'toggle';
|
||||||
|
required?: boolean;
|
||||||
|
value?: any;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-create-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSlideToggleModule
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||||
|
<mat-dialog-content>
|
||||||
|
<div class="form-fields">
|
||||||
|
@for (field of data.fields; track field.name) {
|
||||||
|
@if (field.type === 'toggle') {
|
||||||
|
<div class="toggle-field">
|
||||||
|
<mat-slide-toggle [(ngModel)]="formData[field.name]" color="primary">
|
||||||
|
{{ field.label }}
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>{{ field.label }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[type]="field.type"
|
||||||
|
[(ngModel)]="formData[field.name]"
|
||||||
|
[required]="!!field.required">
|
||||||
|
@if (field.required && !formData[field.name]) {
|
||||||
|
<mat-error>{{ field.label }} is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-dialog-content>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="onCreate()" [disabled]="!isValid()">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.form-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 400px;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class CreateDialogComponent {
|
||||||
|
formData: any = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<CreateDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateDialogData
|
||||||
|
) {
|
||||||
|
// Initialize form data with default values
|
||||||
|
this.data.fields.forEach(field => {
|
||||||
|
this.formData[field.name] = field.value !== undefined ? field.value :
|
||||||
|
(field.type === 'toggle' ? true :
|
||||||
|
field.type === 'number' ? 0 : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid(): boolean {
|
||||||
|
return this.data.fields.every(field =>
|
||||||
|
!field.required || !!this.formData[field.name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreate(): void {
|
||||||
|
if (this.isValid()) {
|
||||||
|
this.dialogRef.close(this.formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/models/category.model.ts
Normal file
19
src/app/models/category.model.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
priority: number;
|
||||||
|
img?: string;
|
||||||
|
projectId: string;
|
||||||
|
subcategories?: Subcategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subcategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
priority: number;
|
||||||
|
img?: string;
|
||||||
|
categoryId: string;
|
||||||
|
itemCount?: number;
|
||||||
|
}
|
||||||
3
src/app/models/index.ts
Normal file
3
src/app/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './project.model';
|
||||||
|
export * from './category.model';
|
||||||
|
export * from './item.model';
|
||||||
35
src/app/models/item.model.ts
Normal file
35
src/app/models/item.model.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface Item {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
priority: number;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
imgs: string[];
|
||||||
|
tags: string[];
|
||||||
|
simpleDescription: string;
|
||||||
|
description: ItemDescriptionField[];
|
||||||
|
subcategoryId: string;
|
||||||
|
comments?: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemDescriptionField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: Date;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemsListResponse {
|
||||||
|
items: Item[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
8
src/app/models/project.model.ts
Normal file
8
src/app/models/project.model.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
theme?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
121
src/app/pages/category-editor/category-editor.component.html
Normal file
121
src/app/pages/category-editor/category-editor.component.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<div class="editor-container">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
</div>
|
||||||
|
} @else if (category()) {
|
||||||
|
<div class="editor-header">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h2>Edit Category</h2>
|
||||||
|
@if (saving()) {
|
||||||
|
<span class="save-indicator">Saving...</span>
|
||||||
|
}
|
||||||
|
<button mat-icon-button color="warn" (click)="deleteCategory()" matTooltip="Delete Category">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-content">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="category()!.name"
|
||||||
|
(blur)="onFieldChange('name', category()!.name)"
|
||||||
|
required>
|
||||||
|
@if (!category()!.name || category()!.name.trim().length === 0) {
|
||||||
|
<mat-error>Category name is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>ID</mat-label>
|
||||||
|
<input matInput [value]="category()!.id" disabled>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-slide-toggle
|
||||||
|
[(ngModel)]="category()!.visible"
|
||||||
|
(change)="onFieldChange('visible', category()!.visible)"
|
||||||
|
color="primary">
|
||||||
|
Visible
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Priority</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="category()!.priority"
|
||||||
|
(blur)="onFieldChange('priority', category()!.priority)"
|
||||||
|
required
|
||||||
|
min="0">
|
||||||
|
<mat-hint>Lower numbers appear first</mat-hint>
|
||||||
|
@if (category()!.priority < 0) {
|
||||||
|
<mat-error>Priority cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h3>Image</h3>
|
||||||
|
|
||||||
|
@if (category()!.img) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="category()!.img" [alt]="category()!.name">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="image-inputs">
|
||||||
|
<div class="upload-option">
|
||||||
|
<label for="file-upload" class="upload-label">
|
||||||
|
<mat-icon>upload_file</mat-icon>
|
||||||
|
Upload Image
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelect($event, 'file')"
|
||||||
|
hidden>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Or enter image URL</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="category()!.img || ''"
|
||||||
|
(blur)="onImageSelect($event, 'url')">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subcategories-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Subcategories ({{ category()!.subcategories?.length || 0 }})</h3>
|
||||||
|
<button mat-mini-fab color="primary" (click)="addSubcategory()" matTooltip="Add Subcategory">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (category()!.subcategories?.length) {
|
||||||
|
<mat-list>
|
||||||
|
@for (sub of category()!.subcategories; track sub.id) {
|
||||||
|
<mat-list-item (click)="openSubcategory(sub.id)">
|
||||||
|
<span matListItemTitle>{{ sub.name }}</span>
|
||||||
|
<span matListItemLine>Priority: {{ sub.priority }}</span>
|
||||||
|
<button mat-icon-button matListItemMeta>
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-list-item>
|
||||||
|
}
|
||||||
|
</mat-list>
|
||||||
|
} @else {
|
||||||
|
<p class="empty-state">No subcategories yet</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
141
src/app/pages/category-editor/category-editor.component.scss
Normal file
141
src/app/pages/category-editor/category-editor.component.scss
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.editor-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator {
|
||||||
|
color: #1976d2;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-section {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option {
|
||||||
|
.upload-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #1976d2;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcategories-section {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-list-item {
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/app/pages/category-editor/category-editor.component.ts
Normal file
187
src/app/pages/category-editor/category-editor.component.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Component, OnInit, signal, effect } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Category } from '../../models';
|
||||||
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatListModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
templateUrl: './category-editor.component.html',
|
||||||
|
styleUrls: ['./category-editor.component.scss']
|
||||||
|
})
|
||||||
|
export class CategoryEditorComponent implements OnInit {
|
||||||
|
category = signal<Category | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
saving = signal(false);
|
||||||
|
categoryId = signal<string>('');
|
||||||
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Get projectId from parent route immediately
|
||||||
|
const parentParams = this.route.parent?.snapshot.params;
|
||||||
|
if (parentParams) {
|
||||||
|
this.projectId.set(parentParams['projectId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.params.subscribe(params => {
|
||||||
|
this.categoryId.set(params['categoryId']);
|
||||||
|
this.loadCategory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCategory() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getCategory(this.categoryId()).subscribe({
|
||||||
|
next: (category) => {
|
||||||
|
this.category.set(category);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load category', err);
|
||||||
|
this.snackBar.open('Failed to load category', 'Close', { duration: 3000 });
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldChange(field: keyof Category, value: any) {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.apiService.queueSave('category', this.categoryId(), field, value);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
if (type === 'file' && target.files?.length) {
|
||||||
|
const file = target.files[0];
|
||||||
|
this.saving.set(true);
|
||||||
|
|
||||||
|
this.apiService.uploadImage(file).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
const cat = this.category();
|
||||||
|
if (cat) {
|
||||||
|
cat.img = response.url;
|
||||||
|
this.onFieldChange('img', response.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (type === 'url') {
|
||||||
|
const url = (target.value || '').trim();
|
||||||
|
if (url) {
|
||||||
|
this.onFieldChange('img', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openSubcategory(subId: string) {
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'subcategory', subId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
if (this.projectId()) {
|
||||||
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubcategory() {
|
||||||
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Create New Subcategory',
|
||||||
|
type: 'subcategory',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||||
|
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||||
|
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.createSubcategory(this.categoryId(), result).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||||
|
this.loadCategory();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to create subcategory', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory() {
|
||||||
|
const cat = this.category();
|
||||||
|
if (!cat) return;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Category',
|
||||||
|
message: `Are you sure you want to delete "${cat.name}"? This will also delete all subcategories and items.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(confirmed => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.apiService.deleteCategory(this.categoryId()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||||
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
310
src/app/pages/item-editor/item-editor.component.html
Normal file
310
src/app/pages/item-editor/item-editor.component.html
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<div class="editor-container">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
</div>
|
||||||
|
} @else if (item()) {
|
||||||
|
<div class="editor-header">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h2>Edit Item</h2>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
@if (saving()) {
|
||||||
|
<span class="save-indicator">Saving...</span>
|
||||||
|
}
|
||||||
|
<button mat-raised-button color="accent" (click)="previewInMarketplace()">
|
||||||
|
<mat-icon>open_in_new</mat-icon>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button color="warn" (click)="deleteItem()">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-tab-group class="editor-tabs">
|
||||||
|
<!-- Basic Info Tab -->
|
||||||
|
<mat-tab label="Basic Info">
|
||||||
|
<div class="tab-content">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="item()!.name"
|
||||||
|
(blur)="onFieldChange('name', item()!.name)"
|
||||||
|
required>
|
||||||
|
@if (!item()!.name || item()!.name.trim().length === 0) {
|
||||||
|
<mat-error>Name is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>ID</mat-label>
|
||||||
|
<input matInput [value]="item()!.id" disabled>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-slide-toggle
|
||||||
|
[(ngModel)]="item()!.visible"
|
||||||
|
(change)="onFieldChange('visible', item()!.visible)"
|
||||||
|
color="primary">
|
||||||
|
Visible
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Priority</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="item()!.priority"
|
||||||
|
(blur)="onFieldChange('priority', item()!.priority)">
|
||||||
|
<mat-hint>Lower numbers appear first</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Quantity</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="item()!.quantity"
|
||||||
|
(blur)="onFieldChange('quantity', item()!.quantity)"
|
||||||
|
required
|
||||||
|
min="0">
|
||||||
|
@if (item()!.quantity < 0) {
|
||||||
|
<mat-error>Quantity cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Price</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="item()!.price"
|
||||||
|
(blur)="onFieldChange('price', item()!.price)"
|
||||||
|
required
|
||||||
|
min="0">
|
||||||
|
@if (!item()!.price || item()!.price < 0) {
|
||||||
|
<mat-error>Price must be greater than 0</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="half-width">
|
||||||
|
<mat-label>Currency</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[(ngModel)]="item()!.currency"
|
||||||
|
(selectionChange)="onFieldChange('currency', item()!.currency)">
|
||||||
|
@for (curr of currencies; track curr) {
|
||||||
|
<mat-option [value]="curr">{{ curr }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Simple Description</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="4"
|
||||||
|
[(ngModel)]="item()!.simpleDescription"
|
||||||
|
(blur)="onFieldChange('simpleDescription', item()!.simpleDescription)">
|
||||||
|
</textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Images Tab -->
|
||||||
|
<mat-tab label="Images">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="images-section">
|
||||||
|
<div class="upload-area">
|
||||||
|
<label for="images-upload" class="upload-label">
|
||||||
|
<mat-icon>add_photo_alternate</mat-icon>
|
||||||
|
Upload Images
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="images-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
(change)="onImagesSelect($event)"
|
||||||
|
hidden>
|
||||||
|
|
||||||
|
@if (uploadingImages()) {
|
||||||
|
<mat-spinner diameter="30"></mat-spinner>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="images-grid" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="onImageDrop($event)">
|
||||||
|
@for (img of item()!.imgs; track $index) {
|
||||||
|
<div class="image-card" cdkDrag>
|
||||||
|
<div class="drag-handle" cdkDragHandle>
|
||||||
|
<mat-icon>drag_indicator</mat-icon>
|
||||||
|
</div>
|
||||||
|
<img [src]="img" [alt]="item()!.name">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="remove-btn"
|
||||||
|
(click)="removeImage($index)">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="image-order">{{ $index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!item()!.imgs.length) {
|
||||||
|
<div class="empty-images">
|
||||||
|
<mat-icon>image</mat-icon>
|
||||||
|
<p>No images yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Tags Tab -->
|
||||||
|
<mat-tab label="Tags">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tags-section">
|
||||||
|
<div class="add-tag-form">
|
||||||
|
<mat-form-field appearance="outline" class="tag-input">
|
||||||
|
<mat-label>Add Tag</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="newTag"
|
||||||
|
(keyup.enter)="addTag()"
|
||||||
|
placeholder="e.g. new, sale, featured">
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-raised-button color="primary" (click)="addTag()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tags-list">
|
||||||
|
@for (tag of item()!.tags; track $index) {
|
||||||
|
<mat-chip>
|
||||||
|
{{ tag }}
|
||||||
|
<button matChipRemove (click)="removeTag($index)">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!item()!.tags.length) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>label</mat-icon>
|
||||||
|
<p>No tags yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Detailed Description Tab -->
|
||||||
|
<mat-tab label="Description">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="description-section">
|
||||||
|
<h3>Key-Value Description Fields</h3>
|
||||||
|
<p class="hint">Add structured information like color, size, material, etc.</p>
|
||||||
|
|
||||||
|
<div class="add-desc-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Key</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="newDescKey"
|
||||||
|
placeholder="e.g. Color">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Value</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="newDescValue"
|
||||||
|
placeholder="e.g. Black">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-raised-button color="primary" (click)="addDescriptionField()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Add Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="desc-fields-list">
|
||||||
|
@for (field of item()!.description; track $index) {
|
||||||
|
<div class="desc-field-row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Key</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="field.key"
|
||||||
|
(blur)="updateDescriptionField($index, 'key', $any($event.target).value)">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Value</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="field.value"
|
||||||
|
(blur)="updateDescriptionField($index, 'value', $any($event.target).value)">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
color="warn"
|
||||||
|
(click)="removeDescriptionField($index)">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!item()!.description.length) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>description</mat-icon>
|
||||||
|
<p>No description fields yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Comments Tab -->
|
||||||
|
<mat-tab label="Comments">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="comments-section">
|
||||||
|
@if (item()!.comments?.length) {
|
||||||
|
<div class="comments-list">
|
||||||
|
@for (comment of item()!.comments; track comment.id) {
|
||||||
|
<div class="comment-card">
|
||||||
|
<div class="comment-header">
|
||||||
|
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
||||||
|
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ comment.text }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>comment</mat-icon>
|
||||||
|
<p>No comments yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
317
src/app/pages/item-editor/item-editor.component.scss
Normal file
317
src/app/pages/item-editor/item-editor.component.scss
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
.editor-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator {
|
||||||
|
color: #1976d2;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button mat-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tabs {
|
||||||
|
::ng-deep {
|
||||||
|
.mat-mdc-tab-body-content {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.half-width {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images Tab
|
||||||
|
.images-section {
|
||||||
|
.upload-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #1976d2;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1565c0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: move;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.cdk-drag-preview {
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cdk-drag-animating {
|
||||||
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: move;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(244, 67, 54, 0.9);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(244, 67, 54);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-order {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-images {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags Tab
|
||||||
|
.tags-section {
|
||||||
|
.add-tag-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.tag-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
mat-chip {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description Tab
|
||||||
|
.description-section {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-desc-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-fields-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments Tab
|
||||||
|
.comments-section {
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-card {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/app/pages/item-editor/item-editor.component.ts
Normal file
256
src/app/pages/item-editor/item-editor.component.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Item, ItemDescriptionField } from '../../models';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-item-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
DragDropModule
|
||||||
|
],
|
||||||
|
templateUrl: './item-editor.component.html',
|
||||||
|
styleUrls: ['./item-editor.component.scss']
|
||||||
|
})
|
||||||
|
export class ItemEditorComponent implements OnInit {
|
||||||
|
item = signal<Item | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
saving = signal(false);
|
||||||
|
itemId = signal<string>('');
|
||||||
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
newTag = '';
|
||||||
|
newDescKey = '';
|
||||||
|
newDescValue = '';
|
||||||
|
uploadingImages = signal<boolean>(false);
|
||||||
|
|
||||||
|
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Get projectId from parent route immediately
|
||||||
|
const parentParams = this.route.parent?.snapshot.params;
|
||||||
|
if (parentParams) {
|
||||||
|
this.projectId.set(parentParams['projectId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.params.subscribe(params => {
|
||||||
|
this.itemId.set(params['itemId']);
|
||||||
|
this.loadItem();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadItem() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getItem(this.itemId()).subscribe({
|
||||||
|
next: (item) => {
|
||||||
|
this.item.set(item);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load item', err);
|
||||||
|
this.snackBar.open('Failed to load item', 'Close', { duration: 3000 });
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldChange(field: keyof Item, value: any) {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image handling
|
||||||
|
async onImagesSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files?.length) return;
|
||||||
|
|
||||||
|
this.uploadingImages.set(true);
|
||||||
|
const files = Array.from(target.files);
|
||||||
|
const uploadPromises = files.map(file =>
|
||||||
|
this.apiService.uploadImage(file).toPromise()
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
const newUrls = results.map(r => r!.url);
|
||||||
|
const currentItem = this.item();
|
||||||
|
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedImgs = [...(currentItem.imgs || []), ...newUrls];
|
||||||
|
currentItem.imgs = updatedImgs;
|
||||||
|
this.onFieldChange('imgs', updatedImgs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.snackBar.open('Failed to upload images', 'Close', { duration: 3000 });
|
||||||
|
} finally {
|
||||||
|
this.uploadingImages.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeImage(index: number) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedImgs = [...currentItem.imgs];
|
||||||
|
updatedImgs.splice(index, 1);
|
||||||
|
currentItem.imgs = updatedImgs;
|
||||||
|
this.onFieldChange('imgs', updatedImgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags handling
|
||||||
|
addTag() {
|
||||||
|
if (!this.newTag.trim()) return;
|
||||||
|
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedTags = [...(currentItem.tags || []), this.newTag.trim()];
|
||||||
|
currentItem.tags = updatedTags;
|
||||||
|
this.onFieldChange('tags', updatedTags);
|
||||||
|
this.newTag = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTag(index: number) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedTags = [...currentItem.tags];
|
||||||
|
updatedTags.splice(index, 1);
|
||||||
|
currentItem.tags = updatedTags;
|
||||||
|
this.onFieldChange('tags', updatedTags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description fields handling
|
||||||
|
addDescriptionField() {
|
||||||
|
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;
|
||||||
|
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedDesc = [
|
||||||
|
...(currentItem.description || []),
|
||||||
|
{ key: this.newDescKey.trim(), value: this.newDescValue.trim() }
|
||||||
|
];
|
||||||
|
currentItem.description = updatedDesc;
|
||||||
|
this.onFieldChange('description', updatedDesc);
|
||||||
|
this.newDescKey = '';
|
||||||
|
this.newDescValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDescriptionField(index: number, field: 'key' | 'value', value: string) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedDesc = [...currentItem.description];
|
||||||
|
updatedDesc[index] = { ...updatedDesc[index], [field]: value };
|
||||||
|
currentItem.description = updatedDesc;
|
||||||
|
this.onFieldChange('description', updatedDesc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDescriptionField(index: number) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (currentItem) {
|
||||||
|
const updatedDesc = [...currentItem.description];
|
||||||
|
updatedDesc.splice(index, 1);
|
||||||
|
currentItem.description = updatedDesc;
|
||||||
|
this.onFieldChange('description', updatedDesc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
const item = this.item();
|
||||||
|
if (item && item.subcategoryId) {
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previewInMarketplace() {
|
||||||
|
// Open marketplace in new tab with this item
|
||||||
|
const marketplaceUrl = `http://localhost:4200/item/${this.itemId()}`;
|
||||||
|
window.open(marketplaceUrl, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageDrop(event: CdkDragDrop<string[]>) {
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const imgs = [...item.imgs];
|
||||||
|
moveItemInArray(imgs, event.previousIndex, event.currentIndex);
|
||||||
|
|
||||||
|
this.item.set({ ...item, imgs });
|
||||||
|
this.onFieldChange('imgs', imgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem() {
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Item',
|
||||||
|
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.deleteItem(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/app/pages/items-list/items-list.component.html
Normal file
136
src/app/pages/items-list/items-list.component.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<div class="items-list-container">
|
||||||
|
<mat-toolbar color="primary" class="list-toolbar">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span class="toolbar-title">Items List</span>
|
||||||
|
<span class="toolbar-spacer"></span>
|
||||||
|
<button mat-mini-fab color="accent" (click)="addItem()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<div class="filters-bar">
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-label>Search items</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
(keyup.enter)="onSearch()"
|
||||||
|
placeholder="Search by name...">
|
||||||
|
<button mat-icon-button matSuffix (click)="onSearch()">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="filter-field">
|
||||||
|
<mat-label>Visibility</mat-label>
|
||||||
|
<mat-select [(ngModel)]="visibilityFilter" (selectionChange)="onFilterChange()">
|
||||||
|
<mat-option [value]="undefined">All</mat-option>
|
||||||
|
<mat-option [value]="true">Visible</mat-option>
|
||||||
|
<mat-option [value]="false">Hidden</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (selectedItems().size > 0) {
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<span class="selection-count">{{ selectedItems().size }} selected</span>
|
||||||
|
<button mat-raised-button (click)="bulkToggleVisibility(true)">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Show
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="bulkToggleVisibility(false)">
|
||||||
|
<mat-icon>visibility_off</mat-icon>
|
||||||
|
Hide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-header">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="selectedItems().size === items().length && items().length > 0"
|
||||||
|
[indeterminate]="selectedItems().size > 0 && selectedItems().size < items().length"
|
||||||
|
(change)="toggleSelectAll()">
|
||||||
|
</mat-checkbox>
|
||||||
|
<span class="items-count">{{ items().length }} items</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-grid">
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<div class="item-card" (click)="openItem(item.id)">
|
||||||
|
<div class="item-card-actions">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="selectedItems().has(item.id)"
|
||||||
|
(change)="toggleSelection(item.id)"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
</mat-checkbox>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
color="warn"
|
||||||
|
(click)="deleteItem(item, $event)"
|
||||||
|
class="delete-btn">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-image">
|
||||||
|
@if (item.imgs.length) {
|
||||||
|
<img [src]="item.imgs[0]" [alt]="item.name">
|
||||||
|
} @else {
|
||||||
|
<div class="no-image">
|
||||||
|
<mat-icon>image</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-info">
|
||||||
|
<h3>{{ item.name }}</h3>
|
||||||
|
|
||||||
|
<div class="item-details">
|
||||||
|
<span class="price">{{ item.price }} {{ item.currency }}</span>
|
||||||
|
<span class="quantity">Qty: {{ item.quantity }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="priority">Priority: {{ item.priority }}</span>
|
||||||
|
<mat-icon [class.visible]="item.visible" [class.hidden]="!item.visible">
|
||||||
|
{{ item.visible ? 'visibility' : 'visibility_off' }}
|
||||||
|
</mat-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.tags.length) {
|
||||||
|
<div class="item-tags">
|
||||||
|
@for (tag of item.tags.slice(0, 3); track tag) {
|
||||||
|
<mat-chip>{{ tag }}</mat-chip>
|
||||||
|
}
|
||||||
|
@if (item.tags.length > 3) {
|
||||||
|
<span class="more-tags">+{{ item.tags.length - 3 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-more">
|
||||||
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
|
<span>Loading more items...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!hasMore() && items().length > 0) {
|
||||||
|
<div class="end-message">
|
||||||
|
No more items to load
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading() && items().length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>inventory_2</mat-icon>
|
||||||
|
<p>No items found</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
269
src/app/pages/items-list/items-list.component.scss
Normal file
269
src/app/pages/items-list/items-list.component.scss
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
.items-list-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
.selection-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
.items-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: #1976d2;
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-checkbox {
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: #ccc;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-chip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-tags {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/app/pages/items-list/items-list.component.ts
Normal file
252
src/app/pages/items-list/items-list.component.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { Component, OnInit, signal, HostListener } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Item } from '../../models';
|
||||||
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-items-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
templateUrl: './items-list.component.html',
|
||||||
|
styleUrls: ['./items-list.component.scss']
|
||||||
|
})
|
||||||
|
export class ItemsListComponent implements OnInit {
|
||||||
|
items = signal<Item[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
hasMore = signal(true);
|
||||||
|
page = signal(1);
|
||||||
|
searchQuery = signal('');
|
||||||
|
visibilityFilter = signal<boolean | undefined>(undefined);
|
||||||
|
selectedItems = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
subcategoryId = signal<string>('');
|
||||||
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Get projectId from parent route immediately
|
||||||
|
const parentParams = this.route.parent?.snapshot.params;
|
||||||
|
if (parentParams) {
|
||||||
|
this.projectId.set(parentParams['projectId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.params.subscribe(params => {
|
||||||
|
this.subcategoryId.set(params['subcategoryId']);
|
||||||
|
this.loadItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadItems(append = false) {
|
||||||
|
if (this.loading() || (!append && this.items().length > 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
const currentPage = append ? this.page() + 1 : 1;
|
||||||
|
|
||||||
|
this.apiService.getItems(
|
||||||
|
this.subcategoryId(),
|
||||||
|
currentPage,
|
||||||
|
20,
|
||||||
|
this.searchQuery() || undefined,
|
||||||
|
{
|
||||||
|
visible: this.visibilityFilter(),
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (append) {
|
||||||
|
this.items.set([...this.items(), ...response.items]);
|
||||||
|
} else {
|
||||||
|
this.items.set(response.items);
|
||||||
|
}
|
||||||
|
this.page.set(currentPage);
|
||||||
|
this.hasMore.set(response.hasMore);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load items', err);
|
||||||
|
this.snackBar.open('Failed to load items', 'Close', { duration: 3000 });
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:scroll', [])
|
||||||
|
onScroll() {
|
||||||
|
const scrollPosition = window.pageYOffset + window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
if (scrollPosition >= documentHeight - 200 && this.hasMore() && !this.loading()) {
|
||||||
|
this.loadItems(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch() {
|
||||||
|
this.page.set(1);
|
||||||
|
this.items.set([]);
|
||||||
|
this.loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange() {
|
||||||
|
this.page.set(1);
|
||||||
|
this.items.set([]);
|
||||||
|
this.loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelection(itemId: string) {
|
||||||
|
const selected = new Set(this.selectedItems());
|
||||||
|
if (selected.has(itemId)) {
|
||||||
|
selected.delete(itemId);
|
||||||
|
} else {
|
||||||
|
selected.add(itemId);
|
||||||
|
}
|
||||||
|
this.selectedItems.set(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll() {
|
||||||
|
if (this.selectedItems().size === this.items().length) {
|
||||||
|
this.selectedItems.set(new Set());
|
||||||
|
} else {
|
||||||
|
this.selectedItems.set(new Set(this.items().map(item => item.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkToggleVisibility(visible: boolean) {
|
||||||
|
const itemIds = Array.from(this.selectedItems());
|
||||||
|
if (!itemIds.length) {
|
||||||
|
this.snackBar.open('No items selected', 'Close', { duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiService.bulkUpdateItems(itemIds, { visible }).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.items.update(items =>
|
||||||
|
items.map(item =>
|
||||||
|
itemIds.includes(item.id) ? { ...item, visible } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.snackBar.open(`Updated ${itemIds.length} items`, 'Close', { duration: 2000 });
|
||||||
|
this.selectedItems.set(new Set());
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to update items', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openItem(itemId: string) {
|
||||||
|
console.log('Opening item:', itemId, 'projectId:', this.projectId());
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'item', itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
const subcategoryId = this.subcategoryId();
|
||||||
|
if (subcategoryId) {
|
||||||
|
// Navigate back to subcategory editor
|
||||||
|
this.router.navigate(['/subcategory', subcategoryId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem() {
|
||||||
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
|
width: '500px',
|
||||||
|
data: {
|
||||||
|
title: 'Create New Item',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
||||||
|
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
||||||
|
{ name: 'price', label: 'Price', type: 'number', required: true },
|
||||||
|
{ name: 'currency', label: 'Currency', type: 'text', required: true, value: 'USD' },
|
||||||
|
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
||||||
|
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
const subcategoryId = this.subcategoryId();
|
||||||
|
if (!subcategoryId) return;
|
||||||
|
|
||||||
|
this.apiService.createItem(subcategoryId, result).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
|
||||||
|
this.loadItems();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error creating item:', err);
|
||||||
|
this.snackBar.open('Failed to create item', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(item: Item, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Item',
|
||||||
|
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.deleteItem(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||||
|
this.loadItems();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/app/pages/project-view/project-view.component.html
Normal file
113
src/app/pages/project-view/project-view.component.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<div class="project-view-container">
|
||||||
|
<mat-toolbar color="primary">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span>Project: {{ projectId() }}</span>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<mat-sidenav-container class="sidenav-container">
|
||||||
|
<mat-sidenav mode="side" opened class="categories-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>Categories</h2>
|
||||||
|
<button mat-mini-fab color="primary" (click)="addCategory()" matTooltip="Add Category">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="tree-container">
|
||||||
|
@for (node of treeData(); track node.id) {
|
||||||
|
<div class="tree-node">
|
||||||
|
<div class="node-content category-node">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="toggleNode(node)"
|
||||||
|
[disabled]="!node.children?.length">
|
||||||
|
<mat-icon>
|
||||||
|
{{ node.children?.length ? (node.expanded ? 'expand_more' : 'chevron_right') : '' }}
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="node-name" (click)="editNode(node, $event)">
|
||||||
|
{{ node.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="node-actions">
|
||||||
|
<mat-slide-toggle
|
||||||
|
[checked]="node.visible"
|
||||||
|
(change)="toggleVisibility(node, $event)"
|
||||||
|
color="primary"
|
||||||
|
matTooltip="Toggle Visibility">
|
||||||
|
</mat-slide-toggle>
|
||||||
|
|
||||||
|
<button mat-icon-button (click)="editNode(node, $event)" color="primary" matTooltip="Edit">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-icon-button (click)="deleteCategory(node, $event)" color="warn" matTooltip="Delete">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (node.expanded && node.children?.length) {
|
||||||
|
<div class="subcategories">
|
||||||
|
@for (subNode of node.children; track subNode.id) {
|
||||||
|
<div class="node-content subcategory-node">
|
||||||
|
<span class="node-name" (click)="editNode(subNode, $event)">
|
||||||
|
{{ subNode.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="node-actions">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="viewItems(subNode, $event)"
|
||||||
|
matTooltip="View Items">
|
||||||
|
<mat-icon>list</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-slide-toggle
|
||||||
|
[checked]="subNode.visible"
|
||||||
|
(change)="toggleVisibility(subNode, $event)"
|
||||||
|
color="primary"
|
||||||
|
matTooltip="Toggle Visibility">
|
||||||
|
</mat-slide-toggle>
|
||||||
|
|
||||||
|
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-sidenav>
|
||||||
|
|
||||||
|
<mat-sidenav-content>
|
||||||
|
<div class="content-area">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
|
@if (!hasActiveRoute()) {
|
||||||
|
<div class="welcome-message">
|
||||||
|
<mat-icon style="font-size: 64px; width: 64px; height: 64px; color: #1976d2;">dashboard</mat-icon>
|
||||||
|
<h2>Welcome to {{ project()?.displayName || 'Project' }} Backoffice</h2>
|
||||||
|
<p>Select a category or subcategory from the sidebar to start editing.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-sidenav-content>
|
||||||
|
</mat-sidenav-container>
|
||||||
|
</div>
|
||||||
156
src/app/pages/project-view/project-view.component.scss
Normal file
156
src/app/pages/project-view/project-view.component.scss
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
.project-view-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-toolbar {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav-container {
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-sidebar {
|
||||||
|
width: 380px;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
background-color: #fff;
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(100% - 65px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
.node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
min-height: 48px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.category-node {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.subcategory-node {
|
||||||
|
padding-left: 3rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
mat-slide-toggle {
|
||||||
|
transform: scale(0.75);
|
||||||
|
margin: 0 -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
// width: 32px;
|
||||||
|
// height: 32px;
|
||||||
|
// line-height: 32px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcategories {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 2rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #666;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/app/pages/project-view/project-view.component.ts
Normal file
238
src/app/pages/project-view/project-view.component.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatTreeModule } from '@angular/material/tree';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Category, Subcategory } from '../../models';
|
||||||
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
interface CategoryNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'category' | 'subcategory';
|
||||||
|
visible: boolean;
|
||||||
|
expanded?: boolean;
|
||||||
|
children?: CategoryNode[];
|
||||||
|
categoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-project-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterOutlet,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatTreeModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
],
|
||||||
|
templateUrl: './project-view.component.html',
|
||||||
|
styleUrls: ['./project-view.component.scss']
|
||||||
|
})
|
||||||
|
export class ProjectViewComponent implements OnInit {
|
||||||
|
projectId = signal<string>('');
|
||||||
|
project = signal<any>(null);
|
||||||
|
categories = signal<Category[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
treeData = signal<CategoryNode[]>([]);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.params.subscribe(params => {
|
||||||
|
this.projectId.set(params['projectId']);
|
||||||
|
this.loadProject();
|
||||||
|
this.loadCategories();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasActiveRoute(): boolean {
|
||||||
|
return this.route.children.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProject() {
|
||||||
|
// Load project details
|
||||||
|
this.apiService.getProjects().subscribe({
|
||||||
|
next: (projects) => {
|
||||||
|
const project = projects.find(p => p.id === this.projectId());
|
||||||
|
this.project.set(project);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load project', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCategories() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getCategories(this.projectId()).subscribe({
|
||||||
|
next: (categories) => {
|
||||||
|
this.categories.set(categories);
|
||||||
|
this.buildTree();
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load categories', err);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTree() {
|
||||||
|
const tree: CategoryNode[] = this.categories().map(cat => ({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
type: 'category' as const,
|
||||||
|
visible: cat.visible,
|
||||||
|
expanded: false,
|
||||||
|
children: (cat.subcategories || []).map(sub => ({
|
||||||
|
id: sub.id,
|
||||||
|
name: sub.name,
|
||||||
|
type: 'subcategory' as const,
|
||||||
|
visible: sub.visible,
|
||||||
|
categoryId: cat.id
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
this.treeData.set(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNode(node: CategoryNode) {
|
||||||
|
node.expanded = !node.expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
editNode(node: CategoryNode, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (node.type === 'category') {
|
||||||
|
this.router.navigate(['category', node.id], { relativeTo: this.route });
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['subcategory', node.id], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleVisibility(node: CategoryNode, event: any) {
|
||||||
|
event.stopPropagation();
|
||||||
|
node.visible = !node.visible;
|
||||||
|
|
||||||
|
if (node.type === 'category') {
|
||||||
|
this.apiService.updateCategory(node.id, { visible: node.visible }).subscribe();
|
||||||
|
} else {
|
||||||
|
this.apiService.updateSubcategory(node.id, { visible: node.visible }).subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewItems(node: CategoryNode, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (node.type === 'subcategory') {
|
||||||
|
console.log('Navigating to items for subcategory:', node.id);
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'items', node.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCategory() {
|
||||||
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Create New Category',
|
||||||
|
type: 'category',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||||
|
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||||
|
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to create category', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(node: CategoryNode, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Category',
|
||||||
|
message: `Are you sure you want to delete "${node.name}"? This will also delete all subcategories and items.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
warning: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(confirmed => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.apiService.deleteCategory(node.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSubcategory(node: CategoryNode, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Subcategory',
|
||||||
|
message: `Are you sure you want to delete "${node.name}"? This will also delete all items.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(confirmed => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.apiService.deleteSubcategory(node.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Subcategory deleted', 'Close', { duration: 2000 });
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<div class="dashboard-container">
|
||||||
|
<h1>Marketplace Backoffice</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<div class="error-message">
|
||||||
|
{{ error() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading() && !error()) {
|
||||||
|
<div class="projects-grid">
|
||||||
|
@for (project of projects(); track project.id) {
|
||||||
|
<mat-card class="project-card" (click)="openProject(project.id)">
|
||||||
|
<mat-card-header>
|
||||||
|
@if (project.logoUrl) {
|
||||||
|
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">
|
||||||
|
}
|
||||||
|
<mat-card-title>{{ project.displayName }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="project-status" [class.active]="project.active">
|
||||||
|
{{ project.active ? 'Active' : 'Inactive' }}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-card-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #ccc;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Project } from '../../models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-projects-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatCardModule, MatProgressSpinnerModule],
|
||||||
|
templateUrl: './projects-dashboard.component.html',
|
||||||
|
styleUrls: ['./projects-dashboard.component.scss']
|
||||||
|
})
|
||||||
|
export class ProjectsDashboardComponent implements OnInit {
|
||||||
|
projects = signal<Project[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProjects() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getProjects().subscribe({
|
||||||
|
next: (projects) => {
|
||||||
|
this.projects.set(projects);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.error.set('Failed to load projects');
|
||||||
|
this.loading.set(false);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openProject(projectId: string) {
|
||||||
|
this.router.navigate(['/project', projectId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<div class="editor-container">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
</div>
|
||||||
|
} @else if (subcategory()) {
|
||||||
|
<div class="editor-header">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h2>Edit Subcategory</h2>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
@if (saving()) {
|
||||||
|
<span class="save-indicator">Saving...</span>
|
||||||
|
}
|
||||||
|
<button mat-raised-button color="accent" (click)="viewItems()">
|
||||||
|
<mat-icon>list</mat-icon>
|
||||||
|
View Items
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button color="warn" (click)="deleteSubcategory()">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-content">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="subcategory()!.name"
|
||||||
|
(blur)="onFieldChange('name', subcategory()!.name)"
|
||||||
|
required>
|
||||||
|
@if (!subcategory()!.name || subcategory()!.name.trim().length === 0) {
|
||||||
|
<mat-error>Subcategory name is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>ID</mat-label>
|
||||||
|
<input matInput [value]="subcategory()!.id" disabled>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-slide-toggle
|
||||||
|
[(ngModel)]="subcategory()!.visible"
|
||||||
|
(change)="onFieldChange('visible', subcategory()!.visible)"
|
||||||
|
color="primary">
|
||||||
|
Visible
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Priority</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="subcategory()!.priority"
|
||||||
|
(blur)="onFieldChange('priority', subcategory()!.priority)"
|
||||||
|
required
|
||||||
|
min="0">
|
||||||
|
<mat-hint>Lower numbers appear first</mat-hint>
|
||||||
|
@if (subcategory()!.priority < 0) {
|
||||||
|
<mat-error>Priority cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h3>Image</h3>
|
||||||
|
|
||||||
|
@if (subcategory()!.img) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<img [src]="subcategory()!.img" [alt]="subcategory()!.name">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="image-inputs">
|
||||||
|
<div class="upload-option">
|
||||||
|
<label for="file-upload" class="upload-label">
|
||||||
|
<mat-icon>upload_file</mat-icon>
|
||||||
|
Upload Image
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="onImageSelect($event, 'file')"
|
||||||
|
hidden>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Or enter image URL</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="subcategory()!.img || ''"
|
||||||
|
(blur)="onImageSelect($event, 'url')">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-section">
|
||||||
|
<button mat-raised-button color="primary" (click)="viewItems()">
|
||||||
|
<mat-icon>list</mat-icon>
|
||||||
|
View Items ({{ subcategory()!.itemCount || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
.editor-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-indicator {
|
||||||
|
color: #1976d2;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-section {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-option {
|
||||||
|
.upload-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #1976d2;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-section {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/app/pages/subcategory-editor/subcategory-editor.component.ts
Normal file
159
src/app/pages/subcategory-editor/subcategory-editor.component.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Subcategory } from '../../models';
|
||||||
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-subcategory-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
templateUrl: './subcategory-editor.component.html',
|
||||||
|
styleUrls: ['./subcategory-editor.component.scss']
|
||||||
|
})
|
||||||
|
export class SubcategoryEditorComponent implements OnInit {
|
||||||
|
subcategory = signal<Subcategory | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
saving = signal(false);
|
||||||
|
subcategoryId = signal<string>('');
|
||||||
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Get projectId from parent route immediately
|
||||||
|
const parentParams = this.route.parent?.snapshot.params;
|
||||||
|
if (parentParams) {
|
||||||
|
this.projectId.set(parentParams['projectId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.params.subscribe(params => {
|
||||||
|
this.subcategoryId.set(params['subcategoryId']);
|
||||||
|
this.loadSubcategory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSubcategory() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getSubcategory(this.subcategoryId()).subscribe({
|
||||||
|
next: (subcategory) => {
|
||||||
|
this.subcategory.set(subcategory);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load subcategory', err);
|
||||||
|
this.snackBar.open('Failed to load subcategory', 'Close', { duration: 3000 });
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldChange(field: keyof Subcategory, value: any) {
|
||||||
|
this.saving.set(true);
|
||||||
|
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
if (type === 'file' && target.files?.length) {
|
||||||
|
const file = target.files[0];
|
||||||
|
this.saving.set(true);
|
||||||
|
|
||||||
|
this.apiService.uploadImage(file).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
const sub = this.subcategory();
|
||||||
|
if (sub) {
|
||||||
|
sub.img = response.url;
|
||||||
|
this.onFieldChange('img', response.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (type === 'url') {
|
||||||
|
const url = (target.value || '').trim();
|
||||||
|
if (url) {
|
||||||
|
this.onFieldChange('img', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewItems() {
|
||||||
|
console.log('View items - projectId:', this.projectId(), 'subcategoryId:', this.subcategoryId());
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'items', this.subcategoryId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
const sub = this.subcategory();
|
||||||
|
if (sub && this.projectId()) {
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSubcategory() {
|
||||||
|
const sub = this.subcategory();
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Subcategory',
|
||||||
|
message: `Are you sure you want to delete "${sub.name}"? This will also delete all items in this subcategory.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.deleteSubcategory(sub.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 });
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
console.error('Error deleting subcategory:', err);
|
||||||
|
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/app/services/api.service.ts
Normal file
238
src/app/services/api.service.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable, Subject, timer } from 'rxjs';
|
||||||
|
import { debounce, retry, catchError, tap } from 'rxjs/operators';
|
||||||
|
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||||
|
import { MockDataService } from './mock-data.service';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ApiService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private mockService = inject(MockDataService);
|
||||||
|
private readonly API_BASE = environment.apiUrl;
|
||||||
|
|
||||||
|
// Debounced save queue
|
||||||
|
private saveQueue$ = new Subject<SaveOperation>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Set up auto-save with 500ms debounce
|
||||||
|
this.saveQueue$
|
||||||
|
.pipe(debounce(() => timer(500)))
|
||||||
|
.subscribe(operation => {
|
||||||
|
this.executeSave(operation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
getProjects(): Observable<Project[]> {
|
||||||
|
if (environment.useMockData) return this.mockService.getProjects();
|
||||||
|
return this.http.get<Project[]>(`${this.API_BASE}/projects`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
getCategories(projectId: string): Observable<Category[]> {
|
||||||
|
if (environment.useMockData) return this.mockService.getCategories(projectId);
|
||||||
|
return this.http.get<Category[]>(`${this.API_BASE}/projects/${projectId}/categories`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory(categoryId: string): Observable<Category> {
|
||||||
|
if (environment.useMockData) return this.mockService.getCategory(categoryId);
|
||||||
|
return this.http.get<Category>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategory(categoryId: string, data: Partial<Category>): Observable<Category> {
|
||||||
|
if (environment.useMockData) return this.mockService.updateCategory(categoryId, data);
|
||||||
|
return this.http.patch<Category>(`${this.API_BASE}/categories/${categoryId}`, data).pipe(
|
||||||
|
retry(1),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory(projectId: string, data: Partial<Category>): Observable<Category> {
|
||||||
|
if (environment.useMockData) return this.mockService.createCategory(projectId, data);
|
||||||
|
return this.http.post<Category>(`${this.API_BASE}/projects/${projectId}/categories`, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(categoryId: string): Observable<void> {
|
||||||
|
if (environment.useMockData) return this.mockService.deleteCategory(categoryId);
|
||||||
|
return this.http.delete<void>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcategories
|
||||||
|
getSubcategories(categoryId: string): Observable<Subcategory[]> {
|
||||||
|
if (environment.useMockData) return this.mockService.getCategory(categoryId).pipe(
|
||||||
|
tap(cat => cat.subcategories || [])
|
||||||
|
) as any;
|
||||||
|
return this.http.get<Subcategory[]>(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubcategory(subcategoryId: string): Observable<Subcategory> {
|
||||||
|
if (environment.useMockData) return this.mockService.getSubcategory(subcategoryId);
|
||||||
|
return this.http.get<Subcategory>(`${this.API_BASE}/subcategories/${subcategoryId}`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
|
if (environment.useMockData) return this.mockService.updateSubcategory(subcategoryId, data);
|
||||||
|
return this.http.patch<Subcategory>(`${this.API_BASE}/subcategories/${subcategoryId}`, data).pipe(
|
||||||
|
retry(1),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
|
if (environment.useMockData) return this.mockService.createSubcategory(categoryId, data);
|
||||||
|
return this.http.post<Subcategory>(`${this.API_BASE}/categories/${categoryId}/subcategories`, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSubcategory(subcategoryId: string): Observable<void> {
|
||||||
|
if (environment.useMockData) return this.mockService.deleteSubcategory(subcategoryId);
|
||||||
|
return this.http.delete<void>(`${this.API_BASE}/subcategories/${subcategoryId}`).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items
|
||||||
|
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: ItemFilters): Observable<ItemsListResponse> {
|
||||||
|
if (environment.useMockData) return this.mockService.getItems(subcategoryId, page, limit, search, filters);
|
||||||
|
|
||||||
|
let params = new HttpParams()
|
||||||
|
.set('page', page.toString())
|
||||||
|
.set('limit', limit.toString());
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params = params.set('search', search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.visible !== undefined) {
|
||||||
|
params = params.set('visible', filters.visible.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.tags?.length) {
|
||||||
|
params = params.set('tags', filters.tags.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<ItemsListResponse>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(itemId: string): Observable<Item> {
|
||||||
|
if (environment.useMockData) return this.mockService.getItem(itemId);
|
||||||
|
return this.http.get<Item>(`${this.API_BASE}/items/${itemId}`).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem(itemId: string, data: Partial<Item>): Observable<Item> {
|
||||||
|
if (environment.useMockData) return this.mockService.updateItem(itemId, data);
|
||||||
|
return this.http.patch<Item>(`${this.API_BASE}/items/${itemId}`, data).pipe(
|
||||||
|
retry(1),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createItem(subcategoryId: string, data: Partial<Item>): Observable<Item> {
|
||||||
|
if (environment.useMockData) return this.mockService.createItem(subcategoryId, data);
|
||||||
|
return this.http.post<Item>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(itemId: string): Observable<void> {
|
||||||
|
if (environment.useMockData) return this.mockService.deleteItem(itemId);
|
||||||
|
return this.http.delete<void>(`${this.API_BASE}/items/${itemId}`).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateItems(itemIds: string[], data: Partial<Item>): Observable<void> {
|
||||||
|
if (environment.useMockData) return this.mockService.bulkUpdateItems(itemIds, data);
|
||||||
|
return this.http.patch<void>(`${this.API_BASE}/items/bulk`, { itemIds, data }).pipe(
|
||||||
|
retry(1),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image upload
|
||||||
|
uploadImage(file: File): Observable<{ url: string }> {
|
||||||
|
if (environment.useMockData) return this.mockService.uploadImage(file);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
return this.http.post<{ url: string }>(`${this.API_BASE}/upload`, formData).pipe(
|
||||||
|
retry(1),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced auto-save
|
||||||
|
queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: any) {
|
||||||
|
this.saveQueue$.next({ type, id, field, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeSave(operation: SaveOperation) {
|
||||||
|
const data = { [operation.field]: operation.value };
|
||||||
|
|
||||||
|
let request: Observable<any>;
|
||||||
|
switch (operation.type) {
|
||||||
|
case 'category':
|
||||||
|
request = this.updateCategory(operation.id, data);
|
||||||
|
break;
|
||||||
|
case 'subcategory':
|
||||||
|
request = this.updateSubcategory(operation.id, data);
|
||||||
|
break;
|
||||||
|
case 'item':
|
||||||
|
request = this.updateItem(operation.id, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.subscribe({
|
||||||
|
next: () => console.log(`Saved ${operation.type} ${operation.id} - ${operation.field}`),
|
||||||
|
error: (err) => console.error(`Failed to save ${operation.type}`, err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: any): Observable<never> {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveOperation {
|
||||||
|
type: 'category' | 'subcategory' | 'item';
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFilters {
|
||||||
|
visible?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
1
src/app/services/index.ts
Normal file
1
src/app/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './api.service';
|
||||||
379
src/app/services/mock-data.service.ts
Normal file
379
src/app/services/mock-data.service.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of, delay } from 'rxjs';
|
||||||
|
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class MockDataService {
|
||||||
|
private projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: 'dexar',
|
||||||
|
name: 'dexar',
|
||||||
|
displayName: 'Dexar Marketplace',
|
||||||
|
active: true,
|
||||||
|
logoUrl: 'https://via.placeholder.com/150?text=Dexar'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'novo',
|
||||||
|
name: 'novo',
|
||||||
|
displayName: 'Novo Shop',
|
||||||
|
active: true,
|
||||||
|
logoUrl: 'https://via.placeholder.com/150?text=Novo'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private categories: Category[] = [
|
||||||
|
{
|
||||||
|
id: 'cat1',
|
||||||
|
name: 'Electronics',
|
||||||
|
visible: true,
|
||||||
|
priority: 1,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Electronics',
|
||||||
|
projectId: 'dexar',
|
||||||
|
subcategories: [
|
||||||
|
{
|
||||||
|
id: 'sub1',
|
||||||
|
name: 'Smartphones',
|
||||||
|
visible: true,
|
||||||
|
priority: 1,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Smartphones',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
itemCount: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sub2',
|
||||||
|
name: 'Laptops',
|
||||||
|
visible: true,
|
||||||
|
priority: 2,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Laptops',
|
||||||
|
categoryId: 'cat1',
|
||||||
|
itemCount: 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat2',
|
||||||
|
name: 'Clothing',
|
||||||
|
visible: true,
|
||||||
|
priority: 2,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Clothing',
|
||||||
|
projectId: 'dexar',
|
||||||
|
subcategories: [
|
||||||
|
{
|
||||||
|
id: 'sub3',
|
||||||
|
name: 'Men',
|
||||||
|
visible: true,
|
||||||
|
priority: 1,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Men',
|
||||||
|
categoryId: 'cat2',
|
||||||
|
itemCount: 25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat3',
|
||||||
|
name: 'Home & Garden',
|
||||||
|
visible: false,
|
||||||
|
priority: 3,
|
||||||
|
img: 'https://via.placeholder.com/400x300?text=Home',
|
||||||
|
projectId: 'novo',
|
||||||
|
subcategories: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private items: Item[] = [
|
||||||
|
{
|
||||||
|
id: 'item1',
|
||||||
|
name: 'iPhone 15 Pro Max',
|
||||||
|
visible: true,
|
||||||
|
priority: 1,
|
||||||
|
quantity: 50,
|
||||||
|
price: 1299,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: [
|
||||||
|
'https://via.placeholder.com/600x400?text=iPhone+Front',
|
||||||
|
'https://via.placeholder.com/600x400?text=iPhone+Back'
|
||||||
|
],
|
||||||
|
tags: ['new', 'featured', 'bestseller'],
|
||||||
|
simpleDescription: 'Latest iPhone with titanium design and A17 Pro chip',
|
||||||
|
description: [
|
||||||
|
{ key: 'Color', value: 'Natural Titanium' },
|
||||||
|
{ key: 'Storage', value: '256GB' },
|
||||||
|
{ key: 'Display', value: '6.7 inch Super Retina XDR' },
|
||||||
|
{ key: 'Chip', value: 'A17 Pro' }
|
||||||
|
],
|
||||||
|
subcategoryId: 'sub1',
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
text: 'Great phone! Battery life is amazing.',
|
||||||
|
createdAt: new Date('2024-01-10'),
|
||||||
|
author: 'John Doe'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item2',
|
||||||
|
name: 'Samsung Galaxy S24 Ultra',
|
||||||
|
visible: true,
|
||||||
|
priority: 2,
|
||||||
|
quantity: 35,
|
||||||
|
price: 1199,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
|
||||||
|
tags: ['new', 'android'],
|
||||||
|
simpleDescription: 'Premium Samsung flagship with S Pen',
|
||||||
|
description: [
|
||||||
|
{ key: 'Color', value: 'Titanium Gray' },
|
||||||
|
{ key: 'Storage', value: '512GB' },
|
||||||
|
{ key: 'RAM', value: '12GB' }
|
||||||
|
],
|
||||||
|
subcategoryId: 'sub1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item3',
|
||||||
|
name: 'Google Pixel 8 Pro',
|
||||||
|
visible: true,
|
||||||
|
priority: 3,
|
||||||
|
quantity: 20,
|
||||||
|
price: 999,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
|
||||||
|
tags: ['sale', 'android', 'ai'],
|
||||||
|
simpleDescription: 'Best AI photography phone',
|
||||||
|
description: [
|
||||||
|
{ key: 'Color', value: 'Bay Blue' },
|
||||||
|
{ key: 'Storage', value: '256GB' }
|
||||||
|
],
|
||||||
|
subcategoryId: 'sub1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item4',
|
||||||
|
name: 'MacBook Pro 16"',
|
||||||
|
visible: true,
|
||||||
|
priority: 1,
|
||||||
|
quantity: 15,
|
||||||
|
price: 2499,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
|
||||||
|
tags: ['featured', 'professional'],
|
||||||
|
simpleDescription: 'Powerful laptop for professionals',
|
||||||
|
description: [
|
||||||
|
{ key: 'Processor', value: 'M3 Max' },
|
||||||
|
{ key: 'RAM', value: '36GB' },
|
||||||
|
{ key: 'Storage', value: '1TB SSD' }
|
||||||
|
],
|
||||||
|
subcategoryId: 'sub2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item5',
|
||||||
|
name: 'Dell XPS 15',
|
||||||
|
visible: true,
|
||||||
|
priority: 2,
|
||||||
|
quantity: 0,
|
||||||
|
price: 1799,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: ['https://via.placeholder.com/600x400?text=Dell+XPS'],
|
||||||
|
tags: ['out-of-stock'],
|
||||||
|
simpleDescription: 'Premium Windows laptop',
|
||||||
|
description: [
|
||||||
|
{ key: 'Processor', value: 'Intel Core i9' },
|
||||||
|
{ key: 'RAM', value: '32GB' }
|
||||||
|
],
|
||||||
|
subcategoryId: 'sub2'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate more items for testing infinite scroll
|
||||||
|
private generateMoreItems(subcategoryId: string, count: number): Item[] {
|
||||||
|
const items: Item[] = [];
|
||||||
|
for (let i = 6; i <= count + 5; i++) {
|
||||||
|
items.push({
|
||||||
|
id: `item${i}`,
|
||||||
|
name: `Test Product ${i}`,
|
||||||
|
visible: Math.random() > 0.3,
|
||||||
|
priority: i,
|
||||||
|
quantity: Math.floor(Math.random() * 100),
|
||||||
|
price: Math.floor(Math.random() * 1000) + 100,
|
||||||
|
currency: 'USD',
|
||||||
|
imgs: [`https://via.placeholder.com/600x400?text=Product+${i}`],
|
||||||
|
tags: ['test'],
|
||||||
|
simpleDescription: `This is test product number ${i}`,
|
||||||
|
description: [{ key: 'Size', value: 'Medium' }],
|
||||||
|
subcategoryId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjects(): Observable<Project[]> {
|
||||||
|
return of(this.projects).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategories(projectId: string): Observable<Category[]> {
|
||||||
|
const cats = this.categories.filter(c => c.projectId === projectId);
|
||||||
|
return of(cats).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory(categoryId: string): Observable<Category> {
|
||||||
|
const cat = this.categories.find(c => c.id === categoryId)!;
|
||||||
|
return of(cat).pipe(delay(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategory(categoryId: string, data: Partial<Category>): Observable<Category> {
|
||||||
|
const cat = this.categories.find(c => c.id === categoryId)!;
|
||||||
|
Object.assign(cat, data);
|
||||||
|
return of(cat).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory(projectId: string, data: Partial<Category>): Observable<Category> {
|
||||||
|
const newCat: Category = {
|
||||||
|
id: `cat${Date.now()}`,
|
||||||
|
name: data.name || 'New Category',
|
||||||
|
visible: data.visible ?? true,
|
||||||
|
priority: data.priority || 99,
|
||||||
|
img: data.img,
|
||||||
|
projectId,
|
||||||
|
subcategories: []
|
||||||
|
};
|
||||||
|
this.categories.push(newCat);
|
||||||
|
return of(newCat).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(categoryId: string): Observable<void> {
|
||||||
|
const index = this.categories.findIndex(c => c.id === categoryId);
|
||||||
|
if (index > -1) this.categories.splice(index, 1);
|
||||||
|
return of(void 0).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubcategory(subcategoryId: string): Observable<Subcategory> {
|
||||||
|
let sub: Subcategory | undefined;
|
||||||
|
for (const cat of this.categories) {
|
||||||
|
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
||||||
|
if (sub) break;
|
||||||
|
}
|
||||||
|
return of(sub!).pipe(delay(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
|
let sub: Subcategory | undefined;
|
||||||
|
for (const cat of this.categories) {
|
||||||
|
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
||||||
|
if (sub) {
|
||||||
|
Object.assign(sub, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return of(sub!).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
|
const cat = this.categories.find(c => c.id === categoryId)!;
|
||||||
|
const newSub: Subcategory = {
|
||||||
|
id: `sub${Date.now()}`,
|
||||||
|
name: data.name || 'New Subcategory',
|
||||||
|
visible: data.visible ?? true,
|
||||||
|
priority: data.priority || 99,
|
||||||
|
img: data.img,
|
||||||
|
categoryId,
|
||||||
|
itemCount: 0
|
||||||
|
};
|
||||||
|
if (!cat.subcategories) cat.subcategories = [];
|
||||||
|
cat.subcategories.push(newSub);
|
||||||
|
return of(newSub).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSubcategory(subcategoryId: string): Observable<void> {
|
||||||
|
for (const cat of this.categories) {
|
||||||
|
const index = cat.subcategories?.findIndex(s => s.id === subcategoryId) ?? -1;
|
||||||
|
if (index > -1) {
|
||||||
|
cat.subcategories?.splice(index, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return of(void 0).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
||||||
|
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
|
||||||
|
|
||||||
|
// Filter by subcategory
|
||||||
|
allItems = allItems.filter(item => item.subcategoryId === subcategoryId);
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (search) {
|
||||||
|
allItems = allItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply visibility filter
|
||||||
|
if (filters?.visible !== undefined) {
|
||||||
|
allItems = allItems.filter(item => item.visible === filters.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = allItems.length;
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit;
|
||||||
|
const items = allItems.slice(start, end);
|
||||||
|
|
||||||
|
return of({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
hasMore: end < total
|
||||||
|
}).pipe(delay(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(itemId: string): Observable<Item> {
|
||||||
|
const item = this.items.find(i => i.id === itemId)!;
|
||||||
|
return of(item).pipe(delay(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem(itemId: string, data: Partial<Item>): Observable<Item> {
|
||||||
|
const item = this.items.find(i => i.id === itemId)!;
|
||||||
|
Object.assign(item, data);
|
||||||
|
return of(item).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
createItem(subcategoryId: string, data: Partial<Item>): Observable<Item> {
|
||||||
|
const newItem: Item = {
|
||||||
|
id: `item${Date.now()}`,
|
||||||
|
name: data.name || 'New Item',
|
||||||
|
visible: data.visible ?? true,
|
||||||
|
priority: data.priority || 99,
|
||||||
|
quantity: data.quantity || 0,
|
||||||
|
price: data.price || 0,
|
||||||
|
currency: data.currency || 'USD',
|
||||||
|
imgs: data.imgs || [],
|
||||||
|
tags: data.tags || [],
|
||||||
|
simpleDescription: data.simpleDescription || '',
|
||||||
|
description: data.description || [],
|
||||||
|
subcategoryId
|
||||||
|
};
|
||||||
|
this.items.push(newItem);
|
||||||
|
return of(newItem).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(itemId: string): Observable<void> {
|
||||||
|
const index = this.items.findIndex(i => i.id === itemId);
|
||||||
|
if (index > -1) this.items.splice(index, 1);
|
||||||
|
return of(void 0).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateItems(itemIds: string[], data: Partial<Item>): Observable<void> {
|
||||||
|
itemIds.forEach(id => {
|
||||||
|
const item = this.items.find(i => i.id === id);
|
||||||
|
if (item) Object.assign(item, data);
|
||||||
|
});
|
||||||
|
return of(void 0).pipe(delay(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadImage(file: File): Observable<{ url: string }> {
|
||||||
|
// Simulate upload
|
||||||
|
const url = `https://via.placeholder.com/600x400?text=${encodeURIComponent(file.name)}`;
|
||||||
|
return of({ url }).pipe(delay(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/environments/environment.production.ts
Normal file
5
src/environments/environment.production.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
useMockData: false,
|
||||||
|
apiUrl: '/api'
|
||||||
|
};
|
||||||
5
src/environments/environment.ts
Normal file
5
src/environments/environment.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
useMockData: true, // Set to false when backend is ready
|
||||||
|
apiUrl: '/api'
|
||||||
|
};
|
||||||
15
src/index.html
Normal file
15
src/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>MarketBackOffice</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
73
src/styles.scss
Normal file
73
src/styles.scss
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
@import '@angular/material/prebuilt-themes/indigo-pink.css';
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global button improvements
|
||||||
|
button[mat-raised-button], button[mat-flat-button] {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[mat-icon-button] {
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button[mat-mini-fab] {
|
||||||
|
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.35) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Material form field improvements
|
||||||
|
.mat-mdc-form-field {
|
||||||
|
.mat-mdc-text-field-wrapper {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved mat-toolbar
|
||||||
|
mat-toolbar {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Better card shadows
|
||||||
|
mat-card {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar styling
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
15
tsconfig.spec.json
Normal file
15
tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user