fixing and styleing

This commit is contained in:
sdarbinyan
2026-02-20 01:25:29 +04:00
parent cb1349a5fd
commit 070e254a5c
17 changed files with 176 additions and 61 deletions

View File

@@ -16,6 +16,7 @@ export interface CreateDialogData {
type: 'text' | 'number' | 'toggle'; type: 'text' | 'number' | 'toggle';
required?: boolean; required?: boolean;
value?: any; value?: any;
hint?: string;
}[]; }[];
} }
@@ -50,6 +51,9 @@ export interface CreateDialogData {
[type]="field.type" [type]="field.type"
[(ngModel)]="formData[field.name]" [(ngModel)]="formData[field.name]"
[required]="!!field.required"> [required]="!!field.required">
@if (field.hint) {
<mat-hint>{{ field.hint }}</mat-hint>
}
@if (field.required && !formData[field.name]) { @if (field.required && !formData[field.name]) {
<mat-error>{{ field.label }} is required</mat-error> <mat-error>{{ field.label }} is required</mat-error>
} }

View File

@@ -14,7 +14,10 @@ export interface Subcategory {
visible: boolean; visible: boolean;
priority: number; priority: number;
img?: string; img?: string;
/** Root-level category this subcategory belongs to */
categoryId: string; categoryId: string;
/** Direct parent ID — could be a category ID or a parent subcategory ID */
parentId?: string;
itemCount?: number; itemCount?: number;
subcategories?: Subcategory[]; subcategories?: Subcategory[];
hasItems?: boolean; hasItems?: boolean;

View File

@@ -143,7 +143,7 @@ export class CategoryEditorComponent implements OnInit {
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe(result => {
if (result) { if (result) {
this.apiService.createSubcategory(this.categoryId(), result).subscribe({ this.apiService.createSubcategory(this.categoryId(), 'category', result).subscribe({
next: () => { next: () => {
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 }); this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
this.loadCategory(); this.loadCategory();

View File

@@ -61,7 +61,6 @@
gap: 1.5rem; gap: 1.5rem;
background-color: #fff; background-color: #fff;
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
gap: 1.5rem;
.full-width { .full-width {
width: 100%; width: 100%;

View File

@@ -125,17 +125,21 @@ export class ItemEditorComponent implements OnInit {
pathSegments.unshift(subcategory.id); // Add to beginning pathSegments.unshift(subcategory.id); // Add to beginning
// Check if this subcategory has a parent subcategory or belongs to a category // parentId = direct parent (another subcategory or the root category)
if (subcategory.categoryId && subcategory.categoryId.startsWith('cat')) { // categoryId = always the root category
// This is directly under a category, add category and stop const isDirectCategoryChild =
!subcategory.parentId || subcategory.parentId === subcategory.categoryId;
if (isDirectCategoryChild) {
// This subcategory sits directly under a root category
const category = await this.apiService.getCategory(subcategory.categoryId).toPromise(); const category = await this.apiService.getCategory(subcategory.categoryId).toPromise();
if (category) { if (category) {
pathSegments.unshift(category.id); pathSegments.unshift(category.id);
} }
break; break;
} else { } else {
// This is under another subcategory, continue traversing // Still inside a parent subcategory — keep traversing up
currentSubcategoryId = subcategory.categoryId; currentSubcategoryId = subcategory.parentId!;
} }
} catch (err) { } catch (err) {
console.error('Error building path:', err); console.error('Error building path:', err);

View File

@@ -3,7 +3,7 @@
<button mat-icon-button (click)="goBack()"> <button mat-icon-button (click)="goBack()">
<mat-icon>arrow_back</mat-icon> <mat-icon>arrow_back</mat-icon>
</button> </button>
<span class="toolbar-title">Items List</span> <span class="toolbar-title">{{ subcategoryName() || 'Items' }}</span>
<span class="toolbar-spacer"></span> <span class="toolbar-spacer"></span>
<button mat-mini-fab color="accent" (click)="addItem()"> <button mat-mini-fab color="accent" (click)="addItem()">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
@@ -133,4 +133,6 @@
<p>No items found</p> <p>No items found</p>
</div> </div>
} }
<div #scrollSentinel class="scroll-sentinel"></div>
</div> </div>

View File

@@ -13,6 +13,10 @@
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 500; font-weight: 500;
} }
.toolbar-spacer {
flex: 1;
}
} }
.filters-bar { .filters-bar {
@@ -267,3 +271,8 @@
margin: 0; margin: 0;
} }
} }
.scroll-sentinel {
height: 4px;
width: 100%;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, signal, HostListener } from '@angular/core'; import { Component, OnInit, AfterViewInit, OnDestroy, signal, ViewChild, ElementRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -39,14 +39,18 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
templateUrl: './items-list.component.html', templateUrl: './items-list.component.html',
styleUrls: ['./items-list.component.scss'] styleUrls: ['./items-list.component.scss']
}) })
export class ItemsListComponent implements OnInit { export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
items = signal<Item[]>([]); items = signal<Item[]>([]);
loading = signal(false); loading = signal(false);
hasMore = signal(true); hasMore = signal(false);
page = signal(1); page = signal(1);
searchQuery = signal(''); searchQuery = '';
visibilityFilter = signal<boolean | undefined>(undefined); visibilityFilter: boolean | undefined = undefined;
selectedItems = signal<Set<string>>(new Set()); selectedItems = signal<Set<string>>(new Set());
subcategoryName = signal<string>('');
@ViewChild('scrollSentinel') scrollSentinel!: ElementRef;
private intersectionObserver?: IntersectionObserver;
subcategoryId = signal<string>(''); subcategoryId = signal<string>('');
projectId = signal<string>(''); projectId = signal<string>('');
@@ -68,14 +72,17 @@ export class ItemsListComponent implements OnInit {
this.route.params.subscribe(params => { this.route.params.subscribe(params => {
this.subcategoryId.set(params['subcategoryId']); this.subcategoryId.set(params['subcategoryId']);
this.page.set(1);
this.items.set([]);
this.selectedItems.set(new Set());
this.subcategoryName.set('');
this.loadSubcategoryName();
this.loadItems(); this.loadItems();
}); });
} }
loadItems(append = false) { loadItems(append = false) {
if (this.loading() || (!append && this.items().length > 0)) { if (this.loading()) return;
return;
}
this.loading.set(true); this.loading.set(true);
const currentPage = append ? this.page() + 1 : 1; const currentPage = append ? this.page() + 1 : 1;
@@ -84,9 +91,9 @@ export class ItemsListComponent implements OnInit {
this.subcategoryId(), this.subcategoryId(),
currentPage, currentPage,
20, 20,
this.searchQuery() || undefined, this.searchQuery || undefined,
{ {
visible: this.visibilityFilter(), visible: this.visibilityFilter,
tags: [] tags: []
} }
).subscribe({ ).subscribe({
@@ -108,16 +115,31 @@ export class ItemsListComponent implements OnInit {
}); });
} }
@HostListener('window:scroll', []) ngAfterViewInit() {
onScroll() { this.intersectionObserver = new IntersectionObserver(
const scrollPosition = window.pageYOffset + window.innerHeight; entries => {
const documentHeight = document.documentElement.scrollHeight; if (entries[0].isIntersecting && this.hasMore() && !this.loading()) {
this.loadItems(true);
if (scrollPosition >= documentHeight - 200 && this.hasMore() && !this.loading()) { }
this.loadItems(true); },
{ rootMargin: '200px', threshold: 0 }
);
if (this.scrollSentinel?.nativeElement) {
this.intersectionObserver.observe(this.scrollSentinel.nativeElement);
} }
} }
ngOnDestroy() {
this.intersectionObserver?.disconnect();
}
loadSubcategoryName() {
this.apiService.getSubcategory(this.subcategoryId()).subscribe({
next: (sub) => this.subcategoryName.set(sub.name),
error: () => {}
});
}
onSearch() { onSearch() {
this.page.set(1); this.page.set(1);
this.items.set([]); this.items.set([]);
@@ -205,6 +227,8 @@ export class ItemsListComponent implements OnInit {
this.apiService.createItem(subcategoryId, result).subscribe({ this.apiService.createItem(subcategoryId, result).subscribe({
next: () => { next: () => {
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 }); this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
this.page.set(1);
this.items.set([]);
this.loadItems(); this.loadItems();
}, },
error: (err) => { error: (err) => {
@@ -234,6 +258,8 @@ export class ItemsListComponent implements OnInit {
this.apiService.deleteItem(item.id).subscribe({ this.apiService.deleteItem(item.id).subscribe({
next: () => { next: () => {
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 }); this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
this.page.set(1);
this.items.set([]);
this.loadItems(); this.loadItems();
}, },
error: (err) => { error: (err) => {

View File

@@ -3,7 +3,7 @@
<button mat-icon-button (click)="goBack()"> <button mat-icon-button (click)="goBack()">
<mat-icon>arrow_back</mat-icon> <mat-icon>arrow_back</mat-icon>
</button> </button>
<span>Project: {{ projectId() }}</span> <span>{{ project()?.displayName || projectId() }}</span>
</mat-toolbar> </mat-toolbar>
<mat-sidenav-container class="sidenav-container"> <mat-sidenav-container class="sidenav-container">
@@ -87,7 +87,7 @@
</div> </div>
<ng-template #subcategoryTree let-nodes="nodes" let-level="level"> <ng-template #subcategoryTree let-nodes="nodes" let-level="level">
<div class="subcategories" [style.padding-left.rem]="level * 1.5"> <div class="subcategories">
@for (subNode of nodes; track subNode.id) { @for (subNode of nodes; track subNode.id) {
<div> <div>
<div class="node-content subcategory-node" [class.selected]="selectedNodeId() === subNode.id"> <div class="node-content subcategory-node" [class.selected]="selectedNodeId() === subNode.id">

View File

@@ -15,7 +15,7 @@ mat-toolbar {
} }
.categories-sidebar { .categories-sidebar {
width: 380px; width: 420px;
border-right: 1px solid #e0e0e0; border-right: 1px solid #e0e0e0;
background-color: #fff; background-color: #fff;
@@ -31,6 +31,8 @@ mat-toolbar {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 500; font-weight: 500;
} }
// icon centering handled globally via styles.scss
} }
.loading-container { .loading-container {
@@ -68,6 +70,10 @@ mat-toolbar {
background-color: #bbdefb; background-color: #bbdefb;
border-left: 4px solid #1976d2; border-left: 4px solid #1976d2;
padding-left: calc(0.5rem - 4px); padding-left: calc(0.5rem - 4px);
.node-actions {
opacity: 1;
}
&:hover { &:hover {
background-color: #90caf9; background-color: #90caf9;
@@ -85,13 +91,12 @@ mat-toolbar {
} }
&.subcategory-node { &.subcategory-node {
padding-left: 3rem;
font-size: 0.95rem; font-size: 0.95rem;
background-color: #fff; background-color: #fff;
&.selected { &.selected {
background-color: #bbdefb; background-color: #bbdefb;
padding-left: calc(3rem - 4px); border-left: 4px solid #1976d2;
} }
} }
@@ -110,8 +115,8 @@ mat-toolbar {
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.15s;
mat-slide-toggle { mat-slide-toggle {
transform: scale(0.75); transform: scale(0.75);
@@ -119,15 +124,16 @@ mat-toolbar {
} }
button { button {
// width: 32px; --mdc-icon-button-state-layer-size: 30px;
// height: 32px; --mdc-icon-button-icon-size: 18px;
// line-height: 32px; width: 30px;
height: 30px;
padding: 0;
mat-icon { mat-icon {
font-size: 18px; font-size: 18px;
width: 18px; width: 18px;
height: 18px; height: 18px;
line-height: 18px;
} }
&:hover { &:hover {
@@ -143,6 +149,10 @@ mat-toolbar {
.subcategories { .subcategories {
background-color: #fafafa; background-color: #fafafa;
padding-left: 1rem;
border-left: 2px solid #e3e8ef;
margin-left: 1.25rem;
overflow: hidden;
} }
} }

View File

@@ -253,8 +253,8 @@ export class ProjectViewComponent implements OnInit {
return; return;
} }
const parentId = parentNode.type === 'category' ? parentNode.id : parentNode.id; const parentType = parentNode.type === 'category' ? 'category' : 'subcategory';
this.apiService.createSubcategory(parentId, result).subscribe({ this.apiService.createSubcategory(parentNode.id, parentType, result).subscribe({
next: () => { next: () => {
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 }); this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
this.loadCategories(); this.loadCategories();

View File

@@ -15,11 +15,7 @@
@if (saving()) { @if (saving()) {
<span class="save-indicator">Saving...</span> <span class="save-indicator">Saving...</span>
} }
<button mat-raised-button color="accent" (click)="viewItems()"> <button mat-icon-button color="warn" (click)="deleteSubcategory()" matTooltip="Delete Subcategory">
<mat-icon>list</mat-icon>
View Items
</button>
<button mat-icon-button color="warn" (click)="deleteSubcategory()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
</button> </button>
</div> </div>
@@ -109,10 +105,17 @@
</div> </div>
<div class="items-section"> <div class="items-section">
<button mat-raised-button color="primary" (click)="viewItems()"> @if (subcategory()!.subcategories?.length) {
<mat-icon>list</mat-icon> <p class="no-items-note">
View Items ({{ subcategory()!.itemCount || 0 }}) <mat-icon>account_tree</mat-icon>
</button> This subcategory has child subcategories — items can only be added to leaf nodes.
</p>
} @else {
<button mat-raised-button color="primary" (click)="viewItems()">
<mat-icon>{{ subcategory()!.hasItems ? 'list' : 'add' }}</mat-icon>
{{ subcategory()!.hasItems ? 'View Items (' + (subcategory()!.itemCount || 0) + ')' : 'Add Items' }}
</button>
}
</div> </div>
</div> </div>
} }

View File

@@ -115,4 +115,19 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.no-items-note {
display: flex;
align-items: center;
gap: 0.5rem;
color: #888;
font-size: 0.875rem;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #bbb;
}
}
} }

View File

@@ -142,7 +142,12 @@ export class SubcategoryEditorComponent implements OnInit {
this.apiService.deleteSubcategory(sub.id).subscribe({ this.apiService.deleteSubcategory(sub.id).subscribe({
next: () => { next: () => {
this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 }); this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 });
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]); // Navigate to the direct parent (subcategory) if parentId exists, otherwise the root category
if (sub.parentId && sub.parentId !== sub.categoryId) {
this.router.navigate(['/project', this.projectId(), 'subcategory', sub.parentId]);
} else {
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
}
}, },
error: (err: any) => { error: (err: any) => {
console.error('Error deleting subcategory:', err); console.error('Error deleting subcategory:', err);

View File

@@ -103,9 +103,12 @@ export class ApiService {
); );
} }
createSubcategory(categoryId: string, data: Partial<Subcategory>): Observable<Subcategory> { createSubcategory(parentId: string, parentType: 'category' | 'subcategory', data: Partial<Subcategory>): Observable<Subcategory> {
if (environment.useMockData) return this.mockService.createSubcategory(categoryId, data); if (environment.useMockData) return this.mockService.createSubcategory(parentId, data);
return this.http.post<Subcategory>(`${this.API_BASE}/categories/${categoryId}/subcategories`, data).pipe( const endpoint = parentType === 'category'
? `${this.API_BASE}/categories/${parentId}/subcategories`
: `${this.API_BASE}/subcategories/${parentId}/subcategories`;
return this.http.post<Subcategory>(endpoint, data).pipe(
catchError(this.handleError) catchError(this.handleError)
); );
} }

View File

@@ -273,7 +273,8 @@ export class MockDataService {
visible: data.visible ?? true, visible: data.visible ?? true,
priority: data.priority || 99, priority: data.priority || 99,
img: data.img, img: data.img,
categoryId: parentId, categoryId: parentId, // will be root category ID after backend resolves; mock keeps direct parent for simplicity
parentId: parentId,
itemCount: 0 itemCount: 0
}; };

View File

@@ -22,15 +22,41 @@ button[mat-raised-button], button[mat-flat-button] {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
// Icon buttons — always center the icon in its circle
button[mat-icon-button] { button[mat-icon-button] {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
.mat-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1 !important;
}
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.04); background-color: rgba(0, 0, 0, 0.04);
} }
} }
button[mat-mini-fab] { button[mat-mini-fab] {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25) !important; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25) !important;
.mat-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1 !important;
}
&:hover { &:hover {
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.35) !important; box-shadow: 0 5px 12px rgba(0, 0, 0, 0.35) !important;
} }
@@ -55,8 +81,8 @@ mat-card {
// Scrollbar styling // Scrollbar styling
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 6px;
height: 10px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -64,11 +90,11 @@ mat-card {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: #aaa;
border-radius: 5px; border-radius: 6px;
&:hover { &:hover {
background: #555; background: #666;
} }
} }
@@ -85,6 +111,11 @@ mat-card {
--mat-snack-bar-button-color: white !important; --mat-snack-bar-button-color: white !important;
} }
// Common editor layout helpers
.editor-page {
padding: 1.5rem 2rem;
}
.toast-warning { .toast-warning {
--mdc-snackbar-container-color: #ff9800 !important; --mdc-snackbar-container-color: #ff9800 !important;
--mdc-snackbar-supporting-text-color: white !important; --mdc-snackbar-supporting-text-color: white !important;