improvements are done
This commit is contained in:
47
API.md
47
API.md
@@ -107,30 +107,41 @@ Response:
|
|||||||
"priority": 1,
|
"priority": 1,
|
||||||
"img": "https://...",
|
"img": "https://...",
|
||||||
"categoryId": "cat1",
|
"categoryId": "cat1",
|
||||||
"itemCount": 15
|
"itemCount": 15,
|
||||||
|
"hasItems": true,
|
||||||
|
"subcategories": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Subcategories can have nested subcategories (recursive)
|
||||||
|
- If hasItems is true, cannot create child subcategories
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Single Subcategory
|
### Get Single Subcategory
|
||||||
```
|
```
|
||||||
GET /api/subcategories/:subcategoryId
|
GET /api/subcategories/:subcategoryId
|
||||||
|
|
||||||
Response: (subcategory object)
|
Response: (subcategory object with nested subcategories if any)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Subcategory
|
### Create Subcategory
|
||||||
```
|
```
|
||||||
POST /api/categories/:categoryId/subcategories
|
POST /api/categories/:categoryId/subcategories
|
||||||
|
|
||||||
|
Note: categoryId can be either a category ID or a parent subcategory ID for nested structure
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
{
|
{
|
||||||
|
"id": "custom-id", // Optional, auto-generated if not provided
|
||||||
"name": "New Subcategory",
|
"name": "New Subcategory",
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"priority": 10
|
"priority": 10
|
||||||
}
|
}
|
||||||
|
|
||||||
Response: (created subcategory object)
|
Response: (created subcategory object)
|
||||||
|
|
||||||
|
Error: Returns 400 if parent subcategory already has items
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update Subcategory
|
### Update Subcategory
|
||||||
@@ -139,6 +150,7 @@ PATCH /api/subcategories/:subcategoryId
|
|||||||
|
|
||||||
Body: (any field)
|
Body: (any field)
|
||||||
{
|
{
|
||||||
|
"id": "new-id", // ID is now editable (used for routing)
|
||||||
"name": "Updated Name",
|
"name": "Updated Name",
|
||||||
"visible": false
|
"visible": false
|
||||||
}
|
}
|
||||||
@@ -185,7 +197,15 @@ Response:
|
|||||||
{ "key": "Storage", "value": "256GB" }
|
{ "key": "Storage", "value": "256GB" }
|
||||||
],
|
],
|
||||||
"subcategoryId": "sub1",
|
"subcategoryId": "sub1",
|
||||||
"comments": [...]
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"text": "Great product!",
|
||||||
|
"author": "John Doe",
|
||||||
|
"stars": 5,
|
||||||
|
"createdAt": "2024-01-10T10:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 150,
|
"total": 150,
|
||||||
@@ -299,3 +319,24 @@ Response:
|
|||||||
- Images: array of URLs
|
- Images: array of URLs
|
||||||
- Description: array of key-value pairs
|
- Description: array of key-value pairs
|
||||||
- Auto-save triggers PATCH with single field every 500ms
|
- Auto-save triggers PATCH with single field every 500ms
|
||||||
|
### Business Rules
|
||||||
|
|
||||||
|
1. **Nested Subcategories**
|
||||||
|
- Subcategories can have unlimited nesting levels
|
||||||
|
- A subcategory with items (`hasItems: true`) cannot have child subcategories
|
||||||
|
- Creating a subcategory under a parent with items will fail
|
||||||
|
|
||||||
|
2. **Item Management**
|
||||||
|
- When first item is created in a subcategory, `hasItems` is set to true
|
||||||
|
- When last item is deleted, `hasItems` is set to false
|
||||||
|
- Items belong to the deepest subcategory in the hierarchy
|
||||||
|
|
||||||
|
3. **Comments**
|
||||||
|
- Stars field is optional (1-5 rating)
|
||||||
|
- createdAt is ISO 8601 timestamp
|
||||||
|
- author is optional (can be anonymous)
|
||||||
|
|
||||||
|
4. **URL Structure for Marketplace**
|
||||||
|
- Items: `/{categoryId}/{subcategoryId}/.../{itemId}`
|
||||||
|
- Example: `/electronics/smartphones/iphone-15`
|
||||||
|
- Example nested: `/electronics/smartphones/apple/iphone-15`
|
||||||
370
IMPROVEMENTS.md
Normal file
370
IMPROVEMENTS.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# Code Review & Improvements
|
||||||
|
|
||||||
|
## ✅ Completed Improvements
|
||||||
|
|
||||||
|
### 1. Nested Subcategories
|
||||||
|
- ✅ Subcategories can now have infinite nesting levels
|
||||||
|
- ✅ Recursive tree rendering with ng-template
|
||||||
|
- ✅ Visual hierarchy with indentation
|
||||||
|
- ✅ Expand/collapse functionality preserved on refresh
|
||||||
|
|
||||||
|
### 2. Business Logic Enforcement
|
||||||
|
- ✅ Subcategories with items cannot have child subcategories
|
||||||
|
- ✅ `hasItems` flag automatically managed on item creation/deletion
|
||||||
|
- ✅ "Add Subcategory" button hidden when items exist
|
||||||
|
|
||||||
|
### 3. UI/UX Improvements
|
||||||
|
- ✅ Selected category/subcategory highlighted in sidebar
|
||||||
|
- ✅ Back button keeps user within project view
|
||||||
|
- ✅ Star ratings display in comments
|
||||||
|
- ✅ Editable subcategory IDs (for routing)
|
||||||
|
- ✅ Preview button with correct marketplace URL
|
||||||
|
|
||||||
|
### 5. API Documentation
|
||||||
|
- ✅ Updated with new fields and business rules
|
||||||
|
- ✅ Added nested subcategory examples
|
||||||
|
- ✅ Documented validation rules
|
||||||
|
|
||||||
|
## 🔧 Recommended Improvements
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. **Error Handling Enhancement**
|
||||||
|
```typescript
|
||||||
|
// Current: Generic error handling
|
||||||
|
// Improve: Specific error messages and user feedback
|
||||||
|
- Add toast notifications for network errors
|
||||||
|
- Display validation errors from API
|
||||||
|
- Retry logic with user notification
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Loading States**
|
||||||
|
```typescript
|
||||||
|
// Issue: Some operations lack loading indicators
|
||||||
|
// Fix: Add skeleton loaders for better UX
|
||||||
|
- Tree loading skeleton
|
||||||
|
- Image upload progress bars
|
||||||
|
- Save indicators for all fields
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validation**
|
||||||
|
```typescript
|
||||||
|
// Add client-side validation before API calls:
|
||||||
|
- Required field checks
|
||||||
|
- Price/quantity >= 0
|
||||||
|
- Valid URL format for images
|
||||||
|
- Unique subcategory IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Confirmation Dialogs**
|
||||||
|
```typescript
|
||||||
|
// Add warnings for destructive actions:
|
||||||
|
- Deleting category with subcategories
|
||||||
|
- Deleting subcategory with items
|
||||||
|
- Bulk operations affecting many items
|
||||||
|
```
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
5. **Performance Optimization**
|
||||||
|
```typescript
|
||||||
|
// Optimize tree rendering:
|
||||||
|
- Implement virtual scrolling for large trees
|
||||||
|
- Lazy load subcategories on expand
|
||||||
|
- Cache API responses
|
||||||
|
- Debounce search inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Drag & Drop Reordering**
|
||||||
|
```typescript
|
||||||
|
// Allow manual priority setting via drag:
|
||||||
|
- Drag categories to reorder
|
||||||
|
- Drag subcategories within parent
|
||||||
|
- Drag items in list
|
||||||
|
- Auto-update priority field
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Bulk Operations UI**
|
||||||
|
```typescript
|
||||||
|
// Enhance bulk operations:
|
||||||
|
- Select all/none in current view
|
||||||
|
- Bulk edit dialog for multiple fields
|
||||||
|
- Undo functionality
|
||||||
|
- Operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Image Management**
|
||||||
|
```typescript
|
||||||
|
// Improve image handling:
|
||||||
|
- Image preview before upload
|
||||||
|
- Crop/resize functionality
|
||||||
|
- CDN integration
|
||||||
|
- Alt text for accessibility
|
||||||
|
- Multiple image upload with drag & drop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
9. **Search & Filters**
|
||||||
|
```typescript
|
||||||
|
// Enhanced filtering:
|
||||||
|
- Search within project view sidebar
|
||||||
|
- Filter by visibility, priority range
|
||||||
|
- Advanced item search (by tags, price range)
|
||||||
|
- Save filter presets
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Analytics & Insights**
|
||||||
|
```typescript
|
||||||
|
// Add dashboard metrics:
|
||||||
|
- Total items per category
|
||||||
|
- Low stock alerts
|
||||||
|
- Popular items (from comments)
|
||||||
|
- Recently modified items
|
||||||
|
```
|
||||||
|
|
||||||
|
11. **Keyboard Shortcuts**
|
||||||
|
```typescript
|
||||||
|
// Add shortcuts for power users:
|
||||||
|
- Ctrl+S: Force save
|
||||||
|
- Ctrl+N: New item/category
|
||||||
|
- Esc: Close editors
|
||||||
|
- Arrow keys: Navigate tree
|
||||||
|
```
|
||||||
|
|
||||||
|
12. **Accessibility**
|
||||||
|
```typescript
|
||||||
|
// ARIA labels and keyboard navigation:
|
||||||
|
- Screen reader support
|
||||||
|
- Tab navigation
|
||||||
|
- Focus indicators
|
||||||
|
- Color contrast compliance
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Known Issues to Fix
|
||||||
|
|
||||||
|
### 1. API Service Type Safety
|
||||||
|
```typescript
|
||||||
|
// File: api.service.ts
|
||||||
|
// Issue: getSubcategories uses incorrect cast
|
||||||
|
getSubcategories(categoryId: string): Observable<Subcategory[]> {
|
||||||
|
if (environment.useMockData) return this.mockService.getCategory(categoryId).pipe(
|
||||||
|
tap(cat => cat.subcategories || [])
|
||||||
|
) as any; // ❌ Unsafe cast
|
||||||
|
|
||||||
|
// Fix: Return proper type
|
||||||
|
return this.mockService.getCategory(categoryId).pipe(
|
||||||
|
map(cat => cat.subcategories || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Async Preview URL Building
|
||||||
|
```typescript
|
||||||
|
// File: item-editor.component.ts
|
||||||
|
// Issue: No error handling if path building fails
|
||||||
|
async previewInMarketplace() {
|
||||||
|
try {
|
||||||
|
const path = await this.buildCategoryPath();
|
||||||
|
if (!path) {
|
||||||
|
this.snackBar.open('Unable to build preview URL', 'Close');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const marketplaceUrl = `${environment.marketplaceUrl}${path}`;
|
||||||
|
window.open(marketplaceUrl, '_blank');
|
||||||
|
} catch (err) {
|
||||||
|
this.snackBar.open('Preview failed', 'Close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Memory Leaks
|
||||||
|
```typescript
|
||||||
|
// Issue: Subscriptions not unsubscribed
|
||||||
|
// Fix: Use takeUntilDestroyed() or async pipe
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe(/* ... */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Route State Loss
|
||||||
|
```typescript
|
||||||
|
// Issue: Expanded state lost on navigation
|
||||||
|
// Solution: Store in service or localStorage
|
||||||
|
expandedNodes = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const saved = localStorage.getItem('expandedNodes');
|
||||||
|
if (saved) this.expandedNodes.set(new Set(JSON.parse(saved)));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Code Quality Metrics
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- ✅ TypeScript strict mode enabled
|
||||||
|
- ✅ Standalone components (modern Angular)
|
||||||
|
- ✅ Signals for reactive state
|
||||||
|
- ✅ Material Design components
|
||||||
|
- ✅ Responsive layouts
|
||||||
|
- ⚠️ Limited error handling
|
||||||
|
- ⚠️ No unit tests
|
||||||
|
- ⚠️ No E2E tests
|
||||||
|
|
||||||
|
### Recommended Next Steps
|
||||||
|
|
||||||
|
1. **Add Testing**
|
||||||
|
- Unit tests for services (Jasmine/Jest)
|
||||||
|
- Component tests with Testing Library
|
||||||
|
- E2E tests with Playwright
|
||||||
|
|
||||||
|
2. **CI/CD Pipeline**
|
||||||
|
- Automated builds
|
||||||
|
- Linting (ESLint)
|
||||||
|
- Type checking
|
||||||
|
- Test coverage reports
|
||||||
|
|
||||||
|
3. **Documentation**
|
||||||
|
- Component documentation (Storybook)
|
||||||
|
- JSDoc comments for complex logic
|
||||||
|
- Architecture decision records (ADRs)
|
||||||
|
|
||||||
|
4. **Monitoring**
|
||||||
|
- Error tracking (Sentry)
|
||||||
|
- Analytics (Google Analytics)
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
## 🎯 Architecture Recommendations
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
Consider adding NgRx or Signals-based store if:
|
||||||
|
- App grows beyond 20+ components
|
||||||
|
- Need time-travel debugging
|
||||||
|
- Complex state synchronization needed
|
||||||
|
|
||||||
|
### API Layer
|
||||||
|
- Add API response caching
|
||||||
|
- Implement optimistic updates
|
||||||
|
- Add retry policies with exponential backoff
|
||||||
|
- Consider GraphQL for flexible queries
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── core/ # Singletons, guards, interceptors
|
||||||
|
├── shared/ # Reusable components, pipes, directives
|
||||||
|
├── features/ # Feature modules
|
||||||
|
│ ├── projects/
|
||||||
|
│ ├── categories/
|
||||||
|
│ └── items/
|
||||||
|
└── models/ # TypeScript interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Code Style Consistency
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- ✅ Components: `kebab-case.component.ts`
|
||||||
|
- ✅ Services: `kebab-case.service.ts`
|
||||||
|
- ✅ Interfaces: `PascalCase`
|
||||||
|
- ✅ Variables: `camelCase`
|
||||||
|
- ✅ Constants: `UPPER_SNAKE_CASE`
|
||||||
|
|
||||||
|
### Signal Usage
|
||||||
|
```typescript
|
||||||
|
// ✅ Good: Descriptive signal names
|
||||||
|
loading = signal(false);
|
||||||
|
items = signal<Item[]>([]);
|
||||||
|
|
||||||
|
// ❌ Avoid: Redundant 'signal' suffix
|
||||||
|
loadingSignal = signal(false);
|
||||||
|
itemsSignal = signal<Item[]>([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Observable Naming
|
||||||
|
```typescript
|
||||||
|
// ✅ Good: End with $
|
||||||
|
items$ = this.apiService.getItems();
|
||||||
|
|
||||||
|
// ❌ Avoid: No suffix
|
||||||
|
items = this.apiService.getItems();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Security Considerations
|
||||||
|
|
||||||
|
1. **Input Sanitization**
|
||||||
|
- Sanitize HTML in descriptions
|
||||||
|
- Validate URLs before opening
|
||||||
|
- Escape user-generated content
|
||||||
|
|
||||||
|
2. **Authentication**
|
||||||
|
- Add JWT token handling
|
||||||
|
- Implement refresh token logic
|
||||||
|
- Add route guards
|
||||||
|
|
||||||
|
3. **Authorization**
|
||||||
|
- Role-based access control
|
||||||
|
- Permission checks per action
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
4. **XSS Prevention**
|
||||||
|
- Use Angular's built-in sanitization
|
||||||
|
- Avoid `innerHTML` without sanitization
|
||||||
|
- CSP headers
|
||||||
|
|
||||||
|
## 🌐 Internationalization (i18n)
|
||||||
|
|
||||||
|
Consider adding multi-language support:
|
||||||
|
```typescript
|
||||||
|
// Using @angular/localize
|
||||||
|
<h1 i18n>Welcome to Backoffice</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
## ♿ Accessibility Checklist
|
||||||
|
|
||||||
|
- [ ] All images have alt text
|
||||||
|
- [ ] Proper heading hierarchy
|
||||||
|
- [ ] Color contrast WCAG AA compliant
|
||||||
|
- [ ] Keyboard navigation works
|
||||||
|
- [ ] Screen reader tested
|
||||||
|
- [ ] Focus management
|
||||||
|
- [ ] ARIA labels for icons
|
||||||
|
|
||||||
|
## 📱 Mobile Responsiveness
|
||||||
|
|
||||||
|
Current state: Desktop-first design
|
||||||
|
|
||||||
|
Improvements needed:
|
||||||
|
- Mobile-optimized sidebar (drawer)
|
||||||
|
- Touch-friendly controls
|
||||||
|
- Responsive tables
|
||||||
|
- Mobile image upload
|
||||||
|
- Swipe gestures
|
||||||
|
|
||||||
|
## 🚀 Performance Optimization
|
||||||
|
|
||||||
|
1. **Lazy Loading**
|
||||||
|
```typescript
|
||||||
|
// Load features on demand
|
||||||
|
{
|
||||||
|
path: 'items',
|
||||||
|
loadComponent: () => import('./items-list.component')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Image Optimization**
|
||||||
|
- Use WebP format
|
||||||
|
- Lazy load images
|
||||||
|
- Responsive images (srcset)
|
||||||
|
- CDN integration
|
||||||
|
|
||||||
|
3. **Bundle Size**
|
||||||
|
- Tree shaking enabled ✅
|
||||||
|
- Code splitting by routes
|
||||||
|
- Remove unused dependencies
|
||||||
|
- Analyze bundle with webpack-bundle-analyzer
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The codebase is well-structured with modern Angular practices. Key achievements include nested subcategories, validation logic, and good UX patterns. Focus areas for improvement are error handling, testing, and performance optimization.
|
||||||
430
QUICK-REFERENCE.md
Normal file
430
QUICK-REFERENCE.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Quick Reference Guide - New Improvements
|
||||||
|
|
||||||
|
## 🚀 How to Use New Features
|
||||||
|
|
||||||
|
### 1. Validation Service
|
||||||
|
|
||||||
|
#### Import and inject:
|
||||||
|
```typescript
|
||||||
|
import { ValidationService } from '../../services/validation.service';
|
||||||
|
|
||||||
|
constructor(private validationService: ValidationService) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Validate a single field:
|
||||||
|
```typescript
|
||||||
|
onFieldChange(field: string, value: any) {
|
||||||
|
const errors = this.validationService.validateItem({ [field]: value });
|
||||||
|
|
||||||
|
if (errors[field]) {
|
||||||
|
this.snackBar.open(`Error: ${errors[field]}`, 'Close', { duration: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with save
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Validate entire object:
|
||||||
|
```typescript
|
||||||
|
onSubmit() {
|
||||||
|
const errors = this.validationService.validateItem(this.item);
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const errorMsg = Object.values(errors).join(', ');
|
||||||
|
this.snackBar.open(`Validation failed: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with API call
|
||||||
|
this.apiService.createItem(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available validators:
|
||||||
|
- `validateRequired(value)` - Checks for empty values
|
||||||
|
- `validateNumber(value, min?, max?)` - Validates numeric input
|
||||||
|
- `validatePrice(value)` - Ensures price >= 0
|
||||||
|
- `validateQuantity(value)` - Validates integer >= 0
|
||||||
|
- `validateUrl(value)` - Checks URL format
|
||||||
|
- `validateImageUrl(value)` - Validates image extensions
|
||||||
|
- `validateId(value)` - Ensures URL-safe ID (2-50 chars)
|
||||||
|
- `validatePriority(value)` - Validates 0-9999
|
||||||
|
- `validateCurrency(value)` - USD, EUR, RUB, GBP, UAH
|
||||||
|
- `validateArrayNotEmpty(arr)` - Ensures non-empty array
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Toast Notification Service
|
||||||
|
|
||||||
|
#### Import and inject:
|
||||||
|
```typescript
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
|
||||||
|
constructor(private toast: ToastService) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Show notifications:
|
||||||
|
```typescript
|
||||||
|
// Success (green, 2s)
|
||||||
|
this.toast.success('Item saved successfully!');
|
||||||
|
|
||||||
|
// Error (red, 4s)
|
||||||
|
this.toast.error('Failed to save item');
|
||||||
|
|
||||||
|
// Warning (orange, 3s)
|
||||||
|
this.toast.warning('This action will delete all items');
|
||||||
|
|
||||||
|
// Info (blue, 2s)
|
||||||
|
this.toast.info('Loading data...');
|
||||||
|
|
||||||
|
// Custom duration
|
||||||
|
this.toast.success('Saved!', 5000); // 5 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Best practices:
|
||||||
|
- Use `success()` for completed operations
|
||||||
|
- Use `error()` for failures with specific message
|
||||||
|
- Use `warning()` before dangerous actions
|
||||||
|
- Use `info()` for status updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Loading Skeleton Component
|
||||||
|
|
||||||
|
#### Import in component:
|
||||||
|
```typescript
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [
|
||||||
|
// ... other imports
|
||||||
|
LoadingSkeletonComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Use in template:
|
||||||
|
```html
|
||||||
|
<!-- For tree/sidebar -->
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="tree"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
<!-- Your tree content -->
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- For card layouts -->
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="card"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
<!-- Your card content -->
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- For list views -->
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="list"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
<!-- Your list content -->
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- For forms -->
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
<!-- Your form content -->
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available types:
|
||||||
|
- `tree` - Nested hierarchy with indentation (sidebar)
|
||||||
|
- `card` - Grid of rectangular cards
|
||||||
|
- `list` - Vertical list of items
|
||||||
|
- `form` - Form fields with labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Enhanced Error Handling
|
||||||
|
|
||||||
|
The API service now provides detailed error messages automatically:
|
||||||
|
|
||||||
|
#### Error codes and messages:
|
||||||
|
- **400** - "Invalid request data"
|
||||||
|
- **401** - "Unauthorized - please log in"
|
||||||
|
- **403** - "You don't have permission to perform this action"
|
||||||
|
- **404** - "Resource not found"
|
||||||
|
- **409** - "Conflict - resource already exists"
|
||||||
|
- **422** - "Validation failed"
|
||||||
|
- **500** - "Internal server error"
|
||||||
|
- **503** - "Service temporarily unavailable"
|
||||||
|
|
||||||
|
#### Example usage:
|
||||||
|
```typescript
|
||||||
|
this.apiService.createItem(item).subscribe({
|
||||||
|
next: (created) => {
|
||||||
|
this.toast.success('Item created!');
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
// err.message already contains user-friendly message
|
||||||
|
this.toast.error(err.message || 'Failed to create item');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Enhanced Confirmation Dialogs
|
||||||
|
|
||||||
|
#### Example with count information:
|
||||||
|
```typescript
|
||||||
|
deleteCategory(node: CategoryNode) {
|
||||||
|
const subCount = node.children?.length || 0;
|
||||||
|
const message = subCount > 0
|
||||||
|
? `Are you sure you want to delete "${node.name}"? This will also delete ${subCount} subcategory(ies) and all their items. This action cannot be undone.`
|
||||||
|
: `Are you sure you want to delete "${node.name}"? This action cannot be undone.`;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Category',
|
||||||
|
message: message,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true // Shows red confirm button
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
// User confirmed, proceed with deletion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dialog options:
|
||||||
|
- `title` - Dialog header
|
||||||
|
- `message` - Description text (can include HTML)
|
||||||
|
- `confirmText` - Confirm button label (default: "Confirm")
|
||||||
|
- `cancelText` - Cancel button label (default: "Cancel")
|
||||||
|
- `dangerous` - Makes confirm button red (for destructive actions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Styling Tips
|
||||||
|
|
||||||
|
### Toast colors are automatic:
|
||||||
|
```typescript
|
||||||
|
// Green background
|
||||||
|
this.toast.success('...');
|
||||||
|
|
||||||
|
// Red background
|
||||||
|
this.toast.error('...');
|
||||||
|
|
||||||
|
// Orange background
|
||||||
|
this.toast.warning('...');
|
||||||
|
|
||||||
|
// Blue background
|
||||||
|
this.toast.info('...');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading skeletons animate automatically:
|
||||||
|
- Shimmer effect from left to right
|
||||||
|
- Gray placeholders that match content structure
|
||||||
|
- No additional CSS needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Component Integration Checklist
|
||||||
|
|
||||||
|
When adding these features to a new component:
|
||||||
|
|
||||||
|
### For validation:
|
||||||
|
- [ ] Import `ValidationService`
|
||||||
|
- [ ] Inject in constructor
|
||||||
|
- [ ] Call validation before API calls
|
||||||
|
- [ ] Display errors via toast or inline
|
||||||
|
|
||||||
|
### For loading states:
|
||||||
|
- [ ] Import `LoadingSkeletonComponent`
|
||||||
|
- [ ] Add to `imports` array
|
||||||
|
- [ ] Add `loading` signal
|
||||||
|
- [ ] Wrap content with `@if (loading()) { skeleton } @else { content }`
|
||||||
|
|
||||||
|
### For toasts:
|
||||||
|
- [ ] Import `ToastService`
|
||||||
|
- [ ] Inject in constructor
|
||||||
|
- [ ] Replace MatSnackBar calls with toast methods
|
||||||
|
- [ ] Use appropriate type (success/error/warning/info)
|
||||||
|
|
||||||
|
### For error handling:
|
||||||
|
- [ ] Handle `error` callback in subscribe
|
||||||
|
- [ ] Use `err.message` for user-friendly message
|
||||||
|
- [ ] Display via toast service
|
||||||
|
- [ ] Consider retry logic for critical operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Form submission with validation
|
||||||
|
```typescript
|
||||||
|
onSubmit() {
|
||||||
|
// Validate
|
||||||
|
const errors = this.validationService.validateItem(this.item());
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const errorMsg = Object.values(errors).join(', ');
|
||||||
|
this.toast.error(`Validation failed: ${errorMsg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
this.saving.set(true);
|
||||||
|
this.apiService.updateItem(this.itemId(), this.item()).subscribe({
|
||||||
|
next: (updated) => {
|
||||||
|
this.item.set(updated);
|
||||||
|
this.toast.success('Item saved successfully!');
|
||||||
|
this.saving.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toast.error(err.message || 'Failed to save item');
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Loading data with skeleton
|
||||||
|
```typescript
|
||||||
|
// Component
|
||||||
|
loading = signal(true);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getData().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.data.set(data);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toast.error(err.message || 'Failed to load data');
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="list"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
@for (item of data(); track item.id) {
|
||||||
|
<div>{{ item.name }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Destructive action with confirmation
|
||||||
|
```typescript
|
||||||
|
deleteItem() {
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Delete Item',
|
||||||
|
message: `Are you sure you want to delete "${this.item().name}"? This action cannot be undone.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.apiService.deleteItem(this.itemId()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success('Item deleted successfully');
|
||||||
|
this.router.navigate(['/items']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toast.error(err.message || 'Failed to delete item');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Testing Your Implementation
|
||||||
|
|
||||||
|
### Test validation:
|
||||||
|
1. Try submitting empty form → Should show "Name is required"
|
||||||
|
2. Try negative price → Should show "Price must be at least 0"
|
||||||
|
3. Try invalid quantity → Should show "Quantity must be a non-negative integer"
|
||||||
|
|
||||||
|
### Test toasts:
|
||||||
|
1. Success action → Should show green toast
|
||||||
|
2. Failed action → Should show red toast
|
||||||
|
3. Multiple toasts → Should queue properly
|
||||||
|
|
||||||
|
### Test loading skeletons:
|
||||||
|
1. Slow network → Should show skeleton first
|
||||||
|
2. Fast network → Should briefly show skeleton
|
||||||
|
3. Skeleton should match content structure
|
||||||
|
|
||||||
|
### Test error handling:
|
||||||
|
1. Network error → Should show user-friendly message
|
||||||
|
2. 404 error → Should show "Resource not found"
|
||||||
|
3. 500 error → Should show "Internal server error"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Migration Guide
|
||||||
|
|
||||||
|
### Replace old MatSnackBar:
|
||||||
|
```typescript
|
||||||
|
// OLD
|
||||||
|
this.snackBar.open('Saved!', 'Close', { duration: 2000 });
|
||||||
|
this.snackBar.open('Error!', 'Close', { duration: 3000 });
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
this.toast.success('Saved!');
|
||||||
|
this.toast.error('Error!');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replace old spinners:
|
||||||
|
```html
|
||||||
|
<!-- OLD -->
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner></mat-spinner>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- NEW -->
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="list"></app-loading-skeleton>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add validation to existing forms:
|
||||||
|
```typescript
|
||||||
|
// Before API call, add:
|
||||||
|
const errors = this.validationService.validateItem(this.item);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
this.toast.error(`Validation errors: ${Object.values(errors).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Additional Resources
|
||||||
|
|
||||||
|
- **IMPROVEMENTS.md** - Full improvement roadmap
|
||||||
|
- **URGENT-IMPROVEMENTS-COMPLETED.md** - Implementation details
|
||||||
|
- **API.md** - API documentation
|
||||||
|
|
||||||
|
For questions or issues, check the implementation in:
|
||||||
|
- `src/app/pages/project-view/` - Reference implementation
|
||||||
|
- `src/app/pages/item-editor/` - Validation examples
|
||||||
|
- `src/app/services/` - Service documentation
|
||||||
295
URGENT-IMPROVEMENTS-COMPLETED.md
Normal file
295
URGENT-IMPROVEMENTS-COMPLETED.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Urgent Improvements - Implementation Summary
|
||||||
|
|
||||||
|
## ✅ Completed Improvements
|
||||||
|
|
||||||
|
### 1. Enhanced Error Handling
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
#### API Service
|
||||||
|
- Enhanced `handleError` method with specific error messages based on HTTP status codes
|
||||||
|
- 400: "Invalid request data"
|
||||||
|
- 401: "Unauthorized - please log in"
|
||||||
|
- 403: "You don't have permission to perform this action"
|
||||||
|
- 404: "Resource not found"
|
||||||
|
- 409: "Conflict - resource already exists"
|
||||||
|
- 422: "Validation failed"
|
||||||
|
- 500: "Internal server error"
|
||||||
|
- 503: "Service temporarily unavailable"
|
||||||
|
- Added detailed console logging with full error context for debugging
|
||||||
|
- All errors now include fallback generic message
|
||||||
|
|
||||||
|
#### Component Level
|
||||||
|
- **project-view.component.ts**: Added validation errors with user-friendly messages
|
||||||
|
- **item-editor.component.ts**: Added real-time validation feedback with error messages
|
||||||
|
- All error messages now display through MatSnackBar with appropriate duration
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `src/app/services/api.service.ts`
|
||||||
|
- `src/app/pages/project-view/project-view.component.ts`
|
||||||
|
- `src/app/pages/item-editor/item-editor.component.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Client-Side Validation
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
#### Validation Service Created
|
||||||
|
**Location:** `src/app/services/validation.service.ts`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Field Validators:**
|
||||||
|
- `validateRequired()` - checks for empty values
|
||||||
|
- `validateNumber()` - validates numeric input with optional min/max
|
||||||
|
- `validatePrice()` - ensures price >= 0
|
||||||
|
- `validateQuantity()` - validates integer >= 0
|
||||||
|
- `validateUrl()` - checks URL format
|
||||||
|
- `validateImageUrl()` - validates image extensions (.jpg, .jpeg, .png, .gif, .webp, .svg)
|
||||||
|
- `validateId()` - ensures URL-safe ID (2-50 chars, alphanumeric and hyphens)
|
||||||
|
- `validatePriority()` - validates priority (0-9999)
|
||||||
|
- `validateCurrency()` - validates against allowed currencies (USD, EUR, RUB, GBP, UAH)
|
||||||
|
- `validateArrayNotEmpty()` - ensures arrays have at least one element
|
||||||
|
|
||||||
|
- **Composite Validators:**
|
||||||
|
- `validateItem()` - validates entire Item object
|
||||||
|
- `validateCategoryOrSubcategory()` - validates Category/Subcategory object
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- ✅ Imported in project-view component
|
||||||
|
- ✅ Imported in item-editor component
|
||||||
|
- ✅ Real-time validation on field changes in item-editor
|
||||||
|
- ✅ Validation before creating categories/subcategories
|
||||||
|
- ✅ Validation errors displayed via MatSnackBar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/services/validation.service.ts` (NEW)
|
||||||
|
- `src/app/pages/project-view/project-view.component.ts` (UPDATED)
|
||||||
|
- `src/app/pages/item-editor/item-editor.component.ts` (UPDATED)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Loading States & Skeleton Loaders
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
#### Loading Skeleton Component Created
|
||||||
|
**Location:** `src/app/components/loading-skeleton/loading-skeleton.component.ts`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Standalone component with 4 skeleton types:
|
||||||
|
- `tree` - for sidebar category tree
|
||||||
|
- `card` - for card layouts
|
||||||
|
- `list` - for list views
|
||||||
|
- `form` - for form editors
|
||||||
|
- CSS shimmer animation for better perceived performance
|
||||||
|
- Configurable via `@Input() type` property
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- ✅ Added to project-view component for tree loading
|
||||||
|
- ✅ Component is ready for integration in other views
|
||||||
|
|
||||||
|
**Visual Effect:**
|
||||||
|
- Animated shimmer gradient from left to right
|
||||||
|
- Gray placeholder boxes with proper spacing
|
||||||
|
- Mimics actual content structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/components/loading-skeleton/loading-skeleton.component.ts` (NEW)
|
||||||
|
- `src/app/pages/project-view/project-view.component.html` (UPDATED)
|
||||||
|
- `src/app/pages/project-view/project-view.component.ts` (UPDATED - imports)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Enhanced Confirmation Dialogs
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
#### Improvements:
|
||||||
|
- **Category Deletion:**
|
||||||
|
- Shows count of subcategories that will be deleted
|
||||||
|
- Different messages for categories with/without children
|
||||||
|
- Example: "This will also delete 3 subcategory(ies) and all their items"
|
||||||
|
|
||||||
|
- **Subcategory Deletion:**
|
||||||
|
- Shows nested subcategory count
|
||||||
|
- Shows if items exist
|
||||||
|
- Warns about cascade deletion
|
||||||
|
- Example: "This will also delete 2 nested subcategory(ies) and all items."
|
||||||
|
|
||||||
|
- **All confirmations now include:**
|
||||||
|
- Clear warning: "This action cannot be undone."
|
||||||
|
- `dangerous: true` flag for visual distinction
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `src/app/pages/project-view/project-view.component.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Toast Notification Service
|
||||||
|
**Status:** ✅ COMPLETED
|
||||||
|
|
||||||
|
#### Toast Service Created
|
||||||
|
**Location:** `src/app/services/toast.service.ts`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Type-safe notification methods:
|
||||||
|
- `success()` - green, 2s duration
|
||||||
|
- `error()` - red, 4s duration
|
||||||
|
- `warning()` - orange, 3s duration
|
||||||
|
- `info()` - blue, 2s duration
|
||||||
|
- Positioned at top-right corner
|
||||||
|
- Custom CSS classes for each type
|
||||||
|
- Dismissible with "Close" button
|
||||||
|
|
||||||
|
**Styling:**
|
||||||
|
- Added toast CSS classes to `src/styles.scss`
|
||||||
|
- Colors:
|
||||||
|
- Success: #4caf50 (green)
|
||||||
|
- Error: #f44336 (red)
|
||||||
|
- Warning: #ff9800 (orange)
|
||||||
|
- Info: #2196f3 (blue)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/app/services/toast.service.ts` (NEW)
|
||||||
|
- `src/app/services/index.ts` (UPDATED - export added)
|
||||||
|
- `src/styles.scss` (UPDATED - toast styles added)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Implementation Statistics
|
||||||
|
|
||||||
|
### New Files Created: 3
|
||||||
|
1. `validation.service.ts` - 173 lines
|
||||||
|
2. `loading-skeleton.component.ts` - ~80 lines
|
||||||
|
3. `toast.service.ts` - ~60 lines
|
||||||
|
|
||||||
|
### Files Modified: 6
|
||||||
|
1. `api.service.ts` - Enhanced error handling
|
||||||
|
2. `project-view.component.ts` - Validation + enhanced confirmations + loading skeleton
|
||||||
|
3. `project-view.component.html` - Loading skeleton integration
|
||||||
|
4. `item-editor.component.ts` - Real-time validation
|
||||||
|
5. `services/index.ts` - Export updates
|
||||||
|
6. `styles.scss` - Toast notification styling
|
||||||
|
|
||||||
|
### Total Lines Added: ~400+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Impact on User Experience
|
||||||
|
|
||||||
|
### Before:
|
||||||
|
- ❌ Generic "Failed to save" errors
|
||||||
|
- ❌ No client-side validation
|
||||||
|
- ❌ Simple spinner for loading
|
||||||
|
- ❌ Basic confirmations without details
|
||||||
|
- ❌ Inconsistent error display
|
||||||
|
|
||||||
|
### After:
|
||||||
|
- ✅ Specific, actionable error messages
|
||||||
|
- ✅ Real-time validation with immediate feedback
|
||||||
|
- ✅ Professional loading skeletons
|
||||||
|
- ✅ Detailed confirmations showing impact
|
||||||
|
- ✅ Consistent toast notifications with color coding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Testing Recommendations
|
||||||
|
|
||||||
|
### Validation Testing:
|
||||||
|
1. Try saving item with empty name → Should show "Name is required"
|
||||||
|
2. Try saving item with negative price → Should show "Price must be at least 0"
|
||||||
|
3. Try saving item with invalid quantity → Should show "Quantity must be a non-negative integer"
|
||||||
|
4. Try invalid image URL → Should show extension validation error
|
||||||
|
|
||||||
|
### Error Handling Testing:
|
||||||
|
1. Simulate 404 error → Should show "Resource not found"
|
||||||
|
2. Simulate 500 error → Should show "Internal server error"
|
||||||
|
3. Simulate network failure → Should show appropriate message
|
||||||
|
|
||||||
|
### Loading States Testing:
|
||||||
|
1. Refresh project view → Should show tree skeleton
|
||||||
|
2. Skeleton should disappear when data loads
|
||||||
|
3. Skeleton should animate with shimmer effect
|
||||||
|
|
||||||
|
### Confirmation Testing:
|
||||||
|
1. Delete category with 3 subcategories → Should show "This will also delete 3 subcategory(ies)..."
|
||||||
|
2. Delete subcategory with items → Should show "This will also delete all items."
|
||||||
|
3. Delete subcategory with nested subs → Should show nested count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Next Steps (Not Yet Implemented)
|
||||||
|
|
||||||
|
### Medium Priority:
|
||||||
|
- [ ] Integrate toast service in all components (replace MatSnackBar)
|
||||||
|
- [ ] Add loading skeletons to item-editor, category-editor, items-list
|
||||||
|
- [ ] Add validation to category-editor and subcategory-editor components
|
||||||
|
- [ ] Add undo/redo functionality
|
||||||
|
- [ ] Add bulk operations with progress tracking
|
||||||
|
- [ ] Add optimistic UI updates
|
||||||
|
|
||||||
|
### Low Priority:
|
||||||
|
- [ ] Add keyboard shortcuts
|
||||||
|
- [ ] Add accessibility improvements (ARIA labels)
|
||||||
|
- [ ] Add PWA support
|
||||||
|
- [ ] Add offline mode with sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Usage Examples
|
||||||
|
|
||||||
|
### Using Validation Service:
|
||||||
|
```typescript
|
||||||
|
import { ValidationService } from '../../services/validation.service';
|
||||||
|
|
||||||
|
constructor(private validationService: ValidationService) {}
|
||||||
|
|
||||||
|
validateForm() {
|
||||||
|
const errors = this.validationService.validateItem(this.item);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
// Show errors
|
||||||
|
this.snackBar.open(`Validation errors: ${Object.values(errors).join(', ')}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Toast Service:
|
||||||
|
```typescript
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
|
||||||
|
constructor(private toast: ToastService) {}
|
||||||
|
|
||||||
|
onSave() {
|
||||||
|
this.toast.success('Item saved successfully!');
|
||||||
|
// or
|
||||||
|
this.toast.error('Failed to save item');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Loading Skeleton:
|
||||||
|
```html
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
|
} @else {
|
||||||
|
<!-- Your content -->
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Summary
|
||||||
|
|
||||||
|
All **HIGH PRIORITY** urgent improvements have been successfully implemented:
|
||||||
|
|
||||||
|
1. ✅ Enhanced error handling with specific messages
|
||||||
|
2. ✅ Comprehensive client-side validation
|
||||||
|
3. ✅ Professional loading skeletons
|
||||||
|
4. ✅ Enhanced confirmation dialogs with detailed warnings
|
||||||
|
5. ✅ Toast notification service (bonus)
|
||||||
|
|
||||||
|
The application now provides:
|
||||||
|
- **Better user feedback** - Clear, actionable error messages
|
||||||
|
- **Improved UX** - Professional loading states and validation
|
||||||
|
- **Safer operations** - Detailed confirmations before destructive actions
|
||||||
|
- **Consistent notifications** - Color-coded toast messages
|
||||||
|
|
||||||
|
**Status:** Ready for testing and QA!
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-loading-skeleton',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="skeleton-wrapper">
|
||||||
|
@if (type === 'tree') {
|
||||||
|
<div class="skeleton-tree">
|
||||||
|
@for (item of [1,2,3,4,5]; track item) {
|
||||||
|
<div class="skeleton-tree-item">
|
||||||
|
<div class="skeleton-circle"></div>
|
||||||
|
<div class="skeleton-line" [style.width]="getRandomWidth()"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (type === 'card') {
|
||||||
|
<div class="skeleton-card">
|
||||||
|
<div class="skeleton-image"></div>
|
||||||
|
<div class="skeleton-text">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (type === 'list') {
|
||||||
|
<div class="skeleton-list">
|
||||||
|
@for (item of [1,2,3]; track item) {
|
||||||
|
<div class="skeleton-list-item">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (type === 'form') {
|
||||||
|
<div class="skeleton-form">
|
||||||
|
@for (item of [1,2,3,4]; track item) {
|
||||||
|
<div class="skeleton-field">
|
||||||
|
<div class="skeleton-line short"></div>
|
||||||
|
<div class="skeleton-input"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.skeleton-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -468px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 468px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line,
|
||||||
|
.skeleton-circle,
|
||||||
|
.skeleton-image,
|
||||||
|
.skeleton-input {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#f0f0f0 0%,
|
||||||
|
#e0e0e0 20%,
|
||||||
|
#f0f0f0 40%,
|
||||||
|
#f0f0f0 100%
|
||||||
|
);
|
||||||
|
background-size: 800px 104px;
|
||||||
|
animation: shimmer 1.5s infinite linear;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-circle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 16px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-image {
|
||||||
|
height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-input {
|
||||||
|
height: 56px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class LoadingSkeletonComponent {
|
||||||
|
@Input() type: 'tree' | 'card' | 'list' | 'form' = 'list';
|
||||||
|
|
||||||
|
getRandomWidth(): string {
|
||||||
|
const widths = ['60%', '70%', '80%', '90%'];
|
||||||
|
return widths[Math.floor(Math.random() * widths.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,4 +16,6 @@ export interface Subcategory {
|
|||||||
img?: string;
|
img?: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
|
subcategories?: Subcategory[];
|
||||||
|
hasItems?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Comment {
|
|||||||
text: string;
|
text: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
stars?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemsListResponse {
|
export interface ItemsListResponse {
|
||||||
|
|||||||
@@ -235,7 +235,10 @@
|
|||||||
placeholder="e.g. Black">
|
placeholder="e.g. Black">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<button mat-raised-button color="primary" (click)="addDescriptionField()">
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="addDescriptionField()">
|
||||||
<mat-icon>add</mat-icon>
|
<mat-icon>add</mat-icon>
|
||||||
Add Field
|
Add Field
|
||||||
</button>
|
</button>
|
||||||
@@ -289,7 +292,18 @@
|
|||||||
@for (comment of item()!.comments; track comment.id) {
|
@for (comment of item()!.comments; track comment.id) {
|
||||||
<div class="comment-card">
|
<div class="comment-card">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
|
<div>
|
||||||
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
||||||
|
@if (comment.stars !== undefined && comment.stars !== null) {
|
||||||
|
<span class="comment-stars">
|
||||||
|
@for (star of [1,2,3,4,5]; track star) {
|
||||||
|
<mat-icon class="star-icon" [class.filled]="star <= comment.stars!">
|
||||||
|
{{ star <= comment.stars! ? 'star' : 'star_border' }}
|
||||||
|
</mat-icon>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
|
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ comment.text }}</p>
|
<p>{{ comment.text }}</p>
|
||||||
|
|||||||
@@ -279,10 +279,32 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.star-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #ffa726;
|
||||||
|
|
||||||
|
&.filled {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.comment-date {
|
.comment-date {
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, OnInit, signal } from '@angular/core';
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@@ -15,7 +16,8 @@ import { MatTabsModule } from '@angular/material/tabs';
|
|||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { Item, ItemDescriptionField } from '../../models';
|
import { ValidationService } from '../../services/validation.service';
|
||||||
|
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -42,10 +44,12 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
|
|||||||
})
|
})
|
||||||
export class ItemEditorComponent implements OnInit {
|
export class ItemEditorComponent implements OnInit {
|
||||||
item = signal<Item | null>(null);
|
item = signal<Item | null>(null);
|
||||||
|
subcategory = signal<Subcategory | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
saving = signal(false);
|
saving = signal(false);
|
||||||
itemId = signal<string>('');
|
itemId = signal<string>('');
|
||||||
projectId = signal<string>('');
|
projectId = signal<string>('');
|
||||||
|
validationErrors = signal<Record<string, string>>({});
|
||||||
|
|
||||||
newTag = '';
|
newTag = '';
|
||||||
newDescKey = '';
|
newDescKey = '';
|
||||||
@@ -59,7 +63,8 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private snackBar: MatSnackBar,
|
private snackBar: MatSnackBar,
|
||||||
private dialog: MatDialog
|
private dialog: MatDialog,
|
||||||
|
private validationService: ValidationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -80,7 +85,8 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
this.apiService.getItem(this.itemId()).subscribe({
|
this.apiService.getItem(this.itemId()).subscribe({
|
||||||
next: (item) => {
|
next: (item) => {
|
||||||
this.item.set(item);
|
this.item.set(item);
|
||||||
this.loading.set(false);
|
// Load subcategory to get allowed description fields
|
||||||
|
this.loadSubcategory(item.subcategoryId);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load item', err);
|
console.error('Failed to load item', err);
|
||||||
@@ -90,7 +96,75 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadSubcategory(subcategoryId: string) {
|
||||||
|
this.apiService.getSubcategory(subcategoryId).subscribe({
|
||||||
|
next: (subcategory) => {
|
||||||
|
this.subcategory.set(subcategory);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load subcategory', err);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildCategoryPath(): Promise<string> {
|
||||||
|
// Build path like: /category/subcategory/subsubcategory/item
|
||||||
|
const item = this.item();
|
||||||
|
if (!item) return '';
|
||||||
|
|
||||||
|
const pathSegments: string[] = [];
|
||||||
|
let currentSubcategoryId = item.subcategoryId;
|
||||||
|
|
||||||
|
// Traverse up the subcategory hierarchy
|
||||||
|
while (currentSubcategoryId) {
|
||||||
|
try {
|
||||||
|
const subcategory = await this.apiService.getSubcategory(currentSubcategoryId).toPromise();
|
||||||
|
if (!subcategory) break;
|
||||||
|
|
||||||
|
pathSegments.unshift(subcategory.id); // Add to beginning
|
||||||
|
|
||||||
|
// Check if this subcategory has a parent subcategory or belongs to a category
|
||||||
|
if (subcategory.categoryId && subcategory.categoryId.startsWith('cat')) {
|
||||||
|
// This is directly under a category, add category and stop
|
||||||
|
const category = await this.apiService.getCategory(subcategory.categoryId).toPromise();
|
||||||
|
if (category) {
|
||||||
|
pathSegments.unshift(category.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// This is under another subcategory, continue traversing
|
||||||
|
currentSubcategoryId = subcategory.categoryId;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error building path:', err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pathSegments.push(this.itemId());
|
||||||
|
return '/' + pathSegments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
onFieldChange(field: keyof Item, value: any) {
|
onFieldChange(field: keyof Item, value: any) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (!currentItem) return;
|
||||||
|
|
||||||
|
// Validate the specific field
|
||||||
|
const errors = this.validationService.validateItem({ [field]: value } as any);
|
||||||
|
const currentErrors = { ...this.validationErrors() };
|
||||||
|
|
||||||
|
if (errors[field]) {
|
||||||
|
currentErrors[field] = errors[field];
|
||||||
|
this.validationErrors.set(currentErrors);
|
||||||
|
this.snackBar.open(`Validation error: ${errors[field]}`, 'Close', { duration: 3000 });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
delete currentErrors[field];
|
||||||
|
this.validationErrors.set(currentErrors);
|
||||||
|
}
|
||||||
|
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
this.apiService.queueSave('item', this.itemId(), field, value);
|
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||||
|
|
||||||
@@ -203,14 +277,32 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
if (item && item.subcategoryId) {
|
if (item && item.subcategoryId) {
|
||||||
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||||
} else {
|
} else {
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previewInMarketplace() {
|
async previewInMarketplace() {
|
||||||
// Open marketplace in new tab with this item
|
// Open marketplace in new tab with this item
|
||||||
const marketplaceUrl = `http://localhost:4200/item/${this.itemId()}`;
|
const item = this.item();
|
||||||
|
const subcategory = this.subcategory();
|
||||||
|
if (!item || !subcategory) {
|
||||||
|
this.snackBar.open('Item data not loaded', 'Close', { duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build the full category hierarchy path
|
||||||
|
const path = await this.buildCategoryPath();
|
||||||
|
if (!path) {
|
||||||
|
this.snackBar.open('Unable to build preview URL', 'Close', { duration: 2000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const marketplaceUrl = `${environment.marketplaceUrl}${path}`;
|
||||||
window.open(marketplaceUrl, '_blank');
|
window.open(marketplaceUrl, '_blank');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview failed:', err);
|
||||||
|
this.snackBar.open('Failed to generate preview URL', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onImageDrop(event: CdkDragDrop<string[]>) {
|
onImageDrop(event: CdkDragDrop<string[]>) {
|
||||||
|
|||||||
@@ -177,13 +177,8 @@ export class ItemsListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
goBack() {
|
goBack() {
|
||||||
const subcategoryId = this.subcategoryId();
|
// Navigate back to the project view
|
||||||
if (subcategoryId) {
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
// Navigate back to subcategory editor
|
|
||||||
this.router.navigate(['/subcategory', subcategoryId]);
|
|
||||||
} else {
|
|
||||||
this.router.navigate(['/']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addItem() {
|
addItem() {
|
||||||
|
|||||||
@@ -16,14 +16,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-container">
|
<app-loading-skeleton type="tree"></app-loading-skeleton>
|
||||||
<mat-spinner diameter="40"></mat-spinner>
|
|
||||||
</div>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="tree-container">
|
<div class="tree-container">
|
||||||
@for (node of treeData(); track node.id) {
|
@for (node of treeData(); track node.id) {
|
||||||
<div class="tree-node">
|
<div class="tree-node">
|
||||||
<div class="node-content category-node">
|
<div class="node-content category-node" [class.selected]="selectedNodeId() === node.id">
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="toggleNode(node)"
|
(click)="toggleNode(node)"
|
||||||
@@ -38,6 +36,14 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="node-actions">
|
<div class="node-actions">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="addSubcategory(node, $event)"
|
||||||
|
matTooltip="Add Subcategory"
|
||||||
|
color="accent">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
<mat-slide-toggle
|
<mat-slide-toggle
|
||||||
[checked]="node.visible"
|
[checked]="node.visible"
|
||||||
(change)="toggleVisibility(node, $event)"
|
(change)="toggleVisibility(node, $event)"
|
||||||
@@ -56,39 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (node.expanded && node.children?.length) {
|
@if (node.expanded && node.children?.length) {
|
||||||
<div class="subcategories">
|
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: node.children, level: 1}"></ng-container>
|
||||||
@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>
|
||||||
}
|
}
|
||||||
@@ -111,3 +85,67 @@
|
|||||||
</mat-sidenav-content>
|
</mat-sidenav-content>
|
||||||
</mat-sidenav-container>
|
</mat-sidenav-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ng-template #subcategoryTree let-nodes="nodes" let-level="level">
|
||||||
|
<div class="subcategories" [style.padding-left.rem]="level * 1.5">
|
||||||
|
@for (subNode of nodes; track subNode.id) {
|
||||||
|
<div>
|
||||||
|
<div class="node-content subcategory-node" [class.selected]="selectedNodeId() === subNode.id">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="toggleNode(subNode)"
|
||||||
|
[disabled]="!subNode.children?.length">
|
||||||
|
<mat-icon>
|
||||||
|
{{ subNode.children?.length ? (subNode.expanded ? 'expand_more' : 'chevron_right') : '' }}
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="node-name" (click)="editNode(subNode, $event)">
|
||||||
|
{{ subNode.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="node-actions">
|
||||||
|
@if (!subNode.hasItems) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="addSubcategory(subNode, $event)"
|
||||||
|
matTooltip="Add Subcategory"
|
||||||
|
color="accent">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (subNode.hasItems) {
|
||||||
|
<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>
|
||||||
|
|
||||||
|
@if (subNode.expanded && subNode.children?.length) {
|
||||||
|
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: subNode.children, level: level + 1}"></ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
|||||||
@@ -64,16 +64,35 @@ mat-toolbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #bbdefb;
|
||||||
|
border-left: 4px solid #1976d2;
|
||||||
|
padding-left: calc(0.5rem - 4px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #90caf9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.category-node {
|
&.category-node {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #bbdefb;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.subcategory-node {
|
&.subcategory-node {
|
||||||
padding-left: 3rem;
|
padding-left: 3rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #bbdefb;
|
||||||
|
padding-left: calc(3rem - 4px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-name {
|
.node-name {
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
|
import { ValidationService } from '../../services/validation.service';
|
||||||
import { Category, Subcategory } from '../../models';
|
import { Category, Subcategory } from '../../models';
|
||||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
|
||||||
interface CategoryNode {
|
interface CategoryNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +26,8 @@ interface CategoryNode {
|
|||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
children?: CategoryNode[];
|
children?: CategoryNode[];
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
hasItems?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -39,7 +44,9 @@ interface CategoryNode {
|
|||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatSnackBarModule
|
MatSnackBarModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
LoadingSkeletonComponent
|
||||||
],
|
],
|
||||||
templateUrl: './project-view.component.html',
|
templateUrl: './project-view.component.html',
|
||||||
styleUrls: ['./project-view.component.scss']
|
styleUrls: ['./project-view.component.scss']
|
||||||
@@ -50,13 +57,15 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
categories = signal<Category[]>([]);
|
categories = signal<Category[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
treeData = signal<CategoryNode[]>([]);
|
treeData = signal<CategoryNode[]>([]);
|
||||||
|
selectedNodeId = signal<string | null>(null);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private snackBar: MatSnackBar
|
private snackBar: MatSnackBar,
|
||||||
|
private validationService: ValidationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -65,6 +74,13 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
this.loadProject();
|
this.loadProject();
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track selected route
|
||||||
|
this.router.events.subscribe(() => {
|
||||||
|
const categoryId = this.route.children[0]?.snapshot.params['categoryId'];
|
||||||
|
const subcategoryId = this.route.children[0]?.snapshot.params['subcategoryId'];
|
||||||
|
this.selectedNodeId.set(subcategoryId || categoryId || null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveRoute(): boolean {
|
hasActiveRoute(): boolean {
|
||||||
@@ -100,21 +116,43 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTree() {
|
buildTree() {
|
||||||
|
// Save current expanded state
|
||||||
|
const expandedState = new Map<string, boolean>();
|
||||||
|
const saveExpandedState = (nodes: CategoryNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.expanded) {
|
||||||
|
expandedState.set(node.id, true);
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
saveExpandedState(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
saveExpandedState(this.treeData());
|
||||||
|
|
||||||
|
// Build new tree
|
||||||
const tree: CategoryNode[] = this.categories().map(cat => ({
|
const tree: CategoryNode[] = this.categories().map(cat => ({
|
||||||
id: cat.id,
|
id: cat.id,
|
||||||
name: cat.name,
|
name: cat.name,
|
||||||
type: 'category' as const,
|
type: 'category' as const,
|
||||||
visible: cat.visible,
|
visible: cat.visible,
|
||||||
expanded: false,
|
expanded: expandedState.has(cat.id),
|
||||||
children: (cat.subcategories || []).map(sub => ({
|
children: this.buildSubcategoryTree(cat.subcategories || [], cat.id, expandedState)
|
||||||
|
}));
|
||||||
|
this.treeData.set(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSubcategoryTree(subcategories: Subcategory[], parentId: string, expandedState?: Map<string, boolean>): CategoryNode[] {
|
||||||
|
return subcategories.map(sub => ({
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
name: sub.name,
|
name: sub.name,
|
||||||
type: 'subcategory' as const,
|
type: 'subcategory' as const,
|
||||||
visible: sub.visible,
|
visible: sub.visible,
|
||||||
categoryId: cat.id
|
expanded: expandedState?.has(sub.id) || false,
|
||||||
}))
|
parentId: parentId,
|
||||||
|
hasItems: sub.hasItems,
|
||||||
|
children: this.buildSubcategoryTree(sub.subcategories || [], sub.id, expandedState)
|
||||||
}));
|
}));
|
||||||
this.treeData.set(tree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleNode(node: CategoryNode) {
|
toggleNode(node: CategoryNode) {
|
||||||
@@ -168,13 +206,61 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
|
|
||||||
dialogRef.afterClosed().subscribe(result => {
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
// Validate before creating
|
||||||
|
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const errorMsg = Object.values(errors).join(', ');
|
||||||
|
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.apiService.createCategory(this.projectId(), result).subscribe({
|
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.snackBar.open('Failed to create category', 'Close', { duration: 3000 });
|
this.snackBar.open(err.message || 'Failed to create category', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubcategory(parentNode: CategoryNode, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: 'Create New Subcategory',
|
||||||
|
type: 'subcategory',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||||
|
{ name: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
|
||||||
|
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||||
|
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
// Validate before creating
|
||||||
|
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const errorMsg = Object.values(errors).join(', ');
|
||||||
|
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = parentNode.type === 'category' ? parentNode.id : parentNode.id;
|
||||||
|
this.apiService.createSubcategory(parentId, result).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.snackBar.open(err.message || 'Failed to create subcategory', 'Close', { duration: 3000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,12 +270,18 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
deleteCategory(node: CategoryNode, event: Event) {
|
deleteCategory(node: CategoryNode, event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const subCount = node.children?.length || 0;
|
||||||
|
const message = subCount > 0
|
||||||
|
? `Are you sure you want to delete "${node.name}"? This will also delete ${subCount} subcategory(ies) and all their items. This action cannot be undone.`
|
||||||
|
: `Are you sure you want to delete "${node.name}"? This action cannot be undone.`;
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Category',
|
title: 'Delete Category',
|
||||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all subcategories and items.`,
|
message: message,
|
||||||
confirmText: 'Delete',
|
confirmText: 'Delete',
|
||||||
warning: true
|
cancelText: 'Cancel',
|
||||||
|
dangerous: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,10 +303,23 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
deleteSubcategory(node: CategoryNode, event: Event) {
|
deleteSubcategory(node: CategoryNode, event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const childCount = node.children?.length || 0;
|
||||||
|
const hasChildren = childCount > 0;
|
||||||
|
const hasItems = node.hasItems;
|
||||||
|
|
||||||
|
let message = `Are you sure you want to delete "${node.name}"?`;
|
||||||
|
if (hasChildren) {
|
||||||
|
message += ` This will also delete ${childCount} nested subcategory(ies).`;
|
||||||
|
}
|
||||||
|
if (hasItems) {
|
||||||
|
message += ' This will also delete all items.';
|
||||||
|
}
|
||||||
|
message += ' This action cannot be undone.';
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
title: 'Delete Subcategory',
|
title: 'Delete Subcategory',
|
||||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all items.`,
|
message: message,
|
||||||
confirmText: 'Delete',
|
confirmText: 'Delete',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
dangerous: true
|
dangerous: true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
@if (!loading() && !error()) {
|
@if (!loading() && !error()) {
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
@for (project of projects(); track project.id) {
|
@for (project of projects(); track project.id) {
|
||||||
<mat-card class="project-card" (click)="openProject(project.id)">
|
<mat-card class="project-card" [class.selected]="currentProjectId() === project.id" (click)="openProject(project.id)">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
@if (project.logoUrl) {
|
@if (project.logoUrl) {
|
||||||
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">
|
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border: 2px solid #1976d2;
|
||||||
|
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 8px 16px rgba(25, 118, 210, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mat-card-header {
|
mat-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class ProjectsDashboardComponent implements OnInit {
|
|||||||
projects = signal<Project[]>([]);
|
projects = signal<Project[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
currentProjectId = signal<string | null>(null);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
@@ -25,6 +26,22 @@ export class ProjectsDashboardComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.loadProjects();
|
this.loadProjects();
|
||||||
|
|
||||||
|
// Check if we're currently viewing a project
|
||||||
|
const urlSegments = this.router.url.split('/');
|
||||||
|
if (urlSegments[1] === 'project' && urlSegments[2]) {
|
||||||
|
this.currentProjectId.set(urlSegments[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to route changes
|
||||||
|
this.router.events.subscribe(() => {
|
||||||
|
const segments = this.router.url.split('/');
|
||||||
|
if (segments[1] === 'project' && segments[2]) {
|
||||||
|
this.currentProjectId.set(segments[2]);
|
||||||
|
} else {
|
||||||
|
this.currentProjectId.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProjects() {
|
loadProjects() {
|
||||||
|
|||||||
@@ -40,7 +40,15 @@
|
|||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
<mat-label>ID</mat-label>
|
<mat-label>ID</mat-label>
|
||||||
<input matInput [value]="subcategory()!.id" disabled>
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="subcategory()!.id"
|
||||||
|
(blur)="onFieldChange('id', subcategory()!.id)"
|
||||||
|
required>
|
||||||
|
<mat-hint>Used for routing - update carefully</mat-hint>
|
||||||
|
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
|
||||||
|
<mat-error>ID is required</mat-error>
|
||||||
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -119,12 +119,8 @@ export class SubcategoryEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
goBack() {
|
goBack() {
|
||||||
const sub = this.subcategory();
|
// Navigate back to the project view (close the editor)
|
||||||
if (sub && this.projectId()) {
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
|
||||||
} else {
|
|
||||||
this.router.navigate(['/']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSubcategory() {
|
deleteSubcategory() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, Subject, timer } from 'rxjs';
|
import { Observable, Subject, timer } from 'rxjs';
|
||||||
import { debounce, retry, catchError, tap } from 'rxjs/operators';
|
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
|
||||||
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||||
import { MockDataService } from './mock-data.service';
|
import { MockDataService } from './mock-data.service';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
@@ -76,9 +76,11 @@ export class ApiService {
|
|||||||
|
|
||||||
// Subcategories
|
// Subcategories
|
||||||
getSubcategories(categoryId: string): Observable<Subcategory[]> {
|
getSubcategories(categoryId: string): Observable<Subcategory[]> {
|
||||||
if (environment.useMockData) return this.mockService.getCategory(categoryId).pipe(
|
if (environment.useMockData) {
|
||||||
tap(cat => cat.subcategories || [])
|
return this.mockService.getCategory(categoryId).pipe(
|
||||||
) as any;
|
map(cat => cat.subcategories || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.http.get<Subcategory[]>(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe(
|
return this.http.get<Subcategory[]>(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe(
|
||||||
retry(2),
|
retry(2),
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
@@ -220,8 +222,51 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleError(error: any): Observable<never> {
|
private handleError(error: any): Observable<never> {
|
||||||
console.error('API Error:', error);
|
let errorMessage = 'An unexpected error occurred';
|
||||||
throw error;
|
|
||||||
|
if (error.error instanceof ErrorEvent) {
|
||||||
|
// Client-side or network error
|
||||||
|
errorMessage = `Network error: ${error.error.message}`;
|
||||||
|
} else if (error.status) {
|
||||||
|
// Backend returned an unsuccessful response code
|
||||||
|
switch (error.status) {
|
||||||
|
case 400:
|
||||||
|
errorMessage = error.error?.message || 'Invalid request';
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
errorMessage = 'Unauthorized. Please log in again.';
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
errorMessage = 'You do not have permission to perform this action';
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
errorMessage = 'Resource not found';
|
||||||
|
break;
|
||||||
|
case 409:
|
||||||
|
errorMessage = error.error?.message || 'Conflict: Resource already exists or has conflicts';
|
||||||
|
break;
|
||||||
|
case 422:
|
||||||
|
errorMessage = error.error?.message || 'Validation failed';
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
errorMessage = 'Server error. Please try again later.';
|
||||||
|
break;
|
||||||
|
case 503:
|
||||||
|
errorMessage = 'Service unavailable. Please try again later.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = error.error?.message || `Error: ${error.status} - ${error.statusText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('API Error:', {
|
||||||
|
message: errorMessage,
|
||||||
|
status: error.status,
|
||||||
|
error: error.error,
|
||||||
|
url: error.url
|
||||||
|
});
|
||||||
|
|
||||||
|
throw { message: errorMessage, status: error.status, originalError: error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export * from './api.service';
|
export * from './api.service';
|
||||||
|
export * from './validation.service';
|
||||||
|
export * from './toast.service';
|
||||||
|
|||||||
@@ -248,53 +248,106 @@ export class MockDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSubcategory(subcategoryId: string): Observable<Subcategory> {
|
getSubcategory(subcategoryId: string): Observable<Subcategory> {
|
||||||
let sub: Subcategory | undefined;
|
const sub = this.findSubcategoryById(subcategoryId);
|
||||||
for (const cat of this.categories) {
|
|
||||||
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
|
||||||
if (sub) break;
|
|
||||||
}
|
|
||||||
return of(sub!).pipe(delay(200));
|
return of(sub!).pipe(delay(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
updateSubcategory(subcategoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
let sub: Subcategory | undefined;
|
const sub = this.findSubcategoryById(subcategoryId);
|
||||||
for (const cat of this.categories) {
|
|
||||||
sub = cat.subcategories?.find(s => s.id === subcategoryId);
|
|
||||||
if (sub) {
|
if (sub) {
|
||||||
Object.assign(sub, data);
|
Object.assign(sub, data);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return of(sub!).pipe(delay(300));
|
return of(sub!).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
createSubcategory(parentId: string, data: Partial<Subcategory>): Observable<Subcategory> {
|
||||||
const cat = this.categories.find(c => c.id === categoryId)!;
|
// Check if parent already has items
|
||||||
|
const parentSubcategory = this.findSubcategoryById(parentId);
|
||||||
|
if (parentSubcategory?.hasItems) {
|
||||||
|
throw new Error('Cannot create subcategory: parent already has items');
|
||||||
|
}
|
||||||
|
|
||||||
const newSub: Subcategory = {
|
const newSub: Subcategory = {
|
||||||
id: `sub${Date.now()}`,
|
id: data.id || `sub${Date.now()}`,
|
||||||
name: data.name || 'New Subcategory',
|
name: data.name || 'New Subcategory',
|
||||||
visible: data.visible ?? true,
|
visible: data.visible ?? true,
|
||||||
priority: data.priority || 99,
|
priority: data.priority || 99,
|
||||||
img: data.img,
|
img: data.img,
|
||||||
categoryId,
|
categoryId: parentId,
|
||||||
itemCount: 0
|
itemCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Try to find parent category first
|
||||||
|
const cat = this.categories.find(c => c.id === parentId);
|
||||||
|
if (cat) {
|
||||||
if (!cat.subcategories) cat.subcategories = [];
|
if (!cat.subcategories) cat.subcategories = [];
|
||||||
cat.subcategories.push(newSub);
|
cat.subcategories.push(newSub);
|
||||||
return of(newSub).pipe(delay(300));
|
return of(newSub).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not a category, search for parent subcategory recursively
|
||||||
|
const parent = this.findSubcategoryById(parentId);
|
||||||
|
if (parent) {
|
||||||
|
if (!parent.subcategories) parent.subcategories = [];
|
||||||
|
parent.subcategories.push(newSub);
|
||||||
|
return of(newSub).pipe(delay(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent not found
|
||||||
|
throw new Error(`Parent with id ${parentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findSubcategoryById(id: string): Subcategory | null {
|
||||||
|
for (const cat of this.categories) {
|
||||||
|
const result = this.searchSubcategories(cat.subcategories || [], id);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private searchSubcategories(subcategories: Subcategory[], id: string): Subcategory | null {
|
||||||
|
for (const sub of subcategories) {
|
||||||
|
if (sub.id === id) return sub;
|
||||||
|
if (sub.subcategories) {
|
||||||
|
const result = this.searchSubcategories(sub.subcategories, id);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
deleteSubcategory(subcategoryId: string): Observable<void> {
|
deleteSubcategory(subcategoryId: string): Observable<void> {
|
||||||
|
// Try to delete from category level
|
||||||
for (const cat of this.categories) {
|
for (const cat of this.categories) {
|
||||||
const index = cat.subcategories?.findIndex(s => s.id === subcategoryId) ?? -1;
|
const index = cat.subcategories?.findIndex(s => s.id === subcategoryId) ?? -1;
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
cat.subcategories?.splice(index, 1);
|
cat.subcategories?.splice(index, 1);
|
||||||
break;
|
return of(void 0).pipe(delay(300));
|
||||||
|
}
|
||||||
|
// Try to delete from nested subcategories
|
||||||
|
if (this.deleteFromSubcategories(cat.subcategories || [], subcategoryId)) {
|
||||||
|
return of(void 0).pipe(delay(300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return of(void 0).pipe(delay(300));
|
return of(void 0).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deleteFromSubcategories(subcategories: Subcategory[], id: string): boolean {
|
||||||
|
for (const sub of subcategories) {
|
||||||
|
if (sub.subcategories) {
|
||||||
|
const index = sub.subcategories.findIndex(s => s.id === id);
|
||||||
|
if (index > -1) {
|
||||||
|
sub.subcategories.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.deleteFromSubcategories(sub.subcategories, id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
||||||
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
|
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
|
||||||
|
|
||||||
@@ -354,12 +407,32 @@ export class MockDataService {
|
|||||||
subcategoryId
|
subcategoryId
|
||||||
};
|
};
|
||||||
this.items.push(newItem);
|
this.items.push(newItem);
|
||||||
|
|
||||||
|
// Mark subcategory as having items
|
||||||
|
const subcategory = this.findSubcategoryById(subcategoryId);
|
||||||
|
if (subcategory) {
|
||||||
|
subcategory.hasItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
return of(newItem).pipe(delay(300));
|
return of(newItem).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteItem(itemId: string): Observable<void> {
|
deleteItem(itemId: string): Observable<void> {
|
||||||
|
const item = this.items.find(i => i.id === itemId);
|
||||||
const index = this.items.findIndex(i => i.id === itemId);
|
const index = this.items.findIndex(i => i.id === itemId);
|
||||||
if (index > -1) this.items.splice(index, 1);
|
if (index > -1) {
|
||||||
|
const subcategoryId = this.items[index].subcategoryId;
|
||||||
|
this.items.splice(index, 1);
|
||||||
|
|
||||||
|
// Check if subcategory still has items
|
||||||
|
const remainingItems = this.items.filter(i => i.subcategoryId === subcategoryId);
|
||||||
|
if (remainingItems.length === 0) {
|
||||||
|
const subcategory = this.findSubcategoryById(subcategoryId);
|
||||||
|
if (subcategory) {
|
||||||
|
subcategory.hasItems = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return of(void 0).pipe(delay(300));
|
return of(void 0).pipe(delay(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
src/app/services/toast.service.ts
Normal file
58
src/app/services/toast.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ToastService {
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
private readonly durations = {
|
||||||
|
success: 2000,
|
||||||
|
error: 4000,
|
||||||
|
warning: 3000,
|
||||||
|
info: 2000
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly classes = {
|
||||||
|
success: 'toast-success',
|
||||||
|
error: 'toast-error',
|
||||||
|
warning: 'toast-warning',
|
||||||
|
info: 'toast-info'
|
||||||
|
};
|
||||||
|
|
||||||
|
show(message: string, type: ToastType = 'info', duration?: number) {
|
||||||
|
this.snackBar.open(
|
||||||
|
message,
|
||||||
|
'Close',
|
||||||
|
{
|
||||||
|
duration: duration || this.durations[type],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top',
|
||||||
|
panelClass: [this.classes[type]]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, duration?: number) {
|
||||||
|
this.show(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, duration?: number) {
|
||||||
|
this.show(message, 'error', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message: string, duration?: number) {
|
||||||
|
this.show(message, 'warning', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, duration?: number) {
|
||||||
|
this.show(message, 'info', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
this.snackBar.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/app/services/validation.service.ts
Normal file
183
src/app/services/validation.service.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ValidationService {
|
||||||
|
|
||||||
|
validateRequired(value: any): string | null {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim().length === 0) {
|
||||||
|
return 'This field cannot be empty';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateNumber(value: any, min?: number, max?: number): string | null {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return 'Must be a valid number';
|
||||||
|
}
|
||||||
|
if (min !== undefined && num < min) {
|
||||||
|
return `Must be at least ${min}`;
|
||||||
|
}
|
||||||
|
if (max !== undefined && num > max) {
|
||||||
|
return `Must be at most ${max}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePrice(value: any): string | null {
|
||||||
|
const numberError = this.validateNumber(value, 0);
|
||||||
|
if (numberError) return numberError;
|
||||||
|
|
||||||
|
const num = Number(value);
|
||||||
|
if (num < 0) {
|
||||||
|
return 'Price cannot be negative';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateQuantity(value: any): string | null {
|
||||||
|
const numberError = this.validateNumber(value, 0);
|
||||||
|
if (numberError) return numberError;
|
||||||
|
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isInteger(num)) {
|
||||||
|
return 'Quantity must be a whole number';
|
||||||
|
}
|
||||||
|
if (num < 0) {
|
||||||
|
return 'Quantity cannot be negative';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUrl(value: string): string | null {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
return null; // Optional field
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(value);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return 'Must be a valid URL (e.g., https://example.com)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateImageUrl(value: string): string | null {
|
||||||
|
const urlError = this.validateUrl(value);
|
||||||
|
if (urlError) return urlError;
|
||||||
|
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
||||||
|
const url = value.toLowerCase();
|
||||||
|
const hasValidExtension = imageExtensions.some(ext => url.includes(ext));
|
||||||
|
|
||||||
|
if (!hasValidExtension) {
|
||||||
|
return 'URL should point to an image file';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateId(value: string): string | null {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
return 'ID is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID should be URL-safe (no spaces, special chars)
|
||||||
|
const validIdPattern = /^[a-z0-9_-]+$/i;
|
||||||
|
if (!validIdPattern.test(value)) {
|
||||||
|
return 'ID can only contain letters, numbers, hyphens, and underscores';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length < 2) {
|
||||||
|
return 'ID must be at least 2 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 50) {
|
||||||
|
return 'ID must be less than 50 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePriority(value: any): string | null {
|
||||||
|
return this.validateNumber(value, 0, 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCurrency(value: string): string | null {
|
||||||
|
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||||
|
if (!validCurrencies.includes(value)) {
|
||||||
|
return `Currency must be one of: ${validCurrencies.join(', ')}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateArrayNotEmpty(arr: any[], fieldName: string): string | null {
|
||||||
|
if (!arr || arr.length === 0) {
|
||||||
|
return `At least one ${fieldName} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite validation for item
|
||||||
|
validateItem(item: Partial<any>): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (item['name'] !== undefined) {
|
||||||
|
const nameError = this.validateRequired(item['name']);
|
||||||
|
if (nameError) errors['name'] = nameError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item['price'] !== undefined) {
|
||||||
|
const priceError = this.validatePrice(item['price']);
|
||||||
|
if (priceError) errors['price'] = priceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item['quantity'] !== undefined) {
|
||||||
|
const quantityError = this.validateQuantity(item['quantity']);
|
||||||
|
if (quantityError) errors['quantity'] = quantityError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item['currency'] !== undefined) {
|
||||||
|
const currencyError = this.validateCurrency(item['currency']);
|
||||||
|
if (currencyError) errors['currency'] = currencyError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item['priority'] !== undefined) {
|
||||||
|
const priorityError = this.validatePriority(item['priority']);
|
||||||
|
if (priorityError) errors['priority'] = priorityError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite validation for category/subcategory
|
||||||
|
validateCategoryOrSubcategory(data: Partial<any>): Record<string, string> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data['name'] !== undefined) {
|
||||||
|
const nameError = this.validateRequired(data['name']);
|
||||||
|
if (nameError) errors['name'] = nameError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['id'] !== undefined) {
|
||||||
|
const idError = this.validateId(data['id']);
|
||||||
|
if (idError) errors['id'] = idError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['priority'] !== undefined) {
|
||||||
|
const priorityError = this.validatePriority(data['priority']);
|
||||||
|
if (priorityError) errors['priority'] = priorityError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['img'] !== undefined && data['img']) {
|
||||||
|
const imgError = this.validateImageUrl(data['img']);
|
||||||
|
if (imgError) errors['img'] = imgError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
useMockData: false,
|
useMockData: false,
|
||||||
apiUrl: '/api'
|
apiUrl: '/api',
|
||||||
|
marketplaceUrl: 'https://dexarmarket.ru'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
useMockData: true, // Set to false when backend is ready
|
useMockData: true, // Set to false when backend is ready
|
||||||
apiUrl: '/api'
|
apiUrl: '/api',
|
||||||
|
marketplaceUrl: 'http://localhost:4200'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,3 +71,28 @@ mat-card {
|
|||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast notifications styling
|
||||||
|
.toast-success {
|
||||||
|
--mdc-snackbar-container-color: #4caf50 !important;
|
||||||
|
--mdc-snackbar-supporting-text-color: white !important;
|
||||||
|
--mat-snack-bar-button-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
--mdc-snackbar-container-color: #f44336 !important;
|
||||||
|
--mdc-snackbar-supporting-text-color: white !important;
|
||||||
|
--mat-snack-bar-button-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-warning {
|
||||||
|
--mdc-snackbar-container-color: #ff9800 !important;
|
||||||
|
--mdc-snackbar-supporting-text-color: white !important;
|
||||||
|
--mat-snack-bar-button-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info {
|
||||||
|
--mdc-snackbar-container-color: #2196f3 !important;
|
||||||
|
--mdc-snackbar-supporting-text-color: white !important;
|
||||||
|
--mat-snack-bar-button-color: white !important;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user