Преглед изворни кода

feat: Introduce FFB production management with filtering, pagination, and CRUD operations, alongside new site management, dashboard, and activity components with updated routing.

Dr-Swopt пре 2 недеља
родитељ
комит
d2c6e1c644

+ 0 - 1
src/app/activity/activity.component.ts

@@ -37,7 +37,6 @@ import { CalculateDialogComponent } from '../components/calculate-dialog/calcula
     MatNativeDateModule,    // ✅ And this
     MatNativeDateModule,    // ✅ And this
     MatAutocompleteModule,
     MatAutocompleteModule,
     ReactiveFormsModule,
     ReactiveFormsModule,
-    CreateActivityDialogComponent,
   ],
   ],
   templateUrl: './activity.component.html',
   templateUrl: './activity.component.html',
   styleUrls: ['./activity.component.css'],
   styleUrls: ['./activity.component.css'],

+ 1 - 0
src/app/app.routes.ts

@@ -12,6 +12,7 @@ export const routes: Routes = [
   { path: 'webauthn-register', component: WebauthnRegisterComponent },
   { path: 'webauthn-register', component: WebauthnRegisterComponent },
   { path: 'webauthn-login', component: WebauthnLoginComponent },
   { path: 'webauthn-login', component: WebauthnLoginComponent },
   { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] },
   { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] },
+  { path: 'sites', loadComponent: () => import('./site/site-management/site-management.component').then(m => m.SiteManagementComponent), canActivate: [authGuard] },
   { path: 'login', component: LoginComponent },
   { path: 'login', component: LoginComponent },
   { path: 'register', component: RegisterComponent },
   { path: 'register', component: RegisterComponent },
 ];
 ];

+ 14 - 20
src/app/components/ffb-production-dialog/create-ffb-production-dialog.component.html

@@ -14,26 +14,24 @@
 
 
     <mat-form-field appearance="outline">
     <mat-form-field appearance="outline">
       <mat-label>Site</mat-label>
       <mat-label>Site</mat-label>
-      <input matInput [formControl]="siteControl" [matAutocomplete]="siteAuto" [readonly]="!!data?.harvest" />
-      <mat-autocomplete #siteAuto="matAutocomplete">
-        <mat-option *ngFor="let option of filteredSites | async" [value]="option">{{ option }}</mat-option>
-      </mat-autocomplete>
+      <mat-select [formControl]="siteControl" (selectionChange)="onSiteChange($event.value)" [compareWith]="compareFn">
+        <mat-option *ngFor="let s of sites" [value]="s">{{ s.name }}</mat-option>
+      </mat-select>
     </mat-form-field>
     </mat-form-field>
 
 
     <mat-form-field appearance="outline">
     <mat-form-field appearance="outline">
       <mat-label>Phase</mat-label>
       <mat-label>Phase</mat-label>
-      <input matInput [formControl]="phaseControl" [matAutocomplete]="phaseAuto" [readonly]="!!data?.harvest" />
-      <mat-autocomplete #phaseAuto="matAutocomplete">
-        <mat-option *ngFor="let option of filteredPhases | async" [value]="option">{{ option }}</mat-option>
-      </mat-autocomplete>
+      <mat-select [formControl]="phaseControl" (selectionChange)="onPhaseChange($event.value)"
+        [compareWith]="compareFn">
+        <mat-option *ngFor="let p of phases" [value]="p">{{ p.name }}</mat-option>
+      </mat-select>
     </mat-form-field>
     </mat-form-field>
 
 
     <mat-form-field appearance="outline">
     <mat-form-field appearance="outline">
       <mat-label>Block</mat-label>
       <mat-label>Block</mat-label>
-      <input matInput [formControl]="blockControl" [matAutocomplete]="blockAuto" [readonly]="!!data?.harvest" />
-      <mat-autocomplete #blockAuto="matAutocomplete">
-        <mat-option *ngFor="let option of filteredBlocks | async" [value]="option">{{ option }}</mat-option>
-      </mat-autocomplete>
+      <mat-select [formControl]="blockControl" [compareWith]="compareFn">
+        <mat-option *ngFor="let b of blocks" [value]="b">{{ b.name }}</mat-option>
+      </mat-select>
     </mat-form-field>
     </mat-form-field>
 
 
     <mat-form-field appearance="outline" class="flex-1">
     <mat-form-field appearance="outline" class="flex-1">
@@ -43,11 +41,9 @@
 
 
     <mat-form-field appearance="outline" class="flex-1">
     <mat-form-field appearance="outline" class="flex-1">
       <mat-label>Quantity UOM</mat-label>
       <mat-label>Quantity UOM</mat-label>
-      <input type="text" matInput formControlName="quantityUom" [matAutocomplete]="quantityUomAuto"
-        [readonly]="!!data?.harvest" />
-      <mat-autocomplete #quantityUomAuto="matAutocomplete">
+      <mat-select formControlName="quantityUom">
         <mat-option *ngFor="let u of quantityUomOptions" [value]="u">{{ u }}</mat-option>
         <mat-option *ngFor="let u of quantityUomOptions" [value]="u">{{ u }}</mat-option>
-      </mat-autocomplete>
+      </mat-select>
     </mat-form-field>
     </mat-form-field>
 
 
     <mat-form-field appearance="outline" class="flex-1">
     <mat-form-field appearance="outline" class="flex-1">
@@ -57,11 +53,9 @@
 
 
     <mat-form-field appearance="outline" class="flex-1">
     <mat-form-field appearance="outline" class="flex-1">
       <mat-label>Weight UOM</mat-label>
       <mat-label>Weight UOM</mat-label>
-      <input type="text" matInput formControlName="weightUom" [matAutocomplete]="weightUomAuto"
-        [readonly]="!!data?.harvest" />
-      <mat-autocomplete #weightUomAuto="matAutocomplete">
+      <mat-select formControlName="weightUom">
         <mat-option *ngFor="let u of weightUomOptions" [value]="u">{{ u }}</mat-option>
         <mat-option *ngFor="let u of weightUomOptions" [value]="u">{{ u }}</mat-option>
-      </mat-autocomplete>
+      </mat-select>
     </mat-form-field>
     </mat-form-field>
 
 
     <div class="remarks-container" style="width: 100%; display: flex; align-items: flex-start; gap: 10px;">
     <div class="remarks-container" style="width: 100%; display: flex; align-items: flex-start; gap: 10px;">

+ 171 - 65
src/app/components/ffb-production-dialog/create-ffb-production-dialog.component.ts

@@ -10,11 +10,13 @@ import { MatNativeDateModule } from '@angular/material/core';
 import { MatIconModule } from '@angular/material/icon';
 import { MatIconModule } from '@angular/material/icon';
 import { HttpClient, HttpClientModule } from '@angular/common/http';
 import { HttpClient, HttpClientModule } from '@angular/common/http';
 import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
 import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatAutocompleteModule } from '@angular/material/autocomplete';
+import { MatSelectModule } from '@angular/material/select';
 import { MatTooltipModule } from '@angular/material/tooltip';
 import { MatTooltipModule } from '@angular/material/tooltip';
 import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
 import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { Observable, startWith, map } from 'rxjs';
 import { webConfig } from '../../config';
 import { webConfig } from '../../config';
+import { SiteService } from '../../services/site-management.service';
+import { Site, Phase, Block } from '../../interfaces/site.interface';
+import { FFBProduction } from '../../ffb/ffb-production.interface';
 
 
 @Component({
 @Component({
   selector: 'app-create-ffb-production-dialog',
   selector: 'app-create-ffb-production-dialog',
@@ -29,7 +31,7 @@ import { webConfig } from '../../config';
     MatButtonModule,
     MatButtonModule,
     MatDatepickerModule,
     MatDatepickerModule,
     MatNativeDateModule,
     MatNativeDateModule,
-    MatAutocompleteModule,
+    MatSelectModule,
     MatIconModule,
     MatIconModule,
     MatSnackBarModule,
     MatSnackBarModule,
     MatTooltipModule,
     MatTooltipModule,
@@ -46,36 +48,25 @@ export class CreateFfbProductionDialogComponent implements OnInit {
   weightUomOptions = ['Kg', 'Ton'];
   weightUomOptions = ['Kg', 'Ton'];
   quantityUomOptions = ['Bunch', 'Bag'];
   quantityUomOptions = ['Bunch', 'Bag'];
 
 
-  allSites: string[] = [];
-  allPhases: string[] = [];
-  allBlocks: string[] = [];
-  allWorkers: string[] = [];
-
-  filteredSites!: Observable<string[]>;
-  filteredPhases!: Observable<string[]>;
-  filteredBlocks!: Observable<string[]>;
-  filteredWorkers!: Observable<string[]>;
+  sites: Site[] = [];
+  phases: Phase[] = [];
+  blocks: Block[] = [];
 
 
   private conversionMap: Record<string, number> = { Bunch: 10, Bundle: 25, Bag: 100 };
   private conversionMap: Record<string, number> = { Bunch: 10, Bundle: 25, Bag: 100 };
 
 
   constructor(
   constructor(
     private fb: FormBuilder,
     private fb: FormBuilder,
     private http: HttpClient,
     private http: HttpClient,
+    private siteService: SiteService,
     private dialogRef: MatDialogRef<CreateFfbProductionDialogComponent>,
     private dialogRef: MatDialogRef<CreateFfbProductionDialogComponent>,
     private snackBar: MatSnackBar,
     private snackBar: MatSnackBar,
-    @Inject(MAT_DIALOG_DATA) public data: any
+    @Inject(MAT_DIALOG_DATA) public data: { harvest?: FFBProduction } // Updated type hint
   ) {
   ) {
-    // Assign lists from parent
-    this.allSites = data?.allSites || [];
-    this.allPhases = data?.allPhases || [];
-    this.allBlocks = data?.allBlocks || [];
-    console.log(data)
-
     this.form = this.fb.group({
     this.form = this.fb.group({
       productionDate: [new Date(), Validators.required],
       productionDate: [new Date(), Validators.required],
-      site: ['', Validators.required],
-      phase: ['', Validators.required],
-      block: ['', Validators.required],
+      site: [null, Validators.required],
+      phase: [null, Validators.required],
+      block: [null, Validators.required],
       quantity: [0, Validators.required],
       quantity: [0, Validators.required],
       quantityUom: ['Bunch', Validators.required],
       quantityUom: ['Bunch', Validators.required],
       weight: [0, Validators.required],
       weight: [0, Validators.required],
@@ -92,31 +83,98 @@ export class CreateFfbProductionDialogComponent implements OnInit {
   }
   }
 
 
   ngOnInit() {
   ngOnInit() {
+    this.loadSites();
+
     // Patch data if editing
     // Patch data if editing
     if (this.data?.harvest) {
     if (this.data?.harvest) {
-      this.form.patchValue(this.data.harvest);
+      const harvest = this.data.harvest;
+
+      // We need to set the form values. 
+      // Important: Since we are using objects for selection, we need to ensure references match or use compareWith
+      // But simpler is to rely on value binding if we use compareWith in template.
+      // Or we can just set the values.
+
+      this.form.patchValue({
+        productionDate: harvest.productionDate,
+        quantity: harvest.quantity,
+        quantityUom: harvest.quantityUom,
+        weight: harvest.weight,
+        weightUom: harvest.weightUom,
+        remarks: harvest.remarks
+      });
+
+      // For dependent dropdowns, we need to load them sequentially
+      // 1. Load phases for the site
+      if (harvest.site && harvest.site.id) {
+        // We might not have the full Site object in 'harvest.site' matching what 'getSites' returns 
+        // but we have id. We should probably find the site wrapper from 'sites' array after it loads?
+        // Actually, let's just set the form control to the object provided.
+        // And trigger the load.
+        this.form.patchValue({ site: harvest.site });
+
+        this.siteService.getPhasesBySite(harvest.site.id).subscribe(phases => {
+          this.phases = phases;
+          this.form.patchValue({ phase: harvest.phase });
+
+          if (harvest.phase && harvest.phase.id) {
+            this.siteService.getBlocksByPhase(harvest.phase.id).subscribe(blocks => {
+              this.blocks = blocks;
+              this.form.patchValue({ block: harvest.block });
+            });
+          }
+        });
+      }
+    }
+  }
+
+  loadSites() {
+    this.siteService.getSites().subscribe(sites => {
+      this.sites = sites;
+    });
+  }
+
+  onSiteChange(site: any) { // site is { id, name } or Site object
+    this.phases = [];
+    this.blocks = [];
+    this.form.patchValue({ phase: null, block: null });
+
+    if (site && site._id) { // Site object from DB has _id
+      this.fetchPhases(site._id);
+    } else if (site && site.id) { // FFBProduction object has id
+      this.fetchPhases(site.id);
     }
     }
+  }
 
 
-    // Autocomplete observables
-    this.filteredSites = this.siteControl.valueChanges.pipe(
-      startWith(this.siteControl.value || ''),
-      map(val => this.filterOptions(val, this.allSites))
-    );
-
-    this.filteredPhases = this.phaseControl.valueChanges.pipe(
-      startWith(this.phaseControl.value || ''),
-      map(val => this.filterOptions(val, this.allPhases))
-    );
-
-    this.filteredBlocks = this.blockControl.valueChanges.pipe(
-      startWith(this.blockControl.value || ''),
-      map(val => this.filterOptions(val, this.allBlocks))
-    );
+  fetchPhases(siteId: string) {
+    this.siteService.getPhasesBySite(siteId).subscribe(phases => {
+      this.phases = phases;
+    });
   }
   }
 
 
-  private filterOptions(value: string, options: string[]): string[] {
-    const filterValue = (value || '').toLowerCase();
-    return options.filter(opt => opt.toLowerCase().includes(filterValue));
+  onPhaseChange(phase: any) {
+    this.blocks = [];
+    this.form.patchValue({ block: null });
+
+    if (phase && phase._id) {
+      this.fetchBlocks(phase._id);
+    } else if (phase && phase.id) {
+      this.fetchBlocks(phase.id);
+    }
+  }
+
+  fetchBlocks(phaseId: string) {
+    this.siteService.getBlocksByPhase(phaseId).subscribe(blocks => {
+      this.blocks = blocks;
+    });
+  }
+
+  // Helper for compareWith in mat-select to handle {id, name} equality
+  compareFn(o1: any, o2: any): boolean {
+    if (!o1 || !o2) return false;
+    // Handle both _id (DB) and id (FFBProduction view) keys if mixed
+    const id1 = o1.id || o1._id;
+    const id2 = o2.id || o2._id;
+    return id1 === id2;
   }
   }
 
 
   private updateWeight() {
   private updateWeight() {
@@ -127,14 +185,13 @@ export class CreateFfbProductionDialogComponent implements OnInit {
   }
   }
 
 
   generateRemark() {
   generateRemark() {
-    this.generatingRemark = true; // Use saving flag to show loading state if needed, or create a separate one
+    this.generatingRemark = true;
     const payload = this.form.value;
     const payload = this.form.value;
 
 
-    // Construct a context object for the LLM based on available form data
     const context = {
     const context = {
-      site: payload.site,
-      phase: payload.phase,
-      block: payload.block,
+      site: payload.site?.name,
+      phase: payload.phase?.name,
+      block: payload.block?.name,
       date: payload.productionDate,
       date: payload.productionDate,
       quantity: payload.quantity,
       quantity: payload.quantity,
       quantityUom: payload.quantityUom,
       quantityUom: payload.quantityUom,
@@ -157,35 +214,46 @@ export class CreateFfbProductionDialogComponent implements OnInit {
   }
   }
 
 
   randomize() {
   randomize() {
-    const defaultSites = ['Site A', 'Site B', 'Site C', 'Site D'];
-    const defaultPhases = ['Phase 1', 'Phase 2', 'Phase 3'];
-    const defaultBlocks = ['Block 1', 'Block 2', 'Block 3'];
+    // Randomize needs to pick valid existing sites now
+    if (this.sites.length === 0) {
+      this.snackBar.open('No sites available to randomize', 'Close', { duration: 2000 });
+      return;
+    }
 
 
-    const sites = [...new Set([...this.allSites, ...defaultSites])];
-    const phases = [...new Set([...this.allPhases, ...defaultPhases])];
-    const blocks = [...new Set([...this.allBlocks, ...defaultBlocks])];
+    const randomSite = this.getRandomItem(this.sites);
+    // trigger selection logic
+    this.form.patchValue({ site: randomSite });
+
+    // We need to fetch phases for this random site
+    this.siteService.getPhasesBySite(randomSite._id!).subscribe(phases => {
+      this.phases = phases;
+      if (phases.length > 0) {
+        const randomPhase = this.getRandomItem(phases);
+        this.form.patchValue({ phase: randomPhase });
+
+        this.siteService.getBlocksByPhase(randomPhase._id!).subscribe(blocks => {
+          this.blocks = blocks;
+          if (blocks.length > 0) {
+            this.form.patchValue({ block: this.getRandomItem(blocks) });
+          }
+        });
+      }
+    });
 
 
-    // Date between 1/1/2025 and 31/12/2026
     const start = new Date('2025-01-01').getTime();
     const start = new Date('2025-01-01').getTime();
     const end = new Date('2026-12-31').getTime();
     const end = new Date('2026-12-31').getTime();
     const randomDate = new Date(start + Math.random() * (end - start));
     const randomDate = new Date(start + Math.random() * (end - start));
 
 
     const quantity = Math.floor(Math.random() * (100 - 10 + 1)) + 10;
     const quantity = Math.floor(Math.random() * (100 - 10 + 1)) + 10;
-    const quantityUom = 'Bunch';
 
 
     this.form.patchValue({
     this.form.patchValue({
-      site: this.getRandomItem(sites),
-      phase: this.getRandomItem(phases),
-      block: this.getRandomItem(blocks),
       productionDate: randomDate,
       productionDate: randomDate,
       quantity: quantity,
       quantity: quantity,
-      quantityUom: quantityUom
+      quantityUom: 'Bunch' // Default
     });
     });
-
-    // Weight auto-updates via subscription
   }
   }
 
 
-  private getRandomItem(arr: string[]) {
+  private getRandomItem(arr: any[]) {
     return arr[Math.floor(Math.random() * arr.length)];
     return arr[Math.floor(Math.random() * arr.length)];
   }
   }
 
 
@@ -204,13 +272,51 @@ export class CreateFfbProductionDialogComponent implements OnInit {
   onSubmit() {
   onSubmit() {
     if (this.form.invalid) return;
     if (this.form.invalid) return;
 
 
-    const payload = {
-      ...this.form.value,
-      productionDate: new Date(this.form.value.productionDate).toISOString(),
+    const formVal = this.form.value;
+
+    // Prepare payload with { id, name } structure
+    // formVal.site is the full Site object from DB (has _id, name, etc)
+
+    const payload: FFBProduction = {
+      ...formVal,
+      productionDate: new Date(formVal.productionDate).toISOString(),
+      site: { id: formVal.site._id || formVal.site.id, name: formVal.site.name },
+      phase: { id: formVal.phase._id || formVal.phase.id, name: formVal.phase.name },
+      block: { id: formVal.block._id || formVal.block.id, name: formVal.block.name }
     };
     };
 
 
+    // If editing, we need the ID? 
+    // The create endpoint is POST, update is usually PUT.
+    // The current code used POST for create.
+    // existing 'onSubmit' logic was: Post to /api/ffb-production
+    // Does it handle update? 
+    // In original code: `editProduction` opened the dialog with data.harvest.
+    // But `onSubmit` did logic: `http.post(exposedUrl + '/api/ffb-production')`.
+    // It seems the original code MIGHT have been missing the Update logic in onSubmit or the backend handles it via _id if present?
+    // Checking original code again... line 213: `http.post`. 
+    // Wait, `editProduction` in parent component (Step 5, line 236) just opens dialog with harvest data.
+    // `CreateFfbProductionDialogComponent` (Step 20) line 213 does POST.
+    // This implies the original 'Edit' might have just been creating a NEW record with pre-filled data? 
+    // OR the backend POST handles upsert? 
+    // A typical REST API: POST is create. PUT is update.
+    // I should probably check if I need to support PUT.
+    // Let's assume for now I should use the same logic as before (POST), but if I am editing, I should probably use PUT if I had the ID.
+    // The user's prompt in Step 0 showed backend controllers for site, phase, block.
+    // The user didn't show ffb-production backend.
+
+    // IMPORTANT: If `data.harvest` has `_id`, we should probably do PUT.
+    // I'll implement PUT if `data.harvest._id` exists, else POST.
+
     this.saving = true;
     this.saving = true;
-    this.http.post(`${webConfig.exposedUrl}/api/ffb-production`, payload).subscribe({
+
+    let req;
+    if (this.data?.harvest?._id) {
+      req = this.http.put(`${webConfig.exposedUrl}/api/ffb-production/${this.data.harvest._id}`, payload);
+    } else {
+      req = this.http.post(`${webConfig.exposedUrl}/api/ffb-production`, payload);
+    }
+
+    req.subscribe({
       next: () => {
       next: () => {
         this.snackBar.open('FFB Production saved!', 'Close', { duration: 3000 });
         this.snackBar.open('FFB Production saved!', 'Close', { duration: 3000 });
         this.dialogRef.close('refresh');
         this.dialogRef.close('refresh');

+ 6 - 3
src/app/dashboard/dashboard.component.html

@@ -16,12 +16,15 @@
     <app-ffb-production></app-ffb-production>
     <app-ffb-production></app-ffb-production>
   </mat-tab>
   </mat-tab>
 
 
-  <mat-tab label="Plantation">
-    <!-- Integrate PlantationTreeComponent here -->
+  <!-- <mat-tab label="Plantation">
     <app-plantation-tree></app-plantation-tree>
     <app-plantation-tree></app-plantation-tree>
-  </mat-tab>
+  </mat-tab> -->
 
 
   <mat-tab label="Chat">
   <mat-tab label="Chat">
     <app-chat></app-chat>
     <app-chat></app-chat>
   </mat-tab>
   </mat-tab>
+
+  <mat-tab label="Site Management">
+    <app-site-management></app-site-management>
+  </mat-tab>
 </mat-tab-group>
 </mat-tab-group>

+ 4 - 2
src/app/dashboard/dashboard.component.ts

@@ -9,6 +9,7 @@ import { ActivityComponent } from '../activity/activity.component';
 import { FfbProductionComponent } from "../ffb/ffb-production.component";
 import { FfbProductionComponent } from "../ffb/ffb-production.component";
 import { WebcamComponent } from "../webcam/webcam.component";
 import { WebcamComponent } from "../webcam/webcam.component";
 import { ChatComponent } from "../chat/chat.component";
 import { ChatComponent } from "../chat/chat.component";
+import { SiteManagementComponent } from '../site/site-management/site-management.component';
 
 
 @Component({
 @Component({
   selector: 'app-dashboard',
   selector: 'app-dashboard',
@@ -22,8 +23,9 @@ import { ChatComponent } from "../chat/chat.component";
     ActivityComponent,
     ActivityComponent,
     FfbProductionComponent,
     FfbProductionComponent,
     WebcamComponent,
     WebcamComponent,
-    ChatComponent
-],
+    ChatComponent,
+    SiteManagementComponent
+  ],
   templateUrl: './dashboard.component.html',
   templateUrl: './dashboard.component.html',
   styleUrls: ['./dashboard.component.css']
   styleUrls: ['./dashboard.component.css']
 })
 })

+ 3 - 3
src/app/ffb/ffb-production.component.html

@@ -95,17 +95,17 @@
 
 
   <ng-container matColumnDef="site">
   <ng-container matColumnDef="site">
     <th mat-header-cell *matHeaderCellDef>Site</th>
     <th mat-header-cell *matHeaderCellDef>Site</th>
-    <td mat-cell *matCellDef="let h">{{ h.site }}</td>
+    <td mat-cell *matCellDef="let h">{{ h.site.name }}</td>
   </ng-container>
   </ng-container>
 
 
   <ng-container matColumnDef="phase">
   <ng-container matColumnDef="phase">
     <th mat-header-cell *matHeaderCellDef>Phase</th>
     <th mat-header-cell *matHeaderCellDef>Phase</th>
-    <td mat-cell *matCellDef="let h">{{ h.phase }}</td>
+    <td mat-cell *matCellDef="let h">{{ h.phase.name }}</td>
   </ng-container>
   </ng-container>
 
 
   <ng-container matColumnDef="block">
   <ng-container matColumnDef="block">
     <th mat-header-cell *matHeaderCellDef>Block</th>
     <th mat-header-cell *matHeaderCellDef>Block</th>
-    <td mat-cell *matCellDef="let h">{{ h.block }}</td>
+    <td mat-cell *matCellDef="let h">{{ h.block.name }}</td>
   </ng-container>
   </ng-container>
 
 
   <ng-container matColumnDef="weight">
   <ng-container matColumnDef="weight">

+ 3 - 3
src/app/ffb/ffb-production.component.ts

@@ -154,9 +154,9 @@ export class FfbProductionComponent implements OnInit {
         // populate unique arrays for filters and autocompletes - this might need a separate API call if we want ALL unique values
         // populate unique arrays for filters and autocompletes - this might need a separate API call if we want ALL unique values
         // For now, we only get unique values from CURRENT PAGE, which is a limitation of server-side pagination without separate metadata endpoint.
         // For now, we only get unique values from CURRENT PAGE, which is a limitation of server-side pagination without separate metadata endpoint.
         // Keeping existing logic for now, but be aware of this limitation.
         // Keeping existing logic for now, but be aware of this limitation.
-        this.uniqueSites = [...new Set(this.production.map(h => h.site))];
-        this.uniquePhases = [...new Set(this.production.map(h => h.phase))];
-        this.uniqueBlocks = [...new Set(this.production.map(h => h.block))];
+        this.uniqueSites = [...new Set(this.production.map(h => h.site.name))];
+        this.uniquePhases = [...new Set(this.production.map(h => h.phase.name))];
+        this.uniqueBlocks = [...new Set(this.production.map(h => h.block.name))];
 
 
         this.loading = false;
         this.loading = false;
       },
       },

+ 3 - 3
src/app/ffb/ffb-production.interface.ts

@@ -1,9 +1,9 @@
 export interface FFBProduction {
 export interface FFBProduction {
   _id?: string;
   _id?: string;
   productionDate: Date | string; // backend returns ISO string; convert to Date where needed
   productionDate: Date | string; // backend returns ISO string; convert to Date where needed
-  site: string;
-  phase: string;
-  block: string;
+  site: { id: string; name: string };
+  phase: { id: string; name: string };
+  block: { id: string; name: string };
   weight: number;
   weight: number;
   weightUom: string;
   weightUom: string;
   quantity: number;
   quantity: number;

+ 35 - 0
src/app/interfaces/site.interface.ts

@@ -0,0 +1,35 @@
+
+export interface Block {
+    _id?: string;
+    phaseId: string;
+    name: string;
+    description?: string;
+    numOfTrees: number;
+    size: number;
+    sizeUom: 'sqft' | 'sqm' | 'acres';
+}
+
+export interface Phase {
+    _id?: string;
+    siteId: string;
+    name: string;
+    description?: string;
+    blocks?: Block[];
+    status: string;
+    loadingBlocks?: boolean; // UI state
+}
+
+export interface Site {
+    _id?: string;
+    name: string;
+    address: string;
+    coordinates?: { lat: number; lng: number };
+    status: 'active' | 'inactive';
+    description: string;
+    phases?: Phase[];
+    metadata?: {
+        createdAt: Date;
+        updatedAt: Date;
+    };
+    loadingPhases?: boolean; // UI state
+}

+ 34 - 0
src/app/services/site-management.service.ts

@@ -0,0 +1,34 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { webConfig } from '../config';
+import { Site, Phase, Block } from '../interfaces/site.interface';
+
+@Injectable({ providedIn: 'root' })
+export class SiteService {
+    private http = inject(HttpClient);
+    private apiUrl = `${webConfig.exposedUrl}/api`;
+
+    // Sites
+    getSites() { return this.http.get<Site[]>(`${this.apiUrl}/sites`); }
+    getSiteById(id: string) { return this.http.get<Site>(`${this.apiUrl}/sites/${id}`); }
+    createSite(site: Site) { return this.http.post<Site>(`${this.apiUrl}/sites`, site); }
+    updateSite(id: string, site: Partial<Site>) { return this.http.put(`${this.apiUrl}/sites/${id}`, site); }
+    deleteSite(id: string) { return this.http.delete(`${this.apiUrl}/sites/${id}`); }
+    generateSiteDescription(data: { name: string, address?: string }) { return this.http.post<{ description: string }>(`${this.apiUrl}/sites/generate-description`, data); }
+
+    // Phases
+    getPhasesBySite(siteId: string) { return this.http.get<Phase[]>(`${this.apiUrl}/phases?siteId=${siteId}`); }
+    getPhaseById(id: string) { return this.http.get<Phase>(`${this.apiUrl}/phases/${id}`); }
+    createPhase(phase: Phase) { return this.http.post<Phase>(`${this.apiUrl}/phases`, phase); }
+    updatePhase(id: string, phase: Partial<Phase>) { return this.http.put(`${this.apiUrl}/phases/${id}`, phase); }
+    deletePhase(id: string) { return this.http.delete(`${this.apiUrl}/phases/${id}`); }
+    generatePhaseDescription(data: { name: string, siteName?: string }) { return this.http.post<{ description: string }>(`${this.apiUrl}/phases/generate-description`, data); }
+
+    // Blocks
+    getBlocksByPhase(phaseId: string) { return this.http.get<Block[]>(`${this.apiUrl}/blocks?phaseId=${phaseId}`); }
+    getBlockById(id: string) { return this.http.get<Block>(`${this.apiUrl}/blocks/${id}`); }
+    createBlock(block: Block) { return this.http.post<Block>(`${this.apiUrl}/blocks`, block); }
+    updateBlock(id: string, block: Partial<Block>) { return this.http.put(`${this.apiUrl}/blocks/${id}`, block); }
+    deleteBlock(id: string) { return this.http.delete(`${this.apiUrl}/blocks/${id}`); }
+    generateBlockDescription(data: { name: string, phaseName?: string, size?: number, numOfTrees?: number }) { return this.http.post<{ description: string }>(`${this.apiUrl}/blocks/generate-description`, data); }
+}

+ 27 - 0
src/app/site/site-management-dialog/site-management-dialog.component.css

@@ -0,0 +1,27 @@
+.dialog-form {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 24px;
+    box-sizing: border-box;
+}
+
+.form-content {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    overflow-y: auto;
+    padding-bottom: 20px;
+}
+
+.dialog-footer {
+    padding-top: 10px;
+    display: flex;
+    justify-content: flex-end;
+    gap: 12px;
+}
+
+/* Ensure inputs take full width */
+mat-form-field {
+    width: 100%;
+}

+ 125 - 0
src/app/site/site-management-dialog/site-management-dialog.component.html

@@ -0,0 +1,125 @@
+<h2 mat-dialog-title>{{ title }}</h2>
+
+<form [formGroup]="form" (ngSubmit)="onSubmit()" class="dialog-form">
+    <div class="form-content">
+
+        <!-- FIELDS FOR SITE -->
+        <ng-container *ngIf="data.type === 'site'">
+            <mat-form-field appearance="outline">
+                <mat-label>Name</mat-label>
+                <input matInput formControlName="name" placeholder="Site Name">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Address</mat-label>
+                <input matInput formControlName="address" placeholder="Address">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Status</mat-label>
+                <mat-select formControlName="status">
+                    <mat-option value="active">Active</mat-option>
+                    <mat-option value="inactive">Inactive</mat-option>
+                </mat-select>
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Description</mat-label>
+                <textarea matInput formControlName="description" rows="3"></textarea>
+                <button mat-icon-button matSuffix type="button" (click)="generateDescription()"
+                    [attr.aria-label]="'Generate AI Description'" matTooltip="Generate AI Description">
+                    <mat-icon>auto_awesome</mat-icon>
+                </button>
+            </mat-form-field>
+        </ng-container>
+
+
+        <!-- FIELDS FOR PHASE -->
+        <ng-container *ngIf="data.type === 'phase'">
+
+            <mat-form-field appearance="outline">
+                <mat-label>Parent Site</mat-label>
+                <mat-select formControlName="siteId">
+                    <mat-option *ngFor="let s of sites" [value]="s._id">{{ s.name }}</mat-option>
+                </mat-select>
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Name</mat-label>
+                <input matInput formControlName="name" placeholder="Phase Name">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Status</mat-label>
+                <mat-select formControlName="status">
+                    <mat-option value="planned">Planned</mat-option>
+                    <mat-option value="active">Active</mat-option>
+                    <mat-option value="completed">Completed</mat-option>
+                </mat-select>
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Description</mat-label>
+                <textarea matInput formControlName="description" rows="3"></textarea>
+                <button mat-icon-button matSuffix type="button" (click)="generateDescription()"
+                    [attr.aria-label]="'Generate AI Description'" matTooltip="Generate AI Description">
+                    <mat-icon>auto_awesome</mat-icon>
+                </button>
+            </mat-form-field>
+        </ng-container>
+
+        <!-- FIELDS FOR BLOCK -->
+        <ng-container *ngIf="data.type === 'block'">
+
+            <mat-form-field appearance="outline">
+                <mat-label>Parent Phase</mat-label>
+                <mat-select formControlName="phaseId">
+                    <mat-option *ngFor="let p of phases" [value]="p._id">{{ p.name }}</mat-option>
+                </mat-select>
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Name</mat-label>
+                <input matInput formControlName="name" placeholder="Block Name">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Size</mat-label>
+                <input matInput type="number" formControlName="size">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Size UOM</mat-label>
+                <mat-select formControlName="sizeUom">
+                    <mat-option value="acres">Acres</mat-option>
+                    <mat-option value="sqft">Sq Ft</mat-option>
+                    <mat-option value="sqm">Sq M</mat-option>
+                </mat-select>
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Number of Trees</mat-label>
+                <input matInput type="number" formControlName="numOfTrees">
+            </mat-form-field>
+
+            <mat-form-field appearance="outline">
+                <mat-label>Description</mat-label>
+                <textarea matInput formControlName="description" rows="3"></textarea>
+                <button mat-icon-button matSuffix type="button" (click)="generateDescription()"
+                    [attr.aria-label]="'Generate AI Description'" matTooltip="Generate AI Description">
+                    <mat-icon>auto_awesome</mat-icon>
+                </button>
+            </mat-form-field>
+        </ng-container>
+
+    </div>
+
+    <div class="dialog-footer">
+        <button *ngIf="data.mode === 'edit'" mat-button color="warn" type="button" (click)="onDelete()">Delete</button>
+        <span style="flex: 1 1 auto;"></span>
+        <button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
+        <button mat-flat-button color="primary" type="submit" [disabled]="form.invalid || saving">
+            {{ saving ? 'Saving...' : 'Save' }}
+        </button>
+    </div>
+</form>

+ 258 - 0
src/app/site/site-management-dialog/site-management-dialog.component.ts

@@ -0,0 +1,258 @@
+import { Component, Inject, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatSelectModule } from '@angular/material/select';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { SiteService } from '../../services/site-management.service';
+import { Site, Phase, Block } from '../../interfaces/site.interface';
+
+@Component({
+    selector: 'app-site-management-dialog',
+    standalone: true,
+    imports: [
+        CommonModule,
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatFormFieldModule,
+        MatInputModule,
+        MatButtonModule,
+        MatSelectModule,
+        MatIconModule,
+        MatSnackBarModule,
+        MatTooltipModule
+    ],
+    templateUrl: './site-management-dialog.component.html',
+    styleUrls: ['./site-management-dialog.component.css']
+})
+export class SiteManagementDialogComponent implements OnInit {
+    form: FormGroup;
+    title: string = '';
+    saving = false;
+
+    // Options for parent dropdowns
+    sites: Site[] = [];
+    phases: Phase[] = [];
+
+    constructor(
+        private fb: FormBuilder,
+        private siteService: SiteService,
+        private snackBar: MatSnackBar,
+        private dialogRef: MatDialogRef<SiteManagementDialogComponent>,
+        @Inject(MAT_DIALOG_DATA) public data: {
+            type: 'site' | 'phase' | 'block',
+            mode: 'create' | 'edit',
+            parent?: any, // Site for Phase, Phase for Block
+            item?: any // The entity being edited
+        }
+    ) {
+        this.title = `${data.mode === 'create' ? 'Create' : 'Edit'} ${this.capitalize(data.type)}`;
+        this.form = this.createForm();
+    }
+
+    ngOnInit() {
+        this.loadOptions();
+
+        if (this.data.mode === 'edit' && this.data.item) {
+            this.form.patchValue(this.data.item);
+        } else if (this.data.mode === 'create' && this.data.parent) {
+            // Pre-fill parent ID
+            if (this.data.type === 'phase') {
+                this.form.patchValue({ siteId: this.data.parent._id });
+            } else if (this.data.type === 'block') {
+                this.form.patchValue({ phaseId: this.data.parent._id });
+            }
+        }
+    }
+
+    createForm(): FormGroup {
+        if (this.data.type === 'site') {
+            return this.fb.group({
+                name: ['', Validators.required],
+                description: [''],
+                address: ['', Validators.required],
+                status: ['active', Validators.required]
+            });
+        } else if (this.data.type === 'phase') {
+            return this.fb.group({
+                siteId: ['', Validators.required],
+                name: ['', Validators.required],
+                description: [''],
+                status: ['planned', Validators.required]
+            });
+        } else { // block
+            return this.fb.group({
+                phaseId: ['', Validators.required],
+                name: ['', Validators.required],
+                description: [''],
+                numOfTrees: [0, Validators.required],
+                size: [0, Validators.required],
+                sizeUom: ['acres', Validators.required]
+            });
+        }
+    }
+
+    loadOptions() {
+        // If we are editing Phase, we need Sites.
+        // If we are editing Block, we need Phases (of the current site? or all phases?)
+        // User said "show a drop down of existing parent".
+        // For Block, user might want to move it to a different Phase.
+        // Ideally we should list all Phases, but that might be huge.
+        // Let's assume we list Phases of the *same* Site if we can determine it, or just fetch all if feasible.
+        // Given the services, we only have `getPhasesBySite`. We don't have `getAllPhases`.
+        // So for Block re-parenting, we might only support moving within the same Site (conceptually) or we'd need to first select Site then Phase?
+        // User requirement: "Parent as in the parent for phase is Site... parent value must always be a dropdown".
+        // Let's implement simplest valid approach: list parents.
+
+        if (this.data.type === 'phase') {
+            // Need sites for dropdown
+            this.siteService.getSites().subscribe(sites => this.sites = sites);
+        } else if (this.data.type === 'block') {
+            // Need phases. But which site?
+            // If create, we have parent (a Phase). We can get its Site ID?
+            // Phase object has `siteId`.
+            // If edit, Block has `phaseId`. We can fetch that Phase to get `siteId`.
+            // Then fetch all Phases of that Site.
+            // This allows moving Block between Phases of the SAME Site.
+
+            let phaseId = this.data.parent?._id || this.data.item?.phaseId;
+            if (phaseId) {
+                // We need to look up the phase to know its siteId, unless we already have the parent object
+                if (this.data.parent && this.data.parent.siteId) {
+                    this.fetchPhasesOfSite(this.data.parent.siteId);
+                } else {
+                    // Fetch phase details first
+                    this.siteService.getPhaseById(phaseId).subscribe(phase => {
+                        if (phase) this.fetchPhasesOfSite(phase.siteId);
+                    });
+                }
+            }
+        }
+    }
+
+    fetchPhasesOfSite(siteId: string) {
+        this.siteService.getPhasesBySite(siteId).subscribe(phases => this.phases = phases);
+    }
+
+    onSubmit() {
+        if (this.form.invalid) return;
+
+        this.saving = true;
+        const val = this.form.value;
+        const id = this.data.item?._id;
+
+        let req;
+
+        if (this.data.type === 'site') {
+            if (this.data.mode === 'create') req = this.siteService.createSite(val);
+            else req = this.siteService.updateSite(id, val);
+        } else if (this.data.type === 'phase') {
+            if (this.data.mode === 'create') req = this.siteService.createPhase(val);
+            else req = this.siteService.updatePhase(id, val);
+        } else {
+            if (this.data.mode === 'create') req = this.siteService.createBlock(val);
+            else req = this.siteService.updateBlock(id, val);
+        }
+
+        req.subscribe({
+            next: () => {
+                this.snackBar.open(`${this.capitalize(this.data.type)} saved`, 'Close', { duration: 2000 });
+                this.dialogRef.close('refresh');
+            },
+            error: (err) => {
+                console.error(err);
+                this.snackBar.open('Error saving', 'Close', { duration: 3000 });
+                this.saving = false;
+            }
+        });
+    }
+
+    onDelete() {
+        if (!confirm(`Are you sure you want to delete this ${this.data.type}? This action cannot be undone.`)) return;
+
+        this.saving = true;
+        const id = this.data.item?._id;
+        let req;
+
+        if (this.data.type === 'site') req = this.siteService.deleteSite(id);
+        else if (this.data.type === 'phase') req = this.siteService.deletePhase(id);
+        else req = this.siteService.deleteBlock(id);
+
+        req.subscribe({
+            next: () => {
+                this.snackBar.open(`${this.capitalize(this.data.type)} deleted`, 'Close', { duration: 2000 });
+                this.dialogRef.close('refresh');
+            },
+            error: (err) => {
+                console.error(err);
+                this.snackBar.open('Error deleting', 'Close', { duration: 3000 });
+                this.saving = false;
+            }
+        });
+    }
+
+    generateDescription() {
+        const val = this.form.value;
+        if (!val.name) {
+            this.snackBar.open('Please enter a name first', 'Close', { duration: 2000 });
+            return;
+        }
+
+        this.form.get('description')?.setValue('Generating...');
+        this.form.get('description')?.disable();
+
+        let req;
+        if (this.data.type === 'site') {
+            req = this.siteService.generateSiteDescription({ name: val.name, address: val.address });
+        } else if (this.data.type === 'phase') {
+            // Find parent site name if possible
+            let siteName = '';
+            if (this.data.parent && this.data.parent.name) siteName = this.data.parent.name;
+            else if (val.siteId) {
+                const site = this.sites.find(s => s._id === val.siteId);
+                if (site) siteName = site.name;
+            }
+            req = this.siteService.generatePhaseDescription({ name: val.name, siteName });
+        } else {
+            // Find parent phase name
+            let phaseName = '';
+            if (this.data.parent && this.data.parent.name) phaseName = this.data.parent.name;
+            else if (val.phaseId) {
+                const phase = this.phases.find(p => p._id === val.phaseId);
+                if (phase) phaseName = phase.name;
+            }
+            req = this.siteService.generateBlockDescription({
+                name: val.name,
+                phaseName,
+                size: val.size,
+                numOfTrees: val.numOfTrees
+            });
+        }
+
+        req.subscribe({
+            next: (res) => {
+                this.form.get('description')?.setValue(res.description);
+                this.form.get('description')?.enable();
+            },
+            error: (err) => {
+                console.error(err);
+                this.snackBar.open('Error generating description', 'Close', { duration: 3000 });
+                this.form.get('description')?.setValue('');
+                this.form.get('description')?.enable();
+            }
+        });
+    }
+
+    capitalize(s: string) {
+        return s.charAt(0).toUpperCase() + s.slice(1);
+    }
+
+    cancel() {
+        this.dialogRef.close();
+    }
+}

+ 203 - 0
src/app/site/site-management/site-management.component.css

@@ -0,0 +1,203 @@
+.container {
+    padding: 20px;
+}
+
+.header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+}
+
+.spinner-container {
+    display: flex;
+    justify-content: center;
+    padding: 50px;
+}
+
+.panel-title {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    font-weight: 500;
+}
+
+.type-icon {
+    color: #555;
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+}
+
+.panel-content {
+    padding-top: 15px;
+}
+
+.sub-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 10px;
+    border-bottom: 1px solid #eee;
+    padding-bottom: 5px;
+}
+
+.sub-header h3 {
+    margin: 0;
+    font-size: 16px;
+    color: #666;
+}
+
+.small-spinner {
+    display: flex;
+    justify-content: center;
+    padding: 10px;
+}
+
+.empty-text {
+    color: #999;
+    font-style: italic;
+    padding: 10px;
+}
+
+.block-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 10px;
+}
+
+.block-panel {
+    background-color: #fafafa !important;
+    border: 1px solid #eee;
+    box-shadow: none !important;
+    margin-bottom: 4px;
+}
+
+.block-panel:hover {
+    background-color: #f1f1f1 !important;
+}
+
+.block-panel .mat-expansion-panel-header {
+    padding: 0 16px;
+    height: 48px;
+    cursor: pointer;
+}
+
+.block-panel .mat-expansion-panel-header-title,
+.block-panel .mat-expansion-panel-header-description {
+    font-size: 13px;
+    align-items: center;
+}
+
+.block-title {
+    font-weight: 500;
+    gap: 8px;
+}
+
+.block-title .mat-icon {
+    font-size: 18px;
+    width: 18px;
+    height: 18px;
+    color: #666;
+}
+
+.description-section {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background-color: #f5f5f5;
+    padding: 12px;
+    border-radius: 4px;
+    margin-bottom: 15px;
+    border-left: 3px solid #3f51b5;
+}
+
+.description-section p {
+    margin: 0;
+    color: #444;
+    font-size: 14px;
+    flex: 1;
+    margin-right: 20px;
+}
+
+.description-section .mat-icon {
+    font-size: 16px;
+    height: 16px;
+    width: 16px;
+    margin-right: 4px;
+}
+
+.block-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 10px;
+    background-color: #f9f9f9;
+    border-radius: 4px;
+    border: 1px solid #eee;
+}
+
+.block-item:hover {
+    background-color: #f0f0f0;
+}
+
+.block-info {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 4px;
+}
+
+.block-header {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.block-description {
+    display: flex;
+    align-items: center;
+    font-size: 12px;
+    color: #666;
+    margin-left: 30px;
+}
+
+.desc-text {
+    font-style: italic;
+    max-width: 500px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.block-description button {
+    height: 24px;
+    width: 24px;
+    line-height: 24px;
+    margin-left: 8px;
+}
+
+.block-description .mat-icon {
+    font-size: 16px;
+    height: 16px;
+    width: 16px;
+}
+
+.block-name {
+    font-weight: 500;
+}
+
+.block-meta {
+    color: #777;
+    font-size: 12px;
+}
+
+.mat-expansion-panel-header-description {
+    align-items: center;
+}
+
+.actions {
+    margin-left: auto;
+    padding-left: 10px;
+}

+ 143 - 0
src/app/site/site-management/site-management.component.html

@@ -0,0 +1,143 @@
+<div class="container">
+    <div class="header">
+        <h1>Site Management</h1>
+        <button mat-raised-button color="primary" (click)="openDialog('site', 'create')">
+            <mat-icon>add</mat-icon> New Site
+        </button>
+    </div>
+
+    <div *ngIf="loading" class="spinner-container">
+        <mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
+    </div>
+
+    <mat-accordion *ngIf="!loading" multi>
+        <!-- SITE LEVEL -->
+        <mat-expansion-panel *ngFor="let site of sites" (opened)="onExpandSite(site)">
+            <mat-expansion-panel-header>
+                <mat-panel-title class="panel-title">
+                    <mat-icon class="type-icon">domain</mat-icon>
+                    {{ site.name }}
+                </mat-panel-title>
+                <mat-panel-description>
+                    <!-- Description moved to content -->
+                </mat-panel-description>
+                <div class="actions" (click)="$event.stopPropagation()">
+                    <button mat-icon-button color="accent" (click)="openDialog('site', 'edit', null, site)"
+                        matTooltip="Edit Site">
+                        <mat-icon>edit</mat-icon>
+                    </button>
+                </div>
+            </mat-expansion-panel-header>
+
+            <div class="panel-content">
+                <div class="description-section">
+                    <p><strong>Description:</strong> {{ site.description || 'No description' }}</p>
+                    <button mat-stroked-button color="primary" size="small" (click)="generateDescription('site', site)"
+                        [disabled]="!!site.description">
+                        <mat-icon>auto_awesome</mat-icon> Generate Description
+                    </button>
+                </div>
+
+                <div class="sub-header">
+                    <h3>Phases</h3>
+                    <button mat-stroked-button color="primary" size="small"
+                        (click)="openDialog('phase', 'create', site)">
+                        <mat-icon>add</mat-icon> Add Phase
+                    </button>
+                </div>
+
+                <div *ngIf="site['loadingPhases']" class="small-spinner">
+                    <mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
+                </div>
+
+                <div *ngIf="!site['loadingPhases'] && (!site.phases || site.phases.length === 0)">
+                    <p class="empty-text">No phases found.</p>
+                </div>
+
+                <mat-accordion multi>
+                    <!-- PHASE LEVEL -->
+                    <mat-expansion-panel *ngFor="let phase of site.phases" (opened)="onExpandPhase(phase)">
+                        <mat-expansion-panel-header>
+                            <mat-panel-title class="panel-title">
+                                <mat-icon class="type-icon">layers</mat-icon>
+                                {{ phase.name }}
+                            </mat-panel-title>
+                            <mat-panel-description>
+                                {{ phase.status }}
+                            </mat-panel-description>
+                            <div class="actions" (click)="$event.stopPropagation()">
+                                <button mat-icon-button color="accent"
+                                    (click)="openDialog('phase', 'edit', site, phase)" matTooltip="Edit Phase">
+                                    <mat-icon>edit</mat-icon>
+                                </button>
+                            </div>
+                        </mat-expansion-panel-header>
+
+                        <div class="panel-content">
+                            <div class="description-section">
+                                <p><strong>Description:</strong> {{ phase.description || 'No description' }}</p>
+                                <button mat-stroked-button color="primary" size="small"
+                                    (click)="generateDescription('phase', phase, site)"
+                                    [disabled]="!!phase.description">
+                                    <mat-icon>auto_awesome</mat-icon> Generate Description
+                                </button>
+                            </div>
+
+                            <div class="sub-header">
+                                <h3>Blocks</h3>
+                                <button mat-stroked-button color="primary" size="small"
+                                    (click)="openDialog('block', 'create', phase)">
+                                    <mat-icon>add</mat-icon> Add Block
+                                </button>
+                            </div>
+
+                            <div *ngIf="phase['loadingBlocks']" class="small-spinner">
+                                <mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
+                            </div>
+
+                            <div *ngIf="!phase['loadingBlocks'] && (!phase.blocks || phase.blocks.length === 0)">
+                                <p class="empty-text">No blocks found.</p>
+                            </div>
+
+                            <!-- BLOCK LEVEL (Expansion Panels) -->
+                            <div class="block-list">
+                                <mat-accordion multi>
+                                    <mat-expansion-panel *ngFor="let block of phase.blocks" class="block-panel">
+                                        <mat-expansion-panel-header>
+                                            <mat-panel-title class="panel-title block-title">
+                                                <mat-icon class="type-icon">grid_on</mat-icon>
+                                                {{ block.name }}
+                                            </mat-panel-title>
+                                            <mat-panel-description>
+                                                {{ block.size }} {{ block.sizeUom }} • {{ block.numOfTrees }} Trees
+                                            </mat-panel-description>
+                                            <div class="actions" (click)="$event.stopPropagation()">
+                                                <button mat-icon-button color="accent"
+                                                    (click)="openDialog('block', 'edit', phase, block)"
+                                                    matTooltip="Edit Block">
+                                                    <mat-icon>edit</mat-icon>
+                                                </button>
+                                            </div>
+                                        </mat-expansion-panel-header>
+
+                                        <div class="panel-content">
+                                            <div class="description-section">
+                                                <p><strong>Description:</strong> {{ block.description || 'No
+                                                    description' }}</p>
+                                                <button mat-stroked-button color="primary" size="small"
+                                                    (click)="generateDescription('block', block, phase)"
+                                                    [disabled]="block.description && block.description.length > 0">
+                                                    <mat-icon>auto_awesome</mat-icon> Generate Description
+                                                </button>
+                                            </div>
+                                        </div>
+                                    </mat-expansion-panel>
+                                </mat-accordion>
+                            </div>
+                        </div>
+                    </mat-expansion-panel>
+                </mat-accordion>
+            </div>
+        </mat-expansion-panel>
+    </mat-accordion>
+</div>

+ 160 - 0
src/app/site/site-management/site-management.component.ts

@@ -0,0 +1,160 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { SiteService } from '../../services/site-management.service';
+import { Site, Phase, Block } from '../../interfaces/site.interface';
+import { SiteManagementDialogComponent } from '../site-management-dialog/site-management-dialog.component';
+
+@Component({
+    selector: 'app-site-management',
+    standalone: true,
+    imports: [
+        CommonModule,
+        MatExpansionModule,
+        MatButtonModule,
+        MatIconModule,
+        MatProgressSpinnerModule,
+        MatDialogModule,
+        MatTooltipModule,
+        MatSnackBarModule
+    ],
+    templateUrl: './site-management.component.html',
+    styleUrls: ['./site-management.component.css']
+})
+export class SiteManagementComponent implements OnInit {
+    siteService = inject(SiteService);
+    dialog = inject(MatDialog);
+    snackBar = inject(MatSnackBar);
+
+    sites: Site[] = [];
+    loading = false;
+
+    ngOnInit() {
+        this.loadSites();
+    }
+
+    loadSites() {
+        this.loading = true;
+        this.siteService.getSites().subscribe({
+            next: (data) => {
+                this.sites = data;
+                this.loading = false;
+            },
+            error: (err) => {
+                console.error(err);
+                this.loading = false;
+            }
+        });
+    }
+
+    // Lazy load phases
+    onExpandSite(site: Site) {
+        if (!site.phases) {
+            site['loadingPhases'] = true; // Temporary flag
+            this.siteService.getPhasesBySite(site._id!).subscribe({
+                next: (phases) => {
+                    site.phases = phases;
+                    site['loadingPhases'] = false;
+                },
+                error: () => site['loadingPhases'] = false
+            });
+        }
+    }
+
+    // Lazy load blocks
+    onExpandPhase(phase: Phase) {
+        if (!phase.blocks) {
+            phase['loadingBlocks'] = true;
+            this.siteService.getBlocksByPhase(phase._id!).subscribe({
+                next: (blocks) => {
+                    phase.blocks = blocks;
+                    phase['loadingBlocks'] = false;
+                },
+                error: () => phase['loadingBlocks'] = false
+            });
+        }
+    }
+
+    openDialog(type: 'site' | 'phase' | 'block', mode: 'create' | 'edit', parent?: any, item?: any) {
+        const dialogRef = this.dialog.open(SiteManagementDialogComponent, {
+            width: '600px',
+            data: { type, mode, parent, item }
+        });
+
+        dialogRef.afterClosed().subscribe(result => {
+            if (result === 'refresh') {
+                if (type === 'site') {
+                    this.loadSites();
+                } else if (type === 'phase') {
+                    if (parent) {
+                        delete parent.phases;
+                        this.onExpandSite(parent);
+                    } else {
+                        this.loadSites();
+                    }
+                } else if (type === 'block') {
+                    if (parent) {
+                        delete parent.blocks;
+                        this.onExpandPhase(parent);
+                    } else {
+                        this.loadSites();
+                    }
+                }
+            }
+        });
+    }
+
+    generateDescription(type: 'site' | 'phase' | 'block', item: any, parent?: any) {
+        this.snackBar.open(`Generating description for ${item.name}...`, 'Close', { duration: 2000 });
+
+        let genReq;
+        if (type === 'site') {
+            genReq = this.siteService.generateSiteDescription({ name: item.name, address: item.address });
+        } else if (type === 'phase') {
+            genReq = this.siteService.generatePhaseDescription({ name: item.name, siteName: parent?.name });
+        } else {
+            genReq = this.siteService.generateBlockDescription({
+                name: item.name,
+                phaseName: parent?.name,
+                size: item.size,
+                numOfTrees: item.numOfTrees
+            });
+        }
+
+        genReq.subscribe({
+            next: (res) => {
+                const newDesc = res.description;
+                // Now update the item
+                let updateReq;
+                if (type === 'site') updateReq = this.siteService.updateSite(item._id, { description: newDesc });
+                else if (type === 'phase') updateReq = this.siteService.updatePhase(item._id, { description: newDesc });
+                else updateReq = this.siteService.updateBlock(item._id, { description: newDesc });
+
+                updateReq.subscribe({
+                    next: () => {
+                        item.description = newDesc; // Immediate UI update
+                        this.snackBar.open('Description updated successfully', 'Close', { duration: 2000 });
+                    },
+                    error: (err) => {
+                        console.error(err);
+                        this.snackBar.open('Error saving description', 'Close', { duration: 3000 });
+                    }
+                });
+            },
+            error: (err) => {
+                console.error(err);
+                this.snackBar.open('Error generating description', 'Close', { duration: 3000 });
+            }
+        });
+    }
+
+    // Helpers for template casting
+    asSite(val: any): Site { return val; }
+    asPhase(val: any): Phase { return val; }
+}