the first commit
This commit is contained in:
121
src/app/pages/category-editor/category-editor.component.html
Normal file
121
src/app/pages/category-editor/category-editor.component.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<div class="editor-container">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
} @else if (category()) {
|
||||
<div class="editor-header">
|
||||
<button mat-icon-button (click)="goBack()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<h2>Edit Category</h2>
|
||||
@if (saving()) {
|
||||
<span class="save-indicator">Saving...</span>
|
||||
}
|
||||
<button mat-icon-button color="warn" (click)="deleteCategory()" matTooltip="Delete Category">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="category()!.name"
|
||||
(blur)="onFieldChange('name', category()!.name)"
|
||||
required>
|
||||
@if (!category()!.name || category()!.name.trim().length === 0) {
|
||||
<mat-error>Category name is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>ID</mat-label>
|
||||
<input matInput [value]="category()!.id" disabled>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-slide-toggle
|
||||
[(ngModel)]="category()!.visible"
|
||||
(change)="onFieldChange('visible', category()!.visible)"
|
||||
color="primary">
|
||||
Visible
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Priority</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="category()!.priority"
|
||||
(blur)="onFieldChange('priority', category()!.priority)"
|
||||
required
|
||||
min="0">
|
||||
<mat-hint>Lower numbers appear first</mat-hint>
|
||||
@if (category()!.priority < 0) {
|
||||
<mat-error>Priority cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="image-section">
|
||||
<h3>Image</h3>
|
||||
|
||||
@if (category()!.img) {
|
||||
<div class="image-preview">
|
||||
<img [src]="category()!.img" [alt]="category()!.name">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="image-inputs">
|
||||
<div class="upload-option">
|
||||
<label for="file-upload" class="upload-label">
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
Upload Image
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
(change)="onImageSelect($event, 'file')"
|
||||
hidden>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Or enter image URL</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="category()!.img || ''"
|
||||
(blur)="onImageSelect($event, 'url')">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subcategories-section">
|
||||
<div class="section-header">
|
||||
<h3>Subcategories ({{ category()!.subcategories?.length || 0 }})</h3>
|
||||
<button mat-mini-fab color="primary" (click)="addSubcategory()" matTooltip="Add Subcategory">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (category()!.subcategories?.length) {
|
||||
<mat-list>
|
||||
@for (sub of category()!.subcategories; track sub.id) {
|
||||
<mat-list-item (click)="openSubcategory(sub.id)">
|
||||
<span matListItemTitle>{{ sub.name }}</span>
|
||||
<span matListItemLine>Priority: {{ sub.priority }}</span>
|
||||
<button mat-icon-button matListItemMeta>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
} @else {
|
||||
<p class="empty-state">No subcategories yet</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
141
src/app/pages/category-editor/category-editor.component.scss
Normal file
141
src/app/pages/category-editor/category-editor.component.scss
Normal file
@@ -0,0 +1,141 @@
|
||||
.editor-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
color: #1976d2;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image-section {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upload-option {
|
||||
.upload-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subcategories-section {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
187
src/app/pages/category-editor/category-editor.component.ts
Normal file
187
src/app/pages/category-editor/category-editor.component.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Component, OnInit, signal, effect } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { Category } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatListModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './category-editor.component.html',
|
||||
styleUrls: ['./category-editor.component.scss']
|
||||
})
|
||||
export class CategoryEditorComponent implements OnInit {
|
||||
category = signal<Category | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
categoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Get projectId from parent route immediately
|
||||
const parentParams = this.route.parent?.snapshot.params;
|
||||
if (parentParams) {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.categoryId.set(params['categoryId']);
|
||||
this.loadCategory();
|
||||
});
|
||||
}
|
||||
|
||||
loadCategory() {
|
||||
this.loading.set(true);
|
||||
this.apiService.getCategory(this.categoryId()).subscribe({
|
||||
next: (category) => {
|
||||
this.category.set(category);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load category', err);
|
||||
this.snackBar.open('Failed to load category', 'Close', { duration: 3000 });
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Category, value: any) {
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('category', this.categoryId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
if (type === 'file' && target.files?.length) {
|
||||
const file = target.files[0];
|
||||
this.saving.set(true);
|
||||
|
||||
this.apiService.uploadImage(file).subscribe({
|
||||
next: (response) => {
|
||||
const cat = this.category();
|
||||
if (cat) {
|
||||
cat.img = response.url;
|
||||
this.onFieldChange('img', response.url);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||
this.saving.set(false);
|
||||
}
|
||||
});
|
||||
} else if (type === 'url') {
|
||||
const url = (target.value || '').trim();
|
||||
if (url) {
|
||||
this.onFieldChange('img', url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openSubcategory(subId: string) {
|
||||
this.router.navigate(['/project', this.projectId(), 'subcategory', subId]);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
if (this.projectId()) {
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
addSubcategory() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Subcategory',
|
||||
type: 'subcategory',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.createSubcategory(this.categoryId(), result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||
this.loadCategory();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to create subcategory', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteCategory() {
|
||||
const cat = this.category();
|
||||
if (!cat) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
message: `Are you sure you want to delete "${cat.name}"? This will also delete all subcategories and items.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteCategory(this.categoryId()).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
310
src/app/pages/item-editor/item-editor.component.html
Normal file
310
src/app/pages/item-editor/item-editor.component.html
Normal file
@@ -0,0 +1,310 @@
|
||||
<div class="editor-container">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
} @else if (item()) {
|
||||
<div class="editor-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<button mat-icon-button (click)="goBack()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<h2>Edit Item</h2>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
@if (saving()) {
|
||||
<span class="save-indicator">Saving...</span>
|
||||
}
|
||||
<button mat-raised-button color="accent" (click)="previewInMarketplace()">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
Preview
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="deleteItem()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-tab-group class="editor-tabs">
|
||||
<!-- Basic Info Tab -->
|
||||
<mat-tab label="Basic Info">
|
||||
<div class="tab-content">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="item()!.name"
|
||||
(blur)="onFieldChange('name', item()!.name)"
|
||||
required>
|
||||
@if (!item()!.name || item()!.name.trim().length === 0) {
|
||||
<mat-error>Name is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>ID</mat-label>
|
||||
<input matInput [value]="item()!.id" disabled>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-slide-toggle
|
||||
[(ngModel)]="item()!.visible"
|
||||
(change)="onFieldChange('visible', item()!.visible)"
|
||||
color="primary">
|
||||
Visible
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Priority</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="item()!.priority"
|
||||
(blur)="onFieldChange('priority', item()!.priority)">
|
||||
<mat-hint>Lower numbers appear first</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Quantity</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="item()!.quantity"
|
||||
(blur)="onFieldChange('quantity', item()!.quantity)"
|
||||
required
|
||||
min="0">
|
||||
@if (item()!.quantity < 0) {
|
||||
<mat-error>Quantity cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
[(ngModel)]="item()!.price"
|
||||
(blur)="onFieldChange('price', item()!.price)"
|
||||
required
|
||||
min="0">
|
||||
@if (!item()!.price || item()!.price < 0) {
|
||||
<mat-error>Price must be greater than 0</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Currency</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="item()!.currency"
|
||||
(selectionChange)="onFieldChange('currency', item()!.currency)">
|
||||
@for (curr of currencies; track curr) {
|
||||
<mat-option [value]="curr">{{ curr }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Simple Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
[(ngModel)]="item()!.simpleDescription"
|
||||
(blur)="onFieldChange('simpleDescription', item()!.simpleDescription)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Images Tab -->
|
||||
<mat-tab label="Images">
|
||||
<div class="tab-content">
|
||||
<div class="images-section">
|
||||
<div class="upload-area">
|
||||
<label for="images-upload" class="upload-label">
|
||||
<mat-icon>add_photo_alternate</mat-icon>
|
||||
Upload Images
|
||||
</label>
|
||||
<input
|
||||
id="images-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
(change)="onImagesSelect($event)"
|
||||
hidden>
|
||||
|
||||
@if (uploadingImages()) {
|
||||
<mat-spinner diameter="30"></mat-spinner>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="images-grid" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="onImageDrop($event)">
|
||||
@for (img of item()!.imgs; track $index) {
|
||||
<div class="image-card" cdkDrag>
|
||||
<div class="drag-handle" cdkDragHandle>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
</div>
|
||||
<img [src]="img" [alt]="item()!.name">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-btn"
|
||||
(click)="removeImage($index)">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<div class="image-order">{{ $index + 1 }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!item()!.imgs.length) {
|
||||
<div class="empty-images">
|
||||
<mat-icon>image</mat-icon>
|
||||
<p>No images yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tags Tab -->
|
||||
<mat-tab label="Tags">
|
||||
<div class="tab-content">
|
||||
<div class="tags-section">
|
||||
<div class="add-tag-form">
|
||||
<mat-form-field appearance="outline" class="tag-input">
|
||||
<mat-label>Add Tag</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newTag"
|
||||
(keyup.enter)="addTag()"
|
||||
placeholder="e.g. new, sale, featured">
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="primary" (click)="addTag()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tags-list">
|
||||
@for (tag of item()!.tags; track $index) {
|
||||
<mat-chip>
|
||||
{{ tag }}
|
||||
<button matChipRemove (click)="removeTag($index)">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</mat-chip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!item()!.tags.length) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>label</mat-icon>
|
||||
<p>No tags yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Detailed Description Tab -->
|
||||
<mat-tab label="Description">
|
||||
<div class="tab-content">
|
||||
<div class="description-section">
|
||||
<h3>Key-Value Description Fields</h3>
|
||||
<p class="hint">Add structured information like color, size, material, etc.</p>
|
||||
|
||||
<div class="add-desc-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Key</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newDescKey"
|
||||
placeholder="e.g. Color">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Value</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newDescValue"
|
||||
placeholder="e.g. Black">
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="addDescriptionField()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="desc-fields-list">
|
||||
@for (field of item()!.description; track $index) {
|
||||
<div class="desc-field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Key</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="field.key"
|
||||
(blur)="updateDescriptionField($index, 'key', $any($event.target).value)">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Value</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="field.value"
|
||||
(blur)="updateDescriptionField($index, 'value', $any($event.target).value)">
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="removeDescriptionField($index)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!item()!.description.length) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>description</mat-icon>
|
||||
<p>No description fields yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Comments Tab -->
|
||||
<mat-tab label="Comments">
|
||||
<div class="tab-content">
|
||||
<div class="comments-section">
|
||||
@if (item()!.comments?.length) {
|
||||
<div class="comments-list">
|
||||
@for (comment of item()!.comments; track comment.id) {
|
||||
<div class="comment-card">
|
||||
<div class="comment-header">
|
||||
<strong>{{ comment.author || 'Anonymous' }}</strong>
|
||||
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
<p>{{ comment.text }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<mat-icon>comment</mat-icon>
|
||||
<p>No comments yet</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
317
src/app/pages/item-editor/item-editor.component.scss
Normal file
317
src/app/pages/item-editor/item-editor.component.scss
Normal file
@@ -0,0 +1,317 @@
|
||||
.editor-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
color: #1976d2;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 2rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0 0 8px 8px;
|
||||
gap: 1.5rem;
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Images Tab
|
||||
.images-section {
|
||||
.upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.upload-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
position: relative;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.cdk-drag-preview {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
z-index: 10;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(244, 67, 54, 0.9);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: rgb(244, 67, 54);
|
||||
}
|
||||
}
|
||||
|
||||
.image-order {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags Tab
|
||||
.tags-section {
|
||||
.add-tag-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description Tab
|
||||
.description-section {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.add-desc-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.desc-fields-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.desc-field-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comments Tab
|
||||
.comments-section {
|
||||
.comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
color: #999;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
256
src/app/pages/item-editor/item-editor.component.ts
Normal file
256
src/app/pages/item-editor/item-editor.component.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ApiService } from '../../services';
|
||||
import { Item, ItemDescriptionField } from '../../models';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-item-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatChipsModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule,
|
||||
MatDialogModule,
|
||||
DragDropModule
|
||||
],
|
||||
templateUrl: './item-editor.component.html',
|
||||
styleUrls: ['./item-editor.component.scss']
|
||||
})
|
||||
export class ItemEditorComponent implements OnInit {
|
||||
item = signal<Item | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
itemId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
newTag = '';
|
||||
newDescKey = '';
|
||||
newDescValue = '';
|
||||
uploadingImages = signal<boolean>(false);
|
||||
|
||||
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Get projectId from parent route immediately
|
||||
const parentParams = this.route.parent?.snapshot.params;
|
||||
if (parentParams) {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.itemId.set(params['itemId']);
|
||||
this.loadItem();
|
||||
});
|
||||
}
|
||||
|
||||
loadItem() {
|
||||
this.loading.set(true);
|
||||
this.apiService.getItem(this.itemId()).subscribe({
|
||||
next: (item) => {
|
||||
this.item.set(item);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load item', err);
|
||||
this.snackBar.open('Failed to load item', 'Close', { duration: 3000 });
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Item, value: any) {
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Image handling
|
||||
async onImagesSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files?.length) return;
|
||||
|
||||
this.uploadingImages.set(true);
|
||||
const files = Array.from(target.files);
|
||||
const uploadPromises = files.map(file =>
|
||||
this.apiService.uploadImage(file).toPromise()
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(uploadPromises);
|
||||
const newUrls = results.map(r => r!.url);
|
||||
const currentItem = this.item();
|
||||
|
||||
if (currentItem) {
|
||||
const updatedImgs = [...(currentItem.imgs || []), ...newUrls];
|
||||
currentItem.imgs = updatedImgs;
|
||||
this.onFieldChange('imgs', updatedImgs);
|
||||
}
|
||||
} catch (err) {
|
||||
this.snackBar.open('Failed to upload images', 'Close', { duration: 3000 });
|
||||
} finally {
|
||||
this.uploadingImages.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
removeImage(index: number) {
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedImgs = [...currentItem.imgs];
|
||||
updatedImgs.splice(index, 1);
|
||||
currentItem.imgs = updatedImgs;
|
||||
this.onFieldChange('imgs', updatedImgs);
|
||||
}
|
||||
}
|
||||
|
||||
// Tags handling
|
||||
addTag() {
|
||||
if (!this.newTag.trim()) return;
|
||||
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedTags = [...(currentItem.tags || []), this.newTag.trim()];
|
||||
currentItem.tags = updatedTags;
|
||||
this.onFieldChange('tags', updatedTags);
|
||||
this.newTag = '';
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedTags = [...currentItem.tags];
|
||||
updatedTags.splice(index, 1);
|
||||
currentItem.tags = updatedTags;
|
||||
this.onFieldChange('tags', updatedTags);
|
||||
}
|
||||
}
|
||||
|
||||
// Description fields handling
|
||||
addDescriptionField() {
|
||||
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;
|
||||
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedDesc = [
|
||||
...(currentItem.description || []),
|
||||
{ key: this.newDescKey.trim(), value: this.newDescValue.trim() }
|
||||
];
|
||||
currentItem.description = updatedDesc;
|
||||
this.onFieldChange('description', updatedDesc);
|
||||
this.newDescKey = '';
|
||||
this.newDescValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateDescriptionField(index: number, field: 'key' | 'value', value: string) {
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedDesc = [...currentItem.description];
|
||||
updatedDesc[index] = { ...updatedDesc[index], [field]: value };
|
||||
currentItem.description = updatedDesc;
|
||||
this.onFieldChange('description', updatedDesc);
|
||||
}
|
||||
}
|
||||
|
||||
removeDescriptionField(index: number) {
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
const updatedDesc = [...currentItem.description];
|
||||
updatedDesc.splice(index, 1);
|
||||
currentItem.description = updatedDesc;
|
||||
this.onFieldChange('description', updatedDesc);
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const item = this.item();
|
||||
if (item && item.subcategoryId) {
|
||||
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
previewInMarketplace() {
|
||||
// Open marketplace in new tab with this item
|
||||
const marketplaceUrl = `http://localhost:4200/item/${this.itemId()}`;
|
||||
window.open(marketplaceUrl, '_blank');
|
||||
}
|
||||
|
||||
onImageDrop(event: CdkDragDrop<string[]>) {
|
||||
const item = this.item();
|
||||
if (!item) return;
|
||||
|
||||
const imgs = [...item.imgs];
|
||||
moveItemInArray(imgs, event.previousIndex, event.currentIndex);
|
||||
|
||||
this.item.set({ ...item, imgs });
|
||||
this.onFieldChange('imgs', imgs);
|
||||
}
|
||||
|
||||
deleteItem() {
|
||||
const item = this.item();
|
||||
if (!item) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Item',
|
||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Error deleting item:', err);
|
||||
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
136
src/app/pages/items-list/items-list.component.html
Normal file
136
src/app/pages/items-list/items-list.component.html
Normal file
@@ -0,0 +1,136 @@
|
||||
<div class="items-list-container">
|
||||
<mat-toolbar color="primary" class="list-toolbar">
|
||||
<button mat-icon-button (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<span class="toolbar-title">Items List</span>
|
||||
<span class="toolbar-spacer"></span>
|
||||
<button mat-mini-fab color="accent" (click)="addItem()">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<div class="filters-bar">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search items</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="searchQuery"
|
||||
(keyup.enter)="onSearch()"
|
||||
placeholder="Search by name...">
|
||||
<button mat-icon-button matSuffix (click)="onSearch()">
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Visibility</mat-label>
|
||||
<mat-select [(ngModel)]="visibilityFilter" (selectionChange)="onFilterChange()">
|
||||
<mat-option [value]="undefined">All</mat-option>
|
||||
<mat-option [value]="true">Visible</mat-option>
|
||||
<mat-option [value]="false">Hidden</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedItems().size > 0) {
|
||||
<div class="bulk-actions">
|
||||
<span class="selection-count">{{ selectedItems().size }} selected</span>
|
||||
<button mat-raised-button (click)="bulkToggleVisibility(true)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Show
|
||||
</button>
|
||||
<button mat-raised-button (click)="bulkToggleVisibility(false)">
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="items-header">
|
||||
<mat-checkbox
|
||||
[checked]="selectedItems().size === items().length && items().length > 0"
|
||||
[indeterminate]="selectedItems().size > 0 && selectedItems().size < items().length"
|
||||
(change)="toggleSelectAll()">
|
||||
</mat-checkbox>
|
||||
<span class="items-count">{{ items().length }} items</span>
|
||||
</div>
|
||||
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track item.id) {
|
||||
<div class="item-card" (click)="openItem(item.id)">
|
||||
<div class="item-card-actions">
|
||||
<mat-checkbox
|
||||
[checked]="selectedItems().has(item.id)"
|
||||
(change)="toggleSelection(item.id)"
|
||||
(click)="$event.stopPropagation()">
|
||||
</mat-checkbox>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="deleteItem(item, $event)"
|
||||
class="delete-btn">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="item-image">
|
||||
@if (item.imgs.length) {
|
||||
<img [src]="item.imgs[0]" [alt]="item.name">
|
||||
} @else {
|
||||
<div class="no-image">
|
||||
<mat-icon>image</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-info">
|
||||
<h3>{{ item.name }}</h3>
|
||||
|
||||
<div class="item-details">
|
||||
<span class="price">{{ item.price }} {{ item.currency }}</span>
|
||||
<span class="quantity">Qty: {{ item.quantity }}</span>
|
||||
</div>
|
||||
|
||||
<div class="item-meta">
|
||||
<span class="priority">Priority: {{ item.priority }}</span>
|
||||
<mat-icon [class.visible]="item.visible" [class.hidden]="!item.visible">
|
||||
{{ item.visible ? 'visibility' : 'visibility_off' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
@if (item.tags.length) {
|
||||
<div class="item-tags">
|
||||
@for (tag of item.tags.slice(0, 3); track tag) {
|
||||
<mat-chip>{{ tag }}</mat-chip>
|
||||
}
|
||||
@if (item.tags.length > 3) {
|
||||
<span class="more-tags">+{{ item.tags.length - 3 }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-more">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading more items...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!hasMore() && items().length > 0) {
|
||||
<div class="end-message">
|
||||
No more items to load
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && items().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>inventory_2</mat-icon>
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
269
src/app/pages/items-list/items-list.component.scss
Normal file
269
src/app/pages/items-list/items-list.component.scss
Normal file
@@ -0,0 +1,269 @@
|
||||
.items-list-container {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: #fafafa;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
.selection-count {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
.items-count {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-4px);
|
||||
border-color: #1976d2;
|
||||
|
||||
.delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.item-card-actions {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
mat-checkbox {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.item-checkbox {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #ccc;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.price {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
&.visible {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.75rem;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.more-tags {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #999;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.125rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
252
src/app/pages/items-list/items-list.component.ts
Normal file
252
src/app/pages/items-list/items-list.component.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Component, OnInit, signal, HostListener } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-items-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatChipsModule,
|
||||
MatCheckboxModule,
|
||||
MatSelectModule,
|
||||
MatToolbarModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './items-list.component.html',
|
||||
styleUrls: ['./items-list.component.scss']
|
||||
})
|
||||
export class ItemsListComponent implements OnInit {
|
||||
items = signal<Item[]>([]);
|
||||
loading = signal(false);
|
||||
hasMore = signal(true);
|
||||
page = signal(1);
|
||||
searchQuery = signal('');
|
||||
visibilityFilter = signal<boolean | undefined>(undefined);
|
||||
selectedItems = signal<Set<string>>(new Set());
|
||||
|
||||
subcategoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Get projectId from parent route immediately
|
||||
const parentParams = this.route.parent?.snapshot.params;
|
||||
if (parentParams) {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.subcategoryId.set(params['subcategoryId']);
|
||||
this.loadItems();
|
||||
});
|
||||
}
|
||||
|
||||
loadItems(append = false) {
|
||||
if (this.loading() || (!append && this.items().length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
const currentPage = append ? this.page() + 1 : 1;
|
||||
|
||||
this.apiService.getItems(
|
||||
this.subcategoryId(),
|
||||
currentPage,
|
||||
20,
|
||||
this.searchQuery() || undefined,
|
||||
{
|
||||
visible: this.visibilityFilter(),
|
||||
tags: []
|
||||
}
|
||||
).subscribe({
|
||||
next: (response) => {
|
||||
if (append) {
|
||||
this.items.set([...this.items(), ...response.items]);
|
||||
} else {
|
||||
this.items.set(response.items);
|
||||
}
|
||||
this.page.set(currentPage);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load items', err);
|
||||
this.snackBar.open('Failed to load items', 'Close', { duration: 3000 });
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:scroll', [])
|
||||
onScroll() {
|
||||
const scrollPosition = window.pageYOffset + window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
if (scrollPosition >= documentHeight - 200 && this.hasMore() && !this.loading()) {
|
||||
this.loadItems(true);
|
||||
}
|
||||
}
|
||||
|
||||
onSearch() {
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
}
|
||||
|
||||
onFilterChange() {
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
}
|
||||
|
||||
toggleSelection(itemId: string) {
|
||||
const selected = new Set(this.selectedItems());
|
||||
if (selected.has(itemId)) {
|
||||
selected.delete(itemId);
|
||||
} else {
|
||||
selected.add(itemId);
|
||||
}
|
||||
this.selectedItems.set(selected);
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.selectedItems().size === this.items().length) {
|
||||
this.selectedItems.set(new Set());
|
||||
} else {
|
||||
this.selectedItems.set(new Set(this.items().map(item => item.id)));
|
||||
}
|
||||
}
|
||||
|
||||
bulkToggleVisibility(visible: boolean) {
|
||||
const itemIds = Array.from(this.selectedItems());
|
||||
if (!itemIds.length) {
|
||||
this.snackBar.open('No items selected', 'Close', { duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiService.bulkUpdateItems(itemIds, { visible }).subscribe({
|
||||
next: () => {
|
||||
this.items.update(items =>
|
||||
items.map(item =>
|
||||
itemIds.includes(item.id) ? { ...item, visible } : item
|
||||
)
|
||||
);
|
||||
this.snackBar.open(`Updated ${itemIds.length} items`, 'Close', { duration: 2000 });
|
||||
this.selectedItems.set(new Set());
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to update items', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openItem(itemId: string) {
|
||||
console.log('Opening item:', itemId, 'projectId:', this.projectId());
|
||||
this.router.navigate(['/project', this.projectId(), 'item', itemId]);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const subcategoryId = this.subcategoryId();
|
||||
if (subcategoryId) {
|
||||
// Navigate back to subcategory editor
|
||||
this.router.navigate(['/subcategory', subcategoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
title: 'Create New Item',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
||||
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
||||
{ name: 'price', label: 'Price', type: 'number', required: true },
|
||||
{ name: 'currency', label: 'Currency', type: 'text', required: true, value: 'USD' },
|
||||
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
const subcategoryId = this.subcategoryId();
|
||||
if (!subcategoryId) return;
|
||||
|
||||
this.apiService.createItem(subcategoryId, result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
|
||||
this.loadItems();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating item:', err);
|
||||
this.snackBar.open('Failed to create item', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteItem(item: Item, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Item',
|
||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||
this.loadItems();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error deleting item:', err);
|
||||
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
113
src/app/pages/project-view/project-view.component.html
Normal file
113
src/app/pages/project-view/project-view.component.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<div class="project-view-container">
|
||||
<mat-toolbar color="primary">
|
||||
<button mat-icon-button (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<span>Project: {{ projectId() }}</span>
|
||||
</mat-toolbar>
|
||||
|
||||
<mat-sidenav-container class="sidenav-container">
|
||||
<mat-sidenav mode="side" opened class="categories-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Categories</h2>
|
||||
<button mat-mini-fab color="primary" (click)="addCategory()" matTooltip="Add Category">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tree-container">
|
||||
@for (node of treeData(); track node.id) {
|
||||
<div class="tree-node">
|
||||
<div class="node-content category-node">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleNode(node)"
|
||||
[disabled]="!node.children?.length">
|
||||
<mat-icon>
|
||||
{{ node.children?.length ? (node.expanded ? 'expand_more' : 'chevron_right') : '' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<span class="node-name" (click)="editNode(node, $event)">
|
||||
{{ node.name }}
|
||||
</span>
|
||||
|
||||
<div class="node-actions">
|
||||
<mat-slide-toggle
|
||||
[checked]="node.visible"
|
||||
(change)="toggleVisibility(node, $event)"
|
||||
color="primary"
|
||||
matTooltip="Toggle Visibility">
|
||||
</mat-slide-toggle>
|
||||
|
||||
<button mat-icon-button (click)="editNode(node, $event)" color="primary" matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-icon-button (click)="deleteCategory(node, $event)" color="warn" matTooltip="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (node.expanded && node.children?.length) {
|
||||
<div class="subcategories">
|
||||
@for (subNode of node.children; track subNode.id) {
|
||||
<div class="node-content subcategory-node">
|
||||
<span class="node-name" (click)="editNode(subNode, $event)">
|
||||
{{ subNode.name }}
|
||||
</span>
|
||||
|
||||
<div class="node-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="viewItems(subNode, $event)"
|
||||
matTooltip="View Items">
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="subNode.visible"
|
||||
(change)="toggleVisibility(subNode, $event)"
|
||||
color="primary"
|
||||
matTooltip="Toggle Visibility">
|
||||
</mat-slide-toggle>
|
||||
|
||||
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav-content>
|
||||
<div class="content-area">
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@if (!hasActiveRoute()) {
|
||||
<div class="welcome-message">
|
||||
<mat-icon style="font-size: 64px; width: 64px; height: 64px; color: #1976d2;">dashboard</mat-icon>
|
||||
<h2>Welcome to {{ project()?.displayName || 'Project' }} Backoffice</h2>
|
||||
<p>Select a category or subcategory from the sidebar to start editing.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</div>
|
||||
156
src/app/pages/project-view/project-view.component.scss
Normal file
156
src/app/pages/project-view/project-view.component.scss
Normal file
@@ -0,0 +1,156 @@
|
||||
.project-view-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mat-toolbar {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidenav-container {
|
||||
flex: 1;
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.categories-sidebar {
|
||||
width: 380px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background-color: #fff;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 65px);
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
.node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
min-height: 48px;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: #e3f2fd;
|
||||
|
||||
.node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.category-node {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
&.subcategory-node {
|
||||
padding-left: 3rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
mat-slide-toggle {
|
||||
transform: scale(0.75);
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
button {
|
||||
// width: 32px;
|
||||
// height: 32px;
|
||||
// line-height: 32px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.subcategories {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 2rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.welcome-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/app/pages/project-view/project-view.component.ts
Normal file
238
src/app/pages/project-view/project-view.component.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatTreeModule } from '@angular/material/tree';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ApiService } from '../../services';
|
||||
import { Category, Subcategory } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
interface CategoryNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'category' | 'subcategory';
|
||||
visible: boolean;
|
||||
expanded?: boolean;
|
||||
children?: CategoryNode[];
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-view',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
MatSidenavModule,
|
||||
MatTreeModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatToolbarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
templateUrl: './project-view.component.html',
|
||||
styleUrls: ['./project-view.component.scss']
|
||||
})
|
||||
export class ProjectViewComponent implements OnInit {
|
||||
projectId = signal<string>('');
|
||||
project = signal<any>(null);
|
||||
categories = signal<Category[]>([]);
|
||||
loading = signal(true);
|
||||
treeData = signal<CategoryNode[]>([]);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private dialog: MatDialog,
|
||||
private snackBar: MatSnackBar
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.params.subscribe(params => {
|
||||
this.projectId.set(params['projectId']);
|
||||
this.loadProject();
|
||||
this.loadCategories();
|
||||
});
|
||||
}
|
||||
|
||||
hasActiveRoute(): boolean {
|
||||
return this.route.children.length > 0;
|
||||
}
|
||||
|
||||
loadProject() {
|
||||
// Load project details
|
||||
this.apiService.getProjects().subscribe({
|
||||
next: (projects) => {
|
||||
const project = projects.find(p => p.id === this.projectId());
|
||||
this.project.set(project);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load project', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadCategories() {
|
||||
this.loading.set(true);
|
||||
this.apiService.getCategories(this.projectId()).subscribe({
|
||||
next: (categories) => {
|
||||
this.categories.set(categories);
|
||||
this.buildTree();
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load categories', err);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildTree() {
|
||||
const tree: CategoryNode[] = this.categories().map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
type: 'category' as const,
|
||||
visible: cat.visible,
|
||||
expanded: false,
|
||||
children: (cat.subcategories || []).map(sub => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: 'subcategory' as const,
|
||||
visible: sub.visible,
|
||||
categoryId: cat.id
|
||||
}))
|
||||
}));
|
||||
this.treeData.set(tree);
|
||||
}
|
||||
|
||||
toggleNode(node: CategoryNode) {
|
||||
node.expanded = !node.expanded;
|
||||
}
|
||||
|
||||
editNode(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (node.type === 'category') {
|
||||
this.router.navigate(['category', node.id], { relativeTo: this.route });
|
||||
} else {
|
||||
this.router.navigate(['subcategory', node.id], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
|
||||
toggleVisibility(node: CategoryNode, event: any) {
|
||||
event.stopPropagation();
|
||||
node.visible = !node.visible;
|
||||
|
||||
if (node.type === 'category') {
|
||||
this.apiService.updateCategory(node.id, { visible: node.visible }).subscribe();
|
||||
} else {
|
||||
this.apiService.updateSubcategory(node.id, { visible: node.visible }).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
viewItems(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (node.type === 'subcategory') {
|
||||
console.log('Navigating to items for subcategory:', node.id);
|
||||
this.router.navigate(['/project', this.projectId(), 'items', node.id]);
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
addCategory() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Category',
|
||||
type: 'category',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to create category', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteCategory(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all subcategories and items.`,
|
||||
confirmText: 'Delete',
|
||||
warning: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteCategory(node.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSubcategory(node: CategoryNode, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Subcategory',
|
||||
message: `Are you sure you want to delete "${node.name}"? This will also delete all items.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteSubcategory(node.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory deleted', 'Close', { duration: 2000 });
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="dashboard-container">
|
||||
<h1>Marketplace Backoffice</h1>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-message">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && !error()) {
|
||||
<div class="projects-grid">
|
||||
@for (project of projects(); track project.id) {
|
||||
<mat-card class="project-card" (click)="openProject(project.id)">
|
||||
<mat-card-header>
|
||||
@if (project.logoUrl) {
|
||||
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">
|
||||
}
|
||||
<mat-card-title>{{ project.displayName }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="project-status" [class.active]="project.active">
|
||||
{{ project.active ? 'Active' : 'Inactive' }}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
.dashboard-container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 1rem;
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.project-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #ccc;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ApiService } from '../../services';
|
||||
import { Project } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatProgressSpinnerModule],
|
||||
templateUrl: './projects-dashboard.component.html',
|
||||
styleUrls: ['./projects-dashboard.component.scss']
|
||||
})
|
||||
export class ProjectsDashboardComponent implements OnInit {
|
||||
projects = signal<Project[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProjects();
|
||||
}
|
||||
|
||||
loadProjects() {
|
||||
this.loading.set(true);
|
||||
this.apiService.getProjects().subscribe({
|
||||
next: (projects) => {
|
||||
this.projects.set(projects);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load projects');
|
||||
this.loading.set(false);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openProject(projectId: string) {
|
||||
this.router.navigate(['/project', projectId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<div class="editor-container">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
} @else if (subcategory()) {
|
||||
<div class="editor-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<button mat-icon-button (click)="goBack()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<h2>Edit Subcategory</h2>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
@if (saving()) {
|
||||
<span class="save-indicator">Saving...</span>
|
||||
}
|
||||
<button mat-raised-button color="accent" (click)="viewItems()">
|
||||
<mat-icon>list</mat-icon>
|
||||
View Items
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="deleteSubcategory()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="subcategory()!.name"
|
||||
(blur)="onFieldChange('name', subcategory()!.name)"
|
||||
required>
|
||||
@if (!subcategory()!.name || subcategory()!.name.trim().length === 0) {
|
||||
<mat-error>Subcategory name is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>ID</mat-label>
|
||||
<input matInput [value]="subcategory()!.id" disabled>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-slide-toggle
|
||||
[(ngModel)]="subcategory()!.visible"
|
||||
(change)="onFieldChange('visible', subcategory()!.visible)"
|
||||
color="primary">
|
||||
Visible
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Priority</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="subcategory()!.priority"
|
||||
(blur)="onFieldChange('priority', subcategory()!.priority)"
|
||||
required
|
||||
min="0">
|
||||
<mat-hint>Lower numbers appear first</mat-hint>
|
||||
@if (subcategory()!.priority < 0) {
|
||||
<mat-error>Priority cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="image-section">
|
||||
<h3>Image</h3>
|
||||
|
||||
@if (subcategory()!.img) {
|
||||
<div class="image-preview">
|
||||
<img [src]="subcategory()!.img" [alt]="subcategory()!.name">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="image-inputs">
|
||||
<div class="upload-option">
|
||||
<label for="file-upload" class="upload-label">
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
Upload Image
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
(change)="onImageSelect($event, 'file')"
|
||||
hidden>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Or enter image URL</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="subcategory()!.img || ''"
|
||||
(blur)="onImageSelect($event, 'url')">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<button mat-raised-button color="primary" (click)="viewItems()">
|
||||
<mat-icon>list</mat-icon>
|
||||
View Items ({{ subcategory()!.itemCount || 0 }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
.editor-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
color: #1976d2;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image-section {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upload-option {
|
||||
.upload-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-section {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
159
src/app/pages/subcategory-editor/subcategory-editor.component.ts
Normal file
159
src/app/pages/subcategory-editor/subcategory-editor.component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { Subcategory } from '../../models';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subcategory-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './subcategory-editor.component.html',
|
||||
styleUrls: ['./subcategory-editor.component.scss']
|
||||
})
|
||||
export class SubcategoryEditorComponent implements OnInit {
|
||||
subcategory = signal<Subcategory | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
subcategoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Get projectId from parent route immediately
|
||||
const parentParams = this.route.parent?.snapshot.params;
|
||||
if (parentParams) {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.subcategoryId.set(params['subcategoryId']);
|
||||
this.loadSubcategory();
|
||||
});
|
||||
}
|
||||
|
||||
loadSubcategory() {
|
||||
this.loading.set(true);
|
||||
this.apiService.getSubcategory(this.subcategoryId()).subscribe({
|
||||
next: (subcategory) => {
|
||||
this.subcategory.set(subcategory);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load subcategory', err);
|
||||
this.snackBar.open('Failed to load subcategory', 'Close', { duration: 3000 });
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Subcategory, value: any) {
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
if (type === 'file' && target.files?.length) {
|
||||
const file = target.files[0];
|
||||
this.saving.set(true);
|
||||
|
||||
this.apiService.uploadImage(file).subscribe({
|
||||
next: (response) => {
|
||||
const sub = this.subcategory();
|
||||
if (sub) {
|
||||
sub.img = response.url;
|
||||
this.onFieldChange('img', response.url);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||
this.saving.set(false);
|
||||
}
|
||||
});
|
||||
} else if (type === 'url') {
|
||||
const url = (target.value || '').trim();
|
||||
if (url) {
|
||||
this.onFieldChange('img', url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
console.log('View items - projectId:', this.projectId(), 'subcategoryId:', this.subcategoryId());
|
||||
this.router.navigate(['/project', this.projectId(), 'items', this.subcategoryId()]);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const sub = this.subcategory();
|
||||
if (sub && this.projectId()) {
|
||||
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
||||
} else {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSubcategory() {
|
||||
const sub = this.subcategory();
|
||||
if (!sub) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Subcategory',
|
||||
message: `Are you sure you want to delete "${sub.name}"? This will also delete all items in this subcategory.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.apiService.deleteSubcategory(sub.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 });
|
||||
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Error deleting subcategory:', err);
|
||||
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user