# WALKTHROUGH — implementacijski zapisnik (Faza 0–5)

Ta dokument je namenjen AI modelom (in ljudem), ki nadaljujejo delo na tem projektu.
Zapisuje **kaj je bilo zgrajeno, zakaj točno tako, in na kaj paziti** — tako da
naslednja seja ne mora znova izpeljati odločitev iz `specification.md` ali znova
naleteti na že rešene pasti. `CLAUDE.md` ostaja kratek operativni vodnik (ukazi,
tech stack); ta datoteka je dolg, natančen sprehod skozi implementacijo.

Avtoritativni vir resnice za podatkovni model/validacije/pravna besedila je in
ostaja `specification.md`. Odobreni fazni plan je v
`/home/andrej/.claude/plans/vast-exploring-sutherland.md` (Faza 0–6; Faza 0–5 je
izvedena, Faza 6 — zaključni pregled kakovosti — ni bila posebej zahtevana po
Fazi 5).

## Stanje: Faza 0–5 zaključene

- Faza 0: odvisnosti, Filament panel, Tailwind tema.
- Faza 1: DB shema + model + EmsoRule.
- Faza 2: Filament uvoz (CSV/Excel → `customers`).
- Faza 3: prijava stranke (brez Fortify/Breeze, custom session-based auth).
- Faza 4: `CustomerForm` Livewire komponenta (segmentacija ZL/ZN, validacija, UX A/B/C, soglasja, UI).
- Faza 5: Filament izvoz (rumeno barvanje spremenjenih celic).
- Faza 6 (zaključni `composer test` pregled celotnega plana) ni bila izrecno zahtevana — ni nujno opravljena kot ločen korak, a vsak fazni checkpoint je `composer test` že preverjal.

## Privzete odločitve, kjer je specifikacija dvoumna

Te odločitve so bile sprejete BREZ izrecne potrditve naročnika (specifikacija jih
pušča odprte) — če se izkažejo za napačne, so spremembe lokalizirane:

1. **EMŠO**: polni JMBG/EMŠO checksum algoritem (ne le `digits:13`) — `app/Rules/EmsoRule.php`.
2. **PEP legacy mapiranje** (`app/Imports/CustomersImport.php::mapPepStatus()`): stari Excel uporablja `Da`/`Ne`, novi model trobesedno (`NISEM`/`SEM`/`DRUZINSKI_CLAN_ALI_SODELAVEC`). Privzeto: `Ne`→`NISEM`, `Da`→`SEM`. **To potrebuje potrditev naročnika** — če se izkaže narobe, popravek je en `match()` v enem mestu.
3. **Države** (`drzava`, `drzava_izdajatelj`, `drzava_davcnega_rezidentstva`): prosto besedilo, ne `<select>` z ISO seznamom. Možna kasnejša izboljšava, ni v obsegu.
4. **Geslo hash**: deterministični `hash_hmac('sha256', $plain, config('app.key'))`, NE `Hash::make()`/bcrypt. Razlog: prijava išče `WHERE password = ?` — bcrypt generira nov salt vsakič in se ne da iskati po vrednosti. Glej `Customer::hashPassword()`.
5. **Geslo v izvozu**: stolpec `Geslo` v izvoženem Excelu je VEDNO prazen (`null`). Čisto besedilo gesla se nikoli ne shrani nikjer (samo HMAC hash), zato ga ni mogoče rekonstruirati za izvoz — to je namerna varnostna odločitev, ne napaka.
6. **Nova polja brez mesta v izvirnem Excelu** (`naslov_za_obvescanje`, `drugo_osebni_dokument`, `ameriski_tin`): dodana kot zadnji podatkovni stolpci pred `Opombe`, tako pri uvozu (na koncu `FIELD_KEYS`) kot pri izvozu (na koncu `DATA_COLUMNS`), da ostane vrstni red izvirnih 31 stolpcev nedotaknjen.
7. **Datumski format**: vsa datumska polja (`datum_rojstva`, `datum_izdaje`, ...) so shranjena in validirana kot **niz `'d.m.Y'`** (`date_format:d.m.Y`), NE generični Laravel `date` rule in NE `<input type="date">`. Razlog: PHP-jevo razčlenjevanje pik-ločenih datumov je dvoumno za dneve ≤12 (npr. `"03.04.1995"` se lahko razčleni narobe) — generičnemu parsanju se izogibamo povsod.
8. **Per-field "spremenjeno" stanje se NIKOLI ne persistira.** Vedno se izračuna sproti kot `original_data[$key] !== current_data[$key]` (tako v izvozu kot v `CustomerForm::isOriginalValue()`). Ni dodatnega stolpca/JSON-a, ki bi to spremljal. To je bilo izrecno vprašano in potrjeno s strani uporabnika med razvojem.

## Arhitektura — kratek file-map

```
app/Models/Customer.php                          — model, hashPassword(), casts (JSON/bool/datetime)
app/Rules/EmsoRule.php                            — EMŠO/JMBG checksum
app/Imports/CustomersImport.php                   — Filament uvoz (Maatwebsite\Excel ToModel)
app/Exports/CustomersExport.php                   — Filament izvoz (FromCollection + WithStyles)
app/Filament/Pages/ImportCustomers.php            — admin stran za uvoz
app/Filament/Resources/Customers/...              — read-only admin pregled + akcija "Izvozi spremembe" (v ListCustomers.php)
app/Http/Controllers/CustomerLoginController.php  — create/store/destroy (prijava brez Auth guard)
app/Http/Middleware/EnsureCustomerIsAuthenticated.php — varuje /obrazec
app/Livewire/CustomerForm.php                     — osrednja komponenta obrazca
resources/views/livewire/customer-form.blade.php  — UI obrazca (Tailwind, a11y orodna vrstica)
resources/views/components/text-field.blade.php   — ponovljiva UX A/B/C text-field komponenta
resources/views/customer/login.blade.php          — prijavna stran
routes/web.php                                    — /, /prijava, /odjava, /obrazec
database/migrations/2026_06_19_084607_create_customers_table.php
```

## Podatkovni model (`customers` tabela)

Kontrolni stolpci: `kupec_dobavitelj`, `stevilka_kup_do`, `st_police_rent`,
`vloga_osebe` (`SKLEN`/`ZAVAR`/`PREJEMNIK RENTE`), `password` (unique, HMAC hash).

`original_data`/`current_data`: JSON, oba inicializirana enako ob uvozu. **31
ključev** je definiranih v `CustomersImport::FIELD_KEYS` (ta vrstni red = stolpci
6–36 v izvirnem Excelu/CSV-ju). Dodatna 3 nova polja
(`naslov_za_obvescanje`, `drugo_osebni_dokument`, `ameriski_tin`) obstajajo samo v
`current_data` po prvi shranitvi obrazca — ob uvozu jih ni, kar mora izvozna logika
obravnavati kot "manjkajoč ključ v `original_data` = sprememba" (glej
`CustomersExport::styles()`, `array_key_exists`).

`is_updated` (bool), `opombe` (text, NI del JSON primerjave/izvoza-barvanja),
`updated_at_portal` (datetime, kdaj je stranka nazadnje shranila obrazec — ločeno
od standardnega `updated_at`).

## Uvoz (`CustomersImport`)

- **Mapiranje stolpcev je POZICIJSKO, ne po besedilu glave** (vrstica 1 ima
  pokvarjeno glavo prvega stolpca v realnih izvozih — Excel formula ostanek).
  `startRow()` vrne `2`. Stolpci 0–4 (`Kupec/Dobavitelj`, `Številka kup./do`,
  `Št. police/rent`, `Vloga osebe`, `Geslo`) gredo v namenske kolone; stolpci 5+ se
  mapirajo na `FIELD_KEYS` po indeksu (`$row[$index + 5]`).
- CSV nastavitve: `delimiter => ';'`, `input_encoding => 'UTF-8'` (`WithCustomCsvSettings`).
- Podvojena gesla se zavrnejo na dveh nivojih: znotraj iste datoteke
  (`$seenPasswordHashes` v `rules()`) in proti že obstoječim zapisom v bazi
  (`Customer::query()->where('password', $hash)->exists()`). Obe se poročata kot
  "skipped failure", ne kot fatalna napaka uvoza.
- `WithChunkReading`/`WithBatchInserts` (`chunkSize`/`batchSize` = 500) zaradi
  ~20.000 vrstic v produkciji.
- **Verzija `maatwebsite/excel` je pribita na `^3.1`, NE `^4.x`** — v4 ima
  tranzitivno odvisnost, ki zahteva PHP 8.4+, kar bi pokvarilo CI lane na PHP 8.3.
  Ne posodabljaj brez preverjanja CI matrike (`.github/workflows/tests.yml`).

## Prijava stranke

Brez Fortify/Breeze/Jetstream, brez `Auth` guard. `CustomerLoginController::store()`
hashira vneseno kodo (`Customer::hashPassword()`) in poišče ujemajočo vrstico z
`WHERE password = ?`. Ob neuspehu vedno ista generična napaka "Neveljavna koda."
(ne razkriva, ali koda obstaja). `session()->regenerate()` + `customer_id` v sejo.
`throttle:customer-login` rate limiter (5/min/IP, definiran v
`AppServiceProvider::configureRateLimiting()`). `EnsureCustomerIsAuthenticated`
middleware varuje `/obrazec` — preusmeri na `/prijava`, če seja nima `customer_id`.

## `CustomerForm` — osrednja komponenta

`app/Livewire/CustomerForm.php` je polnostranska Livewire v4 komponenta,
registrirana direktno v `routes/web.php` (`Route::get('/obrazec', CustomerForm::class)`).

### Segmentacija ZL/ZN

`isSklen()` (`vloga_osebe === 'SKLEN'`) krmili tako blade pogojnike (dolg/kratek
obrazec) kot `rules()` (kateri validacijski set se uporabi). Polja, ki niso del
aktivnega modula, se NE validirajo in se NE prepišejo — `save()` z
`array_merge`-style spread (`[...$this->customer->current_data, ...$validated['current_data']]`)
ohrani vse neobjavljene/neurejene ključe nedotaknjene.

### UX zahteva A — ohranjanje vnosa ob napaki neuspešne validacije

Privzeto vedenje Livewire-a: če `validate()` vrže izjemo, public properties OSTANEJO
take, kot so bile nastavljene (mount() se ne pokliče znova). Ni bilo treba ničesar
posebej kodirati za to zahtevo — samo NE delati ničesar, kar bi properties resetiralo
po neuspešni validaciji (npr. ne klicati `mount()` ali ponovnega nalaganja iz baze v
`save()` pred `validate()`).

### UX zahteva B — placeholder/touched mehanizem

`PLACEHOLDER_KEYS` (24 text/date polj — izrecno BREZ radio/checkbox polj, ki vedno
kažejo dejansko izbrano vrednost). Tok:

1. `mount()`: za vsak `PLACEHOLDER_KEYS` ključ, če `current_data[$key]` ni prazen,
   property se postavi na `''`. Resnična vrednost je vidna SAMO kot HTML
   `placeholder` atribut (`resources/views/components/text-field.blade.php` izračuna
   `$existingValue` direktno iz `$customer->current_data`, ne iz blanked property).
2. `touch($key)`: ob prvem fokusu polja (`wire:focus="touch('...')"` v
   `text-field.blade.php`) se resnična vrednost prenese iz `customer->current_data`
   v `current_data` property, in `touched[$key] = true` se zabeleži permanentno za
   to sejo komponente.
3. `save()` → `resolveUntouchedFields()` (klicano PRVO, PRED `sanitize()` in
   `validate()`): za vsak `PLACEHOLDER_KEYS` ključ, ki ni bil `touch()`-an, prepiše
   prikazano (prazno) vrednost z dejansko vrednostjo iz baze. To MORA biti pred
   validacijo, ker pogojna pravila (npr. `razlog_ce_tin_ne_obstaja` requiredIf TIN
   praznega) bi se sicer sprožila narobe samo zato, ker je polje vizualno prazno.

**Pomembna posledica za teste**: `Livewire::test()->set('current_data.key', ...)`
NE pokliče `touch()` samodejno. Če test nastavi vrednost UX-B polja brez
predhodnega `->call('touch', 'key')`, jo `resolveUntouchedFields()` tiho povrne na
izvirno vrednost iz baze PRED validacijo — videti bo, kot da se `set()` "ni prijel".
Vedno najprej `->call('touch', $key)`, šele nato `->set('current_data.'.$key, ...)`.
Glej obstoječe teste v `tests/Feature/CustomerFormTest.php` za vzorec.

### UX zahteva C — vizualna oznaka spremenjenih polj

`isOriginalValue(string $key): bool` — primerja efektivno prikazano vrednost
(upoštevajoč touched/placeholder stanje) z `customer->original_data[$key]`.
`text-field.blade.php` to uporablja za rumeno obrobo/oznako "Spremenjeno". Reaktivno
zaradi `wire:model.live.debounce.500ms` na vhodnih poljih (ne `wire:model` brez
`.live` — to bi zahtevalo nepovezan network roundtrip, da bi se highlighting sprožil).

Izjema: radio gumbi, ki krmilijo conditional blade bloke (PEP, vrsta osebnega
dokumenta, davčna kategorija), uporabljajo `wire:model.live` BREZ debounce — needbamo
počakati 500ms, da se pogojni blok prikaže/skrije.

### Validacija (`rules()`)

Skupna pravila (oba modula): `priimek_in_ime`, `naslov`, `datum_rojstva`
(`date_format:d.m.Y`+`before:today`), `emso` (+ `EmsoRule`), `davcna_st`
(`digits:8`), `elektronski_naslov`, `telefonska_st`, `soglasam_s_trzenjem`,
3 deklaracijska polja (`accepted`), `opombe`.

Samo-SKLEN pravila: `kraj_rojstva`, `drzavljanstvo`,
`vrsta_osebnega_dokumenta` (`in:osebna izkaznica,potni list,drugo`),
`drugo_osebni_dokument` (`Rule::requiredIf` ko je vrsta `drugo`), dokumentna
polja, `datum_prenehanja` (`after:current_data.datum_izdaje`),
`politicno_izpostavljena_oseba` (`in:NISEM,SEM,DRUZINSKI_CLAN_ALI_SODELAVEC`),
PEP funkcija-polja (`Rule::requiredIf($pepActive)` kjer
`isPepActive()` preverja `SEM`/`DRUZINSKI_CLAN_ALI_SODELAVEC`),
`razlog_ce_tin_ne_obstaja` (requiredIf TIN praznega),
`ameriski_tin` (requiredIf `davcna_rezidentstvo_kategorija === 'US_PERSON'`).

Sanitizacija (`strip_tags`) se zgodi PO `resolveUntouchedFields()`, PRED
`validate()` — torej tudi povrnjene "untouched" vrednosti gredo skozi strip_tags
(no-op za že čiste podatke iz baze, a konsistentno).

Konsenzna polja (`CONSENT_KEYS`, 3 kos) se po validaciji (kjer so `bool` zaradi
`accepted` rule) ročno pretvorijo v string `'1'`/`'0'` PRED merge v
`current_data` — ker je vse ostalo v JSON-u shranjeno kot string, ne mixed tipi.

### `davcna_rezidentstvo_kategorija` — pomožna (ne-perzistentna) property

Ni JSON ključ — obstaja samo za krmiljenje UI radio izbire (Slovenija / ZDA /
drugo) in posredno piše v `current_data.drzava_davcnega_rezidentstva`. Ob
`mount()` se izpelje iz obstoječe vrednosti (`inferTaxResidencyCategory()`), ob
spremembi (`updatedDavcnaRezidentstvoKategorija`) prepiše `drzava_davcnega_rezidentstva`
na `'Slovenija'`/`'ZDA'`/ohrani-če-je-bilo-nekaj-drugega.

## Izvoz (`CustomersExport`)

`FromCollection` + `WithHeadings` + `WithStyles` (NE `FromQuery` — potreben je
dostop do polnega modela v `styles()` za diff). Samo `is_updated = true` zapisi.
`DATA_COLUMNS` const (34 vnosov: 31 izvirnih + 3 nova polja na koncu) je EDINI vir
resnice za stolpčni vrstni red — uporabljen tako v `collection()` (vrstni red
vrednosti) kot v `headings()` (vrstni red glav) kot v `styles()` (indeks stolpca za
barvanje). `FIRST_DATA_COLUMN_INDEX = 6` (stolpci A–E so kontrolni: Kupec/Dobavitelj,
Številka, Št. police, Vloga osebe, Geslo).

`Geslo` stolpec je VEDNO `null` (glej odločitev #5 zgoraj). `Opombe` je zadnji
stolpec, NI del barvne primerjave. Barvanje: `original_data[$key] !== current_data[$key]`
po `array_key_exists` (manjkajoč ključ v `original_data` šteje kot `null`, torej kot
sprememba, če `current_data` ni `null`) → `Fill::FILL_SOLID` + `FFFF00`.

Filament akcija "Izvozi spremembe" je v `ListCustomers.php::getHeaderActions()`,
kliče `Excel::download(new CustomersExport, 'posodobljeni-podatki-{datetime}.xlsx')`.

## Filament admin posebnosti

- `User` model implementira `FilamentUser` (`canAccessPanel(): true`) — BREZ tega
  vsak Filament route v ne-`local` okolju vrne 403 (varnostni default v Filament v5).
- `CustomerResource` je READ-ONLY (ni Create/Edit strani) — stranke se urejajo samo
  prek javnega obrazca ali direktno v bazi, admin samo pregleduje in uvaža/izvaža.
- `CustomerInfolist` uporablja `KeyValueEntry` za `current_data`/`original_data`
  (ne poskuša razčleniti 31+ ključev v posamezna infolist polja).

## Testna pokritost

- `tests/Unit/EmsoRuleTest.php`, `tests/Unit/CustomerHashPasswordTest.php`
- `tests/Feature/CustomersImportTest.php` — pokriva tudi podvojene Gesla
- `tests/Feature/CustomerLoginTest.php`
- `tests/Feature/CustomerFormTest.php` — 11 testov: segmentacija, EMŠO napaka,
  pogojne zahteve, PEP, US_PERSON/ameriski_tin, ZN kratek modul ohrani ZL-polja,
  UX-B blank-a-ohranjeno, UX-B touched-lahko-izbriše, UX-A ohrani vnos ob napaki,
  UX-C `isOriginalValue` odraža spremembe.
- `tests/Feature/CustomersExportTest.php` — 5 testov, generirajo in berejo NAZAJ
  prave `.xlsx` datoteke prek `IOFactory::load()` (ne mock).
- `tests/Feature/FilamentCustomerAdminTest.php` — vključno z
  `test_admin_can_run_export_action` (`Livewire::test(ListCustomers::class)->callAction('export')`).

`composer test` (Pint + PHPStan level 7 + PHPUnit) mora ostati zelen — CI matrika
je PHP 8.3/8.4/8.5.

## Znane omejitve / odprto za naslednjo sejo

- PEP `Da`/`Ne` → `SEM`/`NISEM` mapiranje (odločitev #2 zgoraj) ni bilo formalno
  potrjeno z naročnikom.
- A11y orodna vrstica (kontrast/velikost pisave) je osnovna Alpine implementacija,
  ne polni avrio.si standard (po dogovoru izven obsega, glej plan Faza 4 točka 10).
- V tem sandboxu ni bilo na voljo orodja za avtomatizacijo brskalnika (brez
  `chromium-cli`/Playwright/Xvfb) — UI je bil preverjen prek Livewire/PHPUnit feature
  testov in `curl`/`tinker` ročnih preverjanj zoper pravi DDEV strežnik, NE prek
  dejanskega vizualnega screenshot pregleda. Če se v prihodnji seji odpre dostop do
  brskalnika, je smiselno na novo ročno preveriti obe veji obrazca (SKLEN/ZAVAR) v
  brskalniku.
- Faza 6 (poseben zaključni pregled) ni bila izrecno izvedena kot ločen korak po
  Fazi 5 — vsak fazni checkpoint je `composer test` že sproti preverjal, a celovit
  končni `composer test` pregon po vseh fazah skupaj velja preveriti pred prvo
  produkcijsko uvedbo.
