Przejdź do treści
School IT / Przestrzeń do nauki

Warsztat — formularz, wynik, lista

Wszystko z bloku kontrolek w jednej aplikacji, budowanej krok po kroku tak, jak buduje się rozwiązanie zadania praktycznego: najpierw dane i formularz, potem wyliczenia, potem walidacja i zapis, na końcu lista wyników. Każdy krok to działająca wersja programu.

Angular 22 warsztat plan pracy interfejs formularz computed walidacja tabela 2 × 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Rozpiszesz polecenie zadania na listę elementów do zrobienia i do sprawdzenia.
  • Zaplanujesz kolejność pracy: dane, kontrolki, wyliczenia, walidacja, lista.
  • Opiszesz dane interfejsem i trzymasz je w tablicy, z której korzysta szablon.
  • Połączysz w jednym formularzu pole tekstowe, listę rozwijaną, radio, suwak, checkboxy i datę.
  • Policzysz koszt w computed i rozpiszesz go na pozycje.
  • Zapiszesz poprawne zgłoszenia jako kopie danych w tabeli z sumą i usuwaniem.
  • Sprawdzisz gotową aplikację punkt po punkcie z poleceniem.

Przygotowanie: lekcje 07, 13 i 17–26 — obrazy, pętle, wszystkie kontrolki, wysyłka i walidacja formularza. Przewidywany czas: 2 × 45 min.

TEORIA

Zadanie: zapisy na warsztaty

Klub informatyczny organizuje warsztaty. Przygotuj aplikację zapisów, która:

  1. zbiera imię i nazwisko (min. 5 znaków), klasę z listy, wybrany warsztat (radio), liczbę dni (suwak 1–5), dodatki (obiad 15 zł za dzień, koszulka 40 zł) i datę pierwszego dnia;
  2. pokazuje plakat wybranego warsztatu i aktualny koszt;
  3. nie przyjmuje zgłoszenia bez wymaganych pól i bez akceptacji regulaminu, a błędy pokazuje przy polach;
  4. po zapisie dopisuje zgłoszenie do tabeli i czyści formularz;
  5. pod tabelą pokazuje sumę wszystkich zgłoszeń, a każde zgłoszenie da się usunąć.

Ceny za dzień: Robotyka 60 zł, Druk 3D 75 zł, Gry retro 40 zł. Plakaty leżą w public/img (te same pliki co w lekcji 07).

TEORIA

Jak podejść do takiego zadania

KrokCo robisz
1. Przeczytaj i wypiszZanim napiszesz linijkę kodu, zrób listę: jakie pola, jakie wyliczenia, jakie komunikaty, co ma się stać po kliknięciu. Każdy punkt polecenia to zwykle osobne kryterium oceny.
2. Najpierw daneInterfejs i tablica z danymi (warsztaty, klasy), sygnał dla każdego pola. Dopiero potem szablon. Dane wpisane na sztywno w HTML trudno potem liczyć.
3. Buduj w działających krokachPo każdym kroku uruchom aplikację i sprawdź. Lepiej oddać działające cztery punkty z pięciu niż pięć niedziałających.
4. Sprawdź kryteriaWróć do listy z kroku 1 i odhacz każdy element na działającej aplikacji. Sprawdź przypadki brzegowe: pusty formularz, wartości minimalne i maksymalne.

Plan tej lekcji: krok 1 — model i formularz z podglądem, krok 2 — koszt, krok 3 — walidacja i zapis, krok 4 — tabela zgłoszeń. Każdy krok zawiera pełne pliki, więc możesz zacząć od dowolnego.

KROK 1

Model danych i formularz z podglądem

Interfejs Warsztat, tablica z trzema warsztatami, sygnał dla każdego pola i computed, który wybiera aktualny warsztat po id. Po prawej karta z plakatem i podglądem danych.

src/app/nowa-27-1/demo.ts — pełny plik
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Warsztat {
    id: number;
    nazwa: string;
    cenaZaDzien: number;
    obraz: string;
}

@Component({
    selector: 'app-demo',
    imports: [FormsModule],
    templateUrl: './demo.html'
})
export class Demo {
    readonly warsztaty: Warsztat[] = [
        { id: 1, nazwa: 'Robotyka', cenaZaDzien: 60, obraz: '/img/roboty.svg' },
        { id: 2, nazwa: 'Druk 3D', cenaZaDzien: 75, obraz: '/img/druk3d.svg' },
        { id: 3, nazwa: 'Gry retro', cenaZaDzien: 40, obraz: '/img/turniej.svg' },
    ];
    readonly klasy = ['1TP', '2TP', '3TP', '4TP', '5TP'];

    readonly imie = signal('');
    readonly klasa = signal<string | null>(null);
    readonly warsztatId = signal(1);
    readonly dni = signal(3);
    readonly obiad = signal(false);
    readonly koszulka = signal(false);
    readonly start = signal('');

    readonly warsztat = computed(() => this.warsztaty.find(w => w.id === this.warsztatId())!);
}
src/app/nowa-27-1/demo.html — pełny plik
<div class="p-3">
  <div class="row g-4" style="max-width: 60rem">
    <div class="col-md-7">
      <h4>Zapisy na warsztaty klubu</h4>

      <label class="form-label" for="imie">Imię i nazwisko</label>
      <input id="imie" name="imie" class="form-control mb-2" [(ngModel)]="imie">

      <label class="form-label" for="klasa">Klasa</label>
      <select id="klasa" name="klasa" class="form-select mb-2" [(ngModel)]="klasa">
        <option [ngValue]="null" disabled>— wybierz —</option>
        @for (k of klasy; track k) { <option [ngValue]="k">{{ k }}</option> }
      </select>

      <p class="form-label mb-1">Warsztat</p>
      @for (w of warsztaty; track w.id) {
        <div class="form-check form-check-inline">
          <input [id]="'w' + w.id" name="warsztat" class="form-check-input" type="radio"
                 [value]="w.id" [(ngModel)]="warsztatId">
          <label class="form-check-label" [for]="'w' + w.id">{{ w.nazwa }}</label>
        </div>
      }

      <label class="form-label d-block mt-2" for="dni">Liczba dni: {{ dni() }}</label>
      <input id="dni" name="dni" type="range" class="form-range" min="1" max="5" [(ngModel)]="dni">

      <div class="form-check">
        <input id="obiad" name="obiad" class="form-check-input" type="checkbox" [(ngModel)]="obiad">
        <label class="form-check-label" for="obiad">Obiad (15 zł / dzień)</label>
      </div>
      <div class="form-check mb-2">
        <input id="koszulka" name="koszulka" class="form-check-input" type="checkbox" [(ngModel)]="koszulka">
        <label class="form-check-label" for="koszulka">Koszulka klubowa (40 zł)</label>
      </div>

      <label class="form-label" for="start">Pierwszy dzień</label>
      <input id="start" name="start" type="date" class="form-control" [(ngModel)]="start">
    </div>

    <div class="col-md-5">
      <div class="card">
        <img [src]="warsztat().obraz" [alt]="'Plakat: ' + warsztat().nazwa" class="card-img-top">
        <div class="card-body">
          <h5 class="card-title">{{ warsztat().nazwa }}</h5>
          <p class="card-text mb-0">{{ imie() || '(imię)' }}, klasa {{ klasa() ?? '?' }}</p>
          <p class="card-text">dni: {{ dni() }}, start: {{ start() || '?' }}</p>
        </div>
      </div>
    </div>
  </div>
</div>
wynik
wszystkie kontrolki działają i od razu zmieniają kartę po prawej:
plakat warsztatu, „(imię), klasa ?”, „dni: 3, start: ?”

Wykrzyknik na końcu find(…)! mówi TypeScriptowi, że wynik na pewno istnieje. Wolno tak napisać, bo warsztatId przyjmuje wyłącznie wartości z przycisków radio, a te pochodzą z tej samej tablicy. Nic jeszcze nie jest liczone ani zapisywane — i dobrze, to tylko pierwszy krok.

Zatrzymaj się

Które punkty polecenia już są spełnione, a które jeszcze nie? Zapisz to na kartce.

KROK 2

Wyliczenie kosztu

Dochodzi jeden computed w klasie i rozpisanie kosztu w karcie.

src/app/nowa-27-2/demo.ts — pełny plik
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Warsztat {
    id: number;
    nazwa: string;
    cenaZaDzien: number;
    obraz: string;
}

@Component({
    selector: 'app-demo',
    imports: [FormsModule],
    templateUrl: './demo.html'
})
export class Demo {
    readonly warsztaty: Warsztat[] = [
        { id: 1, nazwa: 'Robotyka', cenaZaDzien: 60, obraz: '/img/roboty.svg' },
        { id: 2, nazwa: 'Druk 3D', cenaZaDzien: 75, obraz: '/img/druk3d.svg' },
        { id: 3, nazwa: 'Gry retro', cenaZaDzien: 40, obraz: '/img/turniej.svg' },
    ];
    readonly klasy = ['1TP', '2TP', '3TP', '4TP', '5TP'];

    readonly imie = signal('');
    readonly klasa = signal<string | null>(null);
    readonly warsztatId = signal(1);
    readonly dni = signal(3);
    readonly obiad = signal(false);
    readonly koszulka = signal(false);
    readonly start = signal('');

    readonly warsztat = computed(() => this.warsztaty.find(w => w.id === this.warsztatId())!);

    readonly koszt = computed(() => {
        let suma = this.warsztat().cenaZaDzien * this.dni();
        if (this.obiad()) {
            suma += 15 * this.dni();
        }
        if (this.koszulka()) {
            suma += 40;
        }
        return suma;
    });
}
src/app/nowa-27-2/demo.html — pełny plik
<div class="p-3">
  <div class="row g-4" style="max-width: 60rem">
    <div class="col-md-7">
      <h4>Zapisy na warsztaty klubu</h4>

      <label class="form-label" for="imie">Imię i nazwisko</label>
      <input id="imie" name="imie" class="form-control mb-2" [(ngModel)]="imie">

      <label class="form-label" for="klasa">Klasa</label>
      <select id="klasa" name="klasa" class="form-select mb-2" [(ngModel)]="klasa">
        <option [ngValue]="null" disabled>— wybierz —</option>
        @for (k of klasy; track k) { <option [ngValue]="k">{{ k }}</option> }
      </select>

      <p class="form-label mb-1">Warsztat</p>
      @for (w of warsztaty; track w.id) {
        <div class="form-check form-check-inline">
          <input [id]="'w' + w.id" name="warsztat" class="form-check-input" type="radio"
                 [value]="w.id" [(ngModel)]="warsztatId">
          <label class="form-check-label" [for]="'w' + w.id">{{ w.nazwa }}</label>
        </div>
      }

      <label class="form-label d-block mt-2" for="dni">Liczba dni: {{ dni() }}</label>
      <input id="dni" name="dni" type="range" class="form-range" min="1" max="5" [(ngModel)]="dni">

      <div class="form-check">
        <input id="obiad" name="obiad" class="form-check-input" type="checkbox" [(ngModel)]="obiad">
        <label class="form-check-label" for="obiad">Obiad (15 zł / dzień)</label>
      </div>
      <div class="form-check mb-2">
        <input id="koszulka" name="koszulka" class="form-check-input" type="checkbox" [(ngModel)]="koszulka">
        <label class="form-check-label" for="koszulka">Koszulka klubowa (40 zł)</label>
      </div>

      <label class="form-label" for="start">Pierwszy dzień</label>
      <input id="start" name="start" type="date" class="form-control" [(ngModel)]="start">
    </div>

    <div class="col-md-5">
      <div class="card">
        <img [src]="warsztat().obraz" [alt]="'Plakat: ' + warsztat().nazwa" class="card-img-top">
        <div class="card-body">
          <h5 class="card-title">{{ warsztat().nazwa }}</h5>
          <p class="card-text mb-0">{{ imie() || '(imię)' }}, klasa {{ klasa() ?? '?' }}</p>
          <p class="card-text">dni: {{ dni() }}, start: {{ start() || '?' }}</p>
          <ul class="small mb-2">
            <li>warsztat: {{ warsztat().cenaZaDzien }} zł × {{ dni() }}</li>
            @if (obiad()) { <li>obiady: 15 zł × {{ dni() }}</li> }
            @if (koszulka()) { <li>koszulka: 40 zł</li> }
          </ul>
          <p class="fs-4 fw-bold mb-0">Razem: {{ koszt() }} zł</p>
        </div>
      </div>
    </div>
  </div>
</div>
wynik dla kolejnych zmian
Robotyka, 3 dni:          180 zł
+ obiad:                   225 zł
+ koszulka:                265 zł
zmiana na Druk 3D:         310 zł
suwak na 5 dni:            490 zł

Rozpisanie w karcie pokazuje, skąd bierze się każda kwota — to ułatwia sprawdzenie, czy wzór jest dobry.

Zatrzymaj się

Policz ręcznie koszt dla Gier retro, 4 dni, z obiadem, bez koszulki. Czy aplikacja pokazuje to samo?

KROK 3

Walidacja i zapis zgłoszenia

Pola trafiają do <form>. Dochodzą walidatory, komunikaty, regulamin, interfejs Zgloszenie i metody zapisz oraz wyczysc. Szablon zmienia się w wielu miejscach, dlatego jest podany w całości.

src/app/nowa-27-3/demo.ts — pełny plik
import { Component, computed, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

interface Zgloszenie {
    imie: string;
    klasa: string;
    warsztat: string;
    dni: number;
    start: string;
    koszt: number;
}

interface Warsztat {
    id: number;
    nazwa: string;
    cenaZaDzien: number;
    obraz: string;
}

@Component({
    selector: 'app-demo',
    imports: [FormsModule],
    templateUrl: './demo.html'
})
export class Demo {
    readonly warsztaty: Warsztat[] = [
        { id: 1, nazwa: 'Robotyka', cenaZaDzien: 60, obraz: '/img/roboty.svg' },
        { id: 2, nazwa: 'Druk 3D', cenaZaDzien: 75, obraz: '/img/druk3d.svg' },
        { id: 3, nazwa: 'Gry retro', cenaZaDzien: 40, obraz: '/img/turniej.svg' },
    ];
    readonly klasy = ['1TP', '2TP', '3TP', '4TP', '5TP'];

    readonly imie = signal('');
    readonly klasa = signal<string | null>(null);
    readonly warsztatId = signal(1);
    readonly dni = signal(3);
    readonly obiad = signal(false);
    readonly koszulka = signal(false);
    readonly start = signal('');

    readonly warsztat = computed(() => this.warsztaty.find(w => w.id === this.warsztatId())!);

    readonly koszt = computed(() => {
        let suma = this.warsztat().cenaZaDzien * this.dni();
        if (this.obiad()) {
            suma += 15 * this.dni();
        }
        if (this.koszulka()) {
            suma += 40;
        }
        return suma;
    });

    readonly zgloszenia = signal<Zgloszenie[]>([]);
    readonly regulamin = signal(false);
    readonly komunikat = signal('');

    zapisz(f: NgForm): void {
        if (f.invalid) {
            f.form.markAllAsTouched();
            this.komunikat.set('');
            return;
        }
        const nowe: Zgloszenie = {
            imie: this.imie().trim(),
            klasa: this.klasa()!,
            warsztat: this.warsztat().nazwa,
            dni: this.dni(),
            start: this.start(),
            koszt: this.koszt(),
        };
        this.zgloszenia.update(lista => [...lista, nowe]);
        this.komunikat.set('Zapisano: ' + nowe.imie + ' — ' + nowe.warsztat);
        this.wyczysc(f);
    }

    wyczysc(f: NgForm): void {
        this.imie.set('');
        this.klasa.set(null);
        this.warsztatId.set(1);
        this.dni.set(3);
        this.obiad.set(false);
        this.koszulka.set(false);
        this.start.set('');
        this.regulamin.set(false);
        f.form.markAsUntouched();
    }
}
src/app/nowa-27-3/demo.html — pełny plik
<div class="p-3">
  <div class="row g-4" style="max-width: 60rem">
    <div class="col-md-7">
      <h4>Zapisy na warsztaty klubu</h4>
      <form #f="ngForm" (ngSubmit)="zapisz(f)">
        <div class="mb-2">
          <label class="form-label" for="imie">Imię i nazwisko *</label>
          <input id="imie" name="imie" class="form-control" required minlength="5"
                 [(ngModel)]="imie" #i="ngModel" [class.is-invalid]="i.invalid && i.touched">
          <div class="invalid-feedback">Podaj imię i nazwisko (co najmniej 5 znaków).</div>
        </div>

        <div class="mb-2">
          <label class="form-label" for="klasa">Klasa *</label>
          <select id="klasa" name="klasa" class="form-select" required
                  [(ngModel)]="klasa" #k="ngModel" [class.is-invalid]="k.invalid && k.touched">
            <option [ngValue]="null" disabled>— wybierz —</option>
            @for (kl of klasy; track kl) { <option [ngValue]="kl">{{ kl }}</option> }
          </select>
          <div class="invalid-feedback">Wybierz klasę.</div>
        </div>

        <p class="form-label mb-1">Warsztat</p>
        @for (w of warsztaty; track w.id) {
          <div class="form-check form-check-inline">
            <input [id]="'w' + w.id" name="warsztat" class="form-check-input" type="radio"
                   [value]="w.id" [(ngModel)]="warsztatId">
            <label class="form-check-label" [for]="'w' + w.id">{{ w.nazwa }}</label>
          </div>
        }

        <label class="form-label d-block mt-2" for="dni">Liczba dni: {{ dni() }}</label>
        <input id="dni" name="dni" type="range" class="form-range" min="1" max="5" [(ngModel)]="dni">

        <div class="form-check">
          <input id="obiad" name="obiad" class="form-check-input" type="checkbox" [(ngModel)]="obiad">
          <label class="form-check-label" for="obiad">Obiad (15 zł / dzień)</label>
        </div>
        <div class="form-check mb-2">
          <input id="koszulka" name="koszulka" class="form-check-input" type="checkbox" [(ngModel)]="koszulka">
          <label class="form-check-label" for="koszulka">Koszulka klubowa (40 zł)</label>
        </div>

        <div>
          <label class="form-label" for="start">Pierwszy dzień *</label>
          <input id="start" name="start" type="date" class="form-control" required
                 [(ngModel)]="start" #s="ngModel" [class.is-invalid]="s.invalid && s.touched">
          <div class="invalid-feedback">Wybierz datę.</div>
        </div>

        <div class="form-check mt-3">
          <input id="reg" name="regulamin" class="form-check-input" type="checkbox" required
                 [(ngModel)]="regulamin" #r="ngModel" [class.is-invalid]="r.invalid && r.touched">
          <label class="form-check-label" for="reg">Akceptuję regulamin warsztatów *</label>
          <div class="invalid-feedback">Wymagana akceptacja regulaminu.</div>
        </div>

        <button class="btn btn-outline-secondary mt-3 me-2" type="button" (click)="wyczysc(f)">Wyczyść</button>
        <button class="btn btn-primary mt-3" type="submit">Zapisz się</button>
      </form>
      @if (komunikat()) { <div class="alert alert-success mt-3">{{ komunikat() }}</div> }
    </div>

    <div class="col-md-5">
      <div class="card">
        <img [src]="warsztat().obraz" [alt]="'Plakat: ' + warsztat().nazwa" class="card-img-top">
        <div class="card-body">
          <h5 class="card-title">{{ warsztat().nazwa }}</h5>
          <p class="card-text mb-0">{{ imie() || '(imię)' }}, klasa {{ klasa() ?? '?' }}</p>
          <p class="card-text fs-4 fw-bold mb-0">Razem: {{ koszt() }} zł</p>
        </div>
      </div>
      <p class="mt-3">Zgłoszeń: {{ zgloszenia().length }}</p>
    </div>
  </div>
</div>
wynik
„Zapisz się” przy pustym formularzu: imię, klasa, data i regulamin na czerwono
po poprawnym zapisie: „Zapisano: Jan Nowak — Robotyka”, Zgłoszeń: 1, formularz czysty

Zgłoszenie zapisujemy jako kopię danych (nowy obiekt), w tym gotową kwotę koszt: this.koszt(). Gdybyś zapisał tylko id warsztatu, a cena później się zmieniła, stare zgłoszenia pokazywałyby nową cenę. „Wyczyść” nie zapisuje niczego, bo ma type="button".

Zatrzymaj się

W zgłoszeniu jest this.klasa()! z wykrzyknikiem. Co gwarantuje, że w tym miejscu klasa nie jest null?

KROK 4

Tabela zgłoszeń, suma i usuwanie

Ostatni element polecenia: tabela z numeracją, przycisk „Usuń” przy każdym wierszu, komunikat dla pustej listy i suma w stopce. W klasie dochodzi computed z sumą i metoda usun.

src/app/nowa-27-4/demo.ts — pełny plik
import { Component, computed, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

interface Zgloszenie {
    imie: string;
    klasa: string;
    warsztat: string;
    dni: number;
    start: string;
    koszt: number;
}

interface Warsztat {
    id: number;
    nazwa: string;
    cenaZaDzien: number;
    obraz: string;
}

@Component({
    selector: 'app-demo',
    imports: [FormsModule],
    templateUrl: './demo.html'
})
export class Demo {
    readonly warsztaty: Warsztat[] = [
        { id: 1, nazwa: 'Robotyka', cenaZaDzien: 60, obraz: '/img/roboty.svg' },
        { id: 2, nazwa: 'Druk 3D', cenaZaDzien: 75, obraz: '/img/druk3d.svg' },
        { id: 3, nazwa: 'Gry retro', cenaZaDzien: 40, obraz: '/img/turniej.svg' },
    ];
    readonly klasy = ['1TP', '2TP', '3TP', '4TP', '5TP'];

    readonly imie = signal('');
    readonly klasa = signal<string | null>(null);
    readonly warsztatId = signal(1);
    readonly dni = signal(3);
    readonly obiad = signal(false);
    readonly koszulka = signal(false);
    readonly start = signal('');

    readonly warsztat = computed(() => this.warsztaty.find(w => w.id === this.warsztatId())!);

    readonly koszt = computed(() => {
        let suma = this.warsztat().cenaZaDzien * this.dni();
        if (this.obiad()) {
            suma += 15 * this.dni();
        }
        if (this.koszulka()) {
            suma += 40;
        }
        return suma;
    });

    readonly zgloszenia = signal<Zgloszenie[]>([]);
    readonly regulamin = signal(false);
    readonly komunikat = signal('');

    readonly przychod = computed(() => {
        let suma = 0;
        for (const z of this.zgloszenia()) {
            suma += z.koszt;
        }
        return suma;
    });

    usun(nr: number): void {
        this.zgloszenia.update(lista => lista.filter((z, i) => i !== nr));
    }

    zapisz(f: NgForm): void {
        if (f.invalid) {
            f.form.markAllAsTouched();
            this.komunikat.set('');
            return;
        }
        const nowe: Zgloszenie = {
            imie: this.imie().trim(),
            klasa: this.klasa()!,
            warsztat: this.warsztat().nazwa,
            dni: this.dni(),
            start: this.start(),
            koszt: this.koszt(),
        };
        this.zgloszenia.update(lista => [...lista, nowe]);
        this.komunikat.set('Zapisano: ' + nowe.imie + ' — ' + nowe.warsztat);
        this.wyczysc(f);
    }

    wyczysc(f: NgForm): void {
        this.imie.set('');
        this.klasa.set(null);
        this.warsztatId.set(1);
        this.dni.set(3);
        this.obiad.set(false);
        this.koszulka.set(false);
        this.start.set('');
        this.regulamin.set(false);
        f.form.markAsUntouched();
    }
}
src/app/nowa-27-4/demo.html — pełny plik
<div class="p-3">
  <div class="row g-4" style="max-width: 60rem">
    <div class="col-md-7">
      <h4>Zapisy na warsztaty klubu</h4>
      <form #f="ngForm" (ngSubmit)="zapisz(f)">
        <div class="mb-2">
          <label class="form-label" for="imie">Imię i nazwisko *</label>
          <input id="imie" name="imie" class="form-control" required minlength="5"
                 [(ngModel)]="imie" #i="ngModel" [class.is-invalid]="i.invalid && i.touched">
          <div class="invalid-feedback">Podaj imię i nazwisko (co najmniej 5 znaków).</div>
        </div>

        <div class="mb-2">
          <label class="form-label" for="klasa">Klasa *</label>
          <select id="klasa" name="klasa" class="form-select" required
                  [(ngModel)]="klasa" #k="ngModel" [class.is-invalid]="k.invalid && k.touched">
            <option [ngValue]="null" disabled>— wybierz —</option>
            @for (kl of klasy; track kl) { <option [ngValue]="kl">{{ kl }}</option> }
          </select>
          <div class="invalid-feedback">Wybierz klasę.</div>
        </div>

        <p class="form-label mb-1">Warsztat</p>
        @for (w of warsztaty; track w.id) {
          <div class="form-check form-check-inline">
            <input [id]="'w' + w.id" name="warsztat" class="form-check-input" type="radio"
                   [value]="w.id" [(ngModel)]="warsztatId">
            <label class="form-check-label" [for]="'w' + w.id">{{ w.nazwa }}</label>
          </div>
        }

        <label class="form-label d-block mt-2" for="dni">Liczba dni: {{ dni() }}</label>
        <input id="dni" name="dni" type="range" class="form-range" min="1" max="5" [(ngModel)]="dni">

        <div class="form-check">
          <input id="obiad" name="obiad" class="form-check-input" type="checkbox" [(ngModel)]="obiad">
          <label class="form-check-label" for="obiad">Obiad (15 zł / dzień)</label>
        </div>
        <div class="form-check mb-2">
          <input id="koszulka" name="koszulka" class="form-check-input" type="checkbox" [(ngModel)]="koszulka">
          <label class="form-check-label" for="koszulka">Koszulka klubowa (40 zł)</label>
        </div>

        <div>
          <label class="form-label" for="start">Pierwszy dzień *</label>
          <input id="start" name="start" type="date" class="form-control" required
                 [(ngModel)]="start" #s="ngModel" [class.is-invalid]="s.invalid && s.touched">
          <div class="invalid-feedback">Wybierz datę.</div>
        </div>

        <div class="form-check mt-3">
          <input id="reg" name="regulamin" class="form-check-input" type="checkbox" required
                 [(ngModel)]="regulamin" #r="ngModel" [class.is-invalid]="r.invalid && r.touched">
          <label class="form-check-label" for="reg">Akceptuję regulamin warsztatów *</label>
          <div class="invalid-feedback">Wymagana akceptacja regulaminu.</div>
        </div>

        <button class="btn btn-outline-secondary mt-3 me-2" type="button" (click)="wyczysc(f)">Wyczyść</button>
        <button class="btn btn-primary mt-3" type="submit">Zapisz się</button>
      </form>
      @if (komunikat()) { <div class="alert alert-success mt-3">{{ komunikat() }}</div> }
    </div>

    <div class="col-md-5">
      <div class="card">
        <img [src]="warsztat().obraz" [alt]="'Plakat: ' + warsztat().nazwa" class="card-img-top">
        <div class="card-body">
          <h5 class="card-title">{{ warsztat().nazwa }}</h5>
          <p class="card-text mb-0">{{ imie() || '(imię)' }}, klasa {{ klasa() ?? '?' }}</p>
          <p class="card-text fs-4 fw-bold mb-0">Razem: {{ koszt() }} zł</p>
        </div>
      </div>
    </div>

    <div class="col-12">
      <h5>Lista zgłoszeń ({{ zgloszenia().length }})</h5>
      <table class="table table-sm align-middle">
        <thead>
          <tr><th>Lp.</th><th>Uczeń</th><th>Klasa</th><th>Warsztat</th><th>Dni</th><th>Start</th><th>Koszt</th><th></th></tr>
        </thead>
        <tbody>
          @for (z of zgloszenia(); track $index; let nr = $index) {
            <tr>
              <td>{{ nr + 1 }}</td>
              <td>{{ z.imie }}</td>
              <td>{{ z.klasa }}</td>
              <td>{{ z.warsztat }}</td>
              <td>{{ z.dni }}</td>
              <td>{{ z.start }}</td>
              <td>{{ z.koszt }} zł</td>
              <td><button class="btn btn-sm btn-outline-danger" type="button" (click)="usun(nr)">Usuń</button></td>
            </tr>
          } @empty {
            <tr><td colspan="8" class="text-secondary">Nikt się jeszcze nie zapisał.</td></tr>
          }
        </tbody>
        <tfoot>
          <tr><th colspan="6" class="text-end">Razem:</th><th>{{ przychod() }} zł</th><th></th></tr>
        </tfoot>
      </table>
    </div>
  </div>
</div>
wynik po trzech zgłoszeniach
Lp.  Uczeń        Klasa  Warsztat   Dni  Start       Koszt
1    Jan Nowak    2TP    Robotyka   3    2026-11-02  180 zł   [Usuń]
2    Ola Wiśniak  3TP    Druk 3D    2    2026-11-09  150 zł   [Usuń]
3    Piotr Lis    1TP    Gry retro  5    2026-11-16  275 zł   [Usuń]
                                         Razem:      605 zł

Usuń środkowe — numeracja się przesuwa, a suma maleje. Usuń wszystkie — pojawia się wiersz „Nikt się jeszcze nie zapisał”. Gotowe: wróć do polecenia i sprawdź punkt po punkcie. Na egzaminie kryteria oceny to zwykle bezpośrednio przepisane punkty polecenia.

Zatrzymaj się

Usuwamy po numerze wiersza ($index), a w lekcji 13 radziliśmy usuwać po id. Dlaczego tutaj jest to bezpieczne? Kiedy przestałoby być?

ELEMENTY WBUDOWANE

Zestawienie elementów warsztatu

Punkt poleceniaRozwiązanieLekcja
dane warsztatówinterface Warsztat, tablica, find po id13, 21
imię, klasa, data[(ngModel)], select z null, type="date"17, 22, 23
warsztat, dni, dodatkiradio z [value], range, checkboxy19, 20, 21
plakat[src], [alt] z computed07
kosztcomputed z klamrami i return11
walidacjarequired, minlength, #x="ngModel", f.invalid, markAllAsTouched26
zapis i czyszczenie(ngSubmit), kopia danych, markAsUntouched24, 26
tabela, suma, usuwanie@for z @empty, computed z pętlą, filter10, 13
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
Zaczynanie od wyglądu i BootstrapaPół lekcji na kolory, a formularz jeszcze nic nie liczy. Najpierw dane i działanie, styl na końcu.
Ceny wpisane w etykietach HTML i osobno w kodzieZmiana ceny w jednym miejscu rozjeżdża się z drugim. Ceny trzymaj w tablicy danych i wyświetlaj z niej.
Zapis zgłoszenia bez sprawdzenia f.invalidDo tabeli trafiają puste wiersze. (ngSubmit) wywołuje się zawsze.
Brak type="button" przy „Wyczyść”Czyszczenie wysyła formularz — i albo zapisuje zgłoszenie, albo zaczerwienia wszystkie pola.
Brak f.form.markAsUntouched() po zapisiePo wyczyszczeniu formularz od razu świeci na czerwono, bo pola zostały „dotknięte” przy wypełnianiu.
Do listy dopisany obiekt z sygnałami zamiast wartościWiersze tabeli zmieniają się razem z formularzem. Do listy wkładaj nowy obiekt z this.pole().
ZADANIA

Zadania

ZAD 1Rabat za pięć dni★☆☆

Gdy uczeń wybierze 5 dni, koszt warsztatu (bez dodatków) jest niższy o 10%. Pokaż to w rozpisaniu kosztu jako osobną pozycję.

Kryterium sukcesu: dla 5 dni rabat pojawia się w karcie i obniża sumę; dla krótszych pobytów — nie.

Podpowiedź

Dołóż computed z rabatem i odejmij go w koszt.

ZAD 2Lista kontrolna★☆☆

Zanim zaczniesz kolejne zadania, przepisz pięć punktów polecenia jako listę kontrolną w komentarzu HTML na początku demo.html. Przy każdym punkcie zapisz, w którym miejscu kodu jest jego realizacja (plik i nazwa metody, computed albo fragment szablonu).

Kryterium sukcesu: każdy z pięciu punktów ma wskazane miejsce w kodzie.

Podpowiedź

Skorzystaj z tabeli „Zestawienie elementów warsztatu”.

ZAD 3Limit miejsc★★☆

Każdy warsztat ma limit miejsc (dopisz pole miejsca w tablicy: 3, 2, 4). Przy nazwie warsztatu w radio pokaż liczbę wolnych miejsc, a gdy miejsc brak — zablokuj ten przycisk i dopisz „brak miejsc”.

Kryterium sukcesu: liczby wolnych miejsc zmieniają się po każdym zapisie i usunięciu; pełnego warsztatu nie da się wybrać.

Podpowiedź

Wolne miejsca: limit minus this.zgloszenia().filter(z => z.warsztat === w.nazwa).length — w metodzie wolne(w). Zablokowanie: [disabled] na radio.

ZAD 4Sortowanie i filtr tabeli★★☆

Nad tabelą dodaj listę rozwijaną „Pokaż: wszystkie / Robotyka / Druk 3D / Gry retro” i przycisk „Sortuj po koszcie”. Suma w stopce ma dotyczyć tylko pokazanych wierszy.

Kryterium sukcesu: filtr zmienia zawartość tabeli i sumę; sortowanie układa wiersze od najdroższego; usuwanie nadal usuwa właściwe zgłoszenie.

Podpowiedź

Wiersze z computed (filtr i kopia posortowana). Usuwanie po numerze przestaje być bezpieczne — dodaj zgłoszeniom pole id i usuwaj po nim.

ZAD 5Edycja zgłoszenia★★★

Przy każdym wierszu dodaj przycisk „Popraw”, który przepisuje dane zgłoszenia z powrotem do formularza i zmienia napis głównego przycisku na „Zapisz zmiany”. Zapis zastępuje stare zgłoszenie zamiast dodawać nowe. Dodaj też przycisk „Anuluj edycję”.

Kryterium sukcesu: poprawione zgłoszenie zmienia się w tym samym wierszu tabeli, a liczba zgłoszeń się nie zmienia.

Podpowiedź

Sygnał edytowanyId: number | null. W zapisie: map(z => z.id === id ? nowe : z). Warsztat odtworzysz po nazwie: find(w => w.nazwa === z.warsztat).

ZAD 6Twoja wersja zadania★★★

Na wzór tej lekcji zbuduj od zera aplikację „Zamówienie obiadów w stołówce”: imię, klasa, zestaw (radio z ceną i obrazkiem), liczba dni (suwak), napój (checkbox), dzień rozpoczęcia (date), zgoda rodzica. Tabela zamówień z sumą i usuwaniem.

Kryterium sukcesu: wszystkie punkty działają; zamówienia trafiają do tabeli tylko po poprawnej walidacji.

Podpowiedź

Zacznij od wypisania elementów polecenia i od pliku demo.ts z danymi. Szablon buduj w czterech krokach jak w tej lekcji.

PODSUMOWANIE

Co trzeba zapamiętać

  • Zadanie z formularzem rozwiązujesz w krokach: dane → kontrolki z podglądem → wyliczenia → walidacja i zapis → lista.
  • Dane opisujesz interfejsem i trzymasz w tablicy; szablon tylko je wyświetla.
  • Po każdym kroku aplikacja ma działać.
  • Zapisane zgłoszenie to nowy obiekt z kopią wartości, w tym wyliczonej kwoty.
  • Na koniec sprawdzasz działającą aplikację punkt po punkcie z poleceniem.

Sprawdź się: masz 60 minut na zadanie z formularzem, wyliczeniem i listą. W jakiej kolejności będziesz pracować i co zrobisz w ostatnich 10 minutach?

Dokumentacja: angular.dev — formularze oparte na szablonie.

Postęp zapisuje się w tej przeglądarce.