My Listings Dashboard — Angular Admin View for Sellers

The seller dashboard is the most complex Angular feature in the classified website — it combines a listing management table, real-time status updates, a multi-step creation wizard, and seller analytics. The CreateListingWizard is a multi-step reactive form that validates each step before proceeding, maintains state across steps (including uploaded photos), and submits the complete listing as a single command when the seller clicks “Publish.”

Seller Dashboard Components

// ── MyListingsComponent — seller's listing management table ───────────────
@Component({
  selector:   'app-my-listings',
  standalone:  true,
  imports:    [MatTableModule, MatButtonModule, MatChipsModule,
               MatSelectModule, MatPaginatorModule, RouterLink, DatePipe],
  template: `
    <!-- Statistics panel ────────────────────────────────────────────────── -->
    <div class="stats-row">
      @for (stat of stats(); track stat.label) {
        <div class="stat-card">
          <span class="stat-value">{{ stat.value }}</span>
          <span class="stat-label">{{ stat.label }}</span>
        </div>
      }
    </div>

    <!-- Action bar ──────────────────────────────────────────────────────── -->
    <div class="actions-bar">
      <button mat-raised-button color="primary"
              routerLink="/my-listings/new"
              data-cy="create-listing-btn">
        + New Listing
      </button>

      @if (selection.hasValue()) {
        <button mat-button (click)="onBulkExpire()">
          Expire Selected ({{ selection.selected.length }})
        </button>
      }
    </div>

    <!-- Listings table ──────────────────────────────────────────────────── -->
    <mat-table [dataSource]="listings()" data-cy="listings-table">
      <!-- Checkbox column ───────────────────────────────────────────────── -->
      <ng-container matColumnDef="select">
        <mat-header-cell *matHeaderCellDef>
          <mat-checkbox (change)="toggleAll($event)"
                        [checked]="allSelected()"></mat-checkbox>
        </mat-header-cell>
        <mat-cell *matCellDef="let row">
          <mat-checkbox [checked]="selection.isSelected(row)"
                        (change)="selection.toggle(row)"></mat-checkbox>
        </mat-cell>
      </ng-container>

      <!-- Title ─────────────────────────────────────────────────────────── -->
      <ng-container matColumnDef="title">
        <mat-header-cell *matHeaderCellDef>Title</mat-header-cell>
        <mat-cell *matCellDef="let listing" data-cy="listing-title">
          <a [routerLink]="['/listings', listing.id]">{{ listing.title }}</a>
        </mat-cell>
      </ng-container>

      <!-- Status badge ──────────────────────────────────────────────────── -->
      <ng-container matColumnDef="status">
        <mat-header-cell *matHeaderCellDef>Status</mat-header-cell>
        <mat-cell *matCellDef="let listing">
          <mat-chip [color]="statusColour(listing.status)">
            {{ listing.status }}
          </mat-chip>
        </mat-cell>
      </ng-container>

      <!-- Actions ───────────────────────────────────────────────────────── -->
      <ng-container matColumnDef="actions">
        <mat-header-cell *matHeaderCellDef></mat-header-cell>
        <mat-cell *matCellDef="let listing">
          @switch (listing.status) {
            @case ('Draft') {
              <button mat-button (click)="onPublish(listing.id)">Publish</button>
            }
            @case ('Expired') {
              <button mat-button (click)="onRenew(listing.id)">Renew</button>
            }
            @case ('Active') {
              <button mat-button (click)="onMarkSold(listing.id)">Mark Sold</button>
            }
          }
          <button mat-icon-button [routerLink]="['/my-listings', listing.id, 'edit']">
            <mat-icon>edit</mat-icon>
          </button>
          <button mat-icon-button color="warn"
                  (click)="onDelete(listing.id)">
            <mat-icon>delete</mat-icon>
          </button>
        </mat-cell>
      </ng-container>

      <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
      <mat-row *matRowDef="let row; columns: displayedColumns"
               [class.pending]="row.status === 'PendingReview'"></mat-row>
    </mat-table>
    <mat-paginator [pageSize]="10" (page)="onPageChange($event)" />
  `,
})
export class MyListingsComponent implements OnInit {
  private listingsApi = inject(ListingsApiService);
  private router      = inject(Router);
  private notify      = inject(ToastService);

  listings        = signal<MyListingDto[]>([]);
  stats           = signal<StatCard[]>([]);
  loading         = signal(true);
  selection       = new SelectionModel<MyListingDto>(true, []);
  displayedColumns = ['select', 'title', 'price', 'status', 'views',
                      'contacts', 'publishedAt', 'expiresAt', 'actions'];

  allSelected = computed(() =>
    this.listings().length > 0 &&
    this.selection.selected.length === this.listings().length);

  statusColour = (status: string): string => ({
    Active:        'primary',
    PendingReview: 'accent',
    Expired:       'warn',
    Draft:         '',
    Rejected:      'warn',
  }[status] ?? '');

  ngOnInit() {
    this.loadListings();
    this.loadStats();
  }

  private loadListings(page = 1): void {
    this.listingsApi.getMyListings(page).pipe(
      finalize(() => this.loading.set(false)),
    ).subscribe(result => this.listings.set(result.items));
  }

  onPublish(id: string): void {
    this.listingsApi.publish(id).subscribe(() => {
      this.notify.success('Listing published!');
      this.loadListings();
    });
  }
}
Note: The @switch block in the actions column renders different action buttons based on the listing’s status — only draft listings show “Publish,” only expired listings show “Renew,” only active listings show “Mark Sold.” This prevents impossible actions (publishing an already-active listing) at the UI level, complementing the domain invariant checks in the backend. The UI provides a clear affordance; the backend enforces the rule.
Tip: The SelectionModel from @angular/cdk/collections manages the checkboxes in the table without custom state management. It tracks which rows are selected (selection.isSelected(row)), provides toggle (selection.toggle(row)), select all (selection.select(...all)), and clear (selection.clear()) operations. Use it for any Angular Material table that needs row selection — it integrates with mat-checkbox cleanly and provides the hasValue() check for conditional action buttons.
Warning: The “Mark Sold” action changes the listing status in a way that cannot be undone — once a listing is sold, it is removed from search results and the seller’s active listings count decreases. Before implementing this as a single-click action, add a confirmation dialog (MatDialog with a confirmation prompt). Accidental “Mark Sold” clicks on valuable listings can frustrate sellers. Use the same confirmation dialog pattern as “Delete” for any irreversible action.

Common Mistakes

Mistake 1 — Allowing impossible status transitions in the UI (confusing UX)

❌ Wrong — “Publish” button shown for Active listings; user clicks it; backend returns 400; confusing error with no context.

✅ Correct — @switch (listing.status) only shows relevant actions; impossible actions never offered; clean UX.

Mistake 2 — No confirmation for irreversible actions (accidental data loss)

❌ Wrong — single-click delete or mark-as-sold; user fat-fingers the button; listing gone; no undo.

✅ Correct — MatDialog confirmation for delete and mark-sold; user must explicitly confirm before irreversible action.

🧠 Test Yourself

A seller has 5 listings: 2 Active, 1 Draft, 1 PendingReview, 1 Expired. They select all and click “Expire Selected.” Which listings should the bulk expire affect?