the first commit

This commit is contained in:
sdarbinyan
2026-01-19 23:17:07 +04:00
commit a1a2a69fd0
52 changed files with 13640 additions and 0 deletions

View 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>

View 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;
}
}

View 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 });
}
});
}
});
}
}