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.
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
computedi 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.
Zadanie: zapisy na warsztaty
Klub informatyczny organizuje warsztaty. Przygotuj aplikację zapisów, która:
- 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;
- pokazuje plakat wybranego warsztatu i aktualny koszt;
- nie przyjmuje zgłoszenia bez wymaganych pól i bez akceptacji regulaminu, a błędy pokazuje przy polach;
- po zapisie dopisuje zgłoszenie do tabeli i czyści formularz;
- 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).
Jak podejść do takiego zadania
| Krok | Co robisz |
|---|---|
| 1. Przeczytaj i wypisz | Zanim 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 dane | Interfejs 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 krokach | Po każdym kroku uruchom aplikację i sprawdź. Lepiej oddać działające cztery punkty z pięciu niż pięć niedziałających. |
| 4. Sprawdź kryteria | Wróć 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.
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.
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())!);
}
<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>
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.
Wyliczenie kosztu
Dochodzi jeden computed w klasie i rozpisanie kosztu w karcie.
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;
});
}
<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>
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?
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.
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();
}
}
<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>
„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?
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.
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();
}
}
<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>
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ć?
Zestawienie elementów warsztatu
| Punkt polecenia | Rozwiązanie | Lekcja |
|---|---|---|
| dane warsztatów | interface Warsztat, tablica, find po id | 13, 21 |
| imię, klasa, data | [(ngModel)], select z null, type="date" | 17, 22, 23 |
| warsztat, dni, dodatki | radio z [value], range, checkboxy | 19, 20, 21 |
| plakat | [src], [alt] z computed | 07 |
| koszt | computed z klamrami i return | 11 |
| walidacja | required, minlength, #x="ngModel", f.invalid, markAllAsTouched | 26 |
| zapis i czyszczenie | (ngSubmit), kopia danych, markAsUntouched | 24, 26 |
| tabela, suma, usuwanie | @for z @empty, computed z pętlą, filter | 10, 13 |
Zanim utkniesz
| Zapis | Problem |
|---|---|
| Zaczynanie od wyglądu i Bootstrapa | Pół 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 kodzie | Zmiana 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.invalid | Do 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 zapisie | Po 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ści | Wiersze tabeli zmieniają się razem z formularzem. Do listy wkładaj nowy obiekt z this.pole(). |
Zadania
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.
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”.
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.
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.
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).
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.
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.