Commit Graph

5 Commits

Author SHA1 Message Date
4dc27bf212 Die Kachel verwies auf eine Adresse, die die Liste nicht lesen konnte
Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".

Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.

Zwei Anmerkungen von Max:

  * Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
    Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
    change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
    vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
    Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
    Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
    vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
    naechsten Speichern von selbst.
  * "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
    Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
    bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
    jede Korrektur am Personenkreis scheitern, solange der Grad noch
    dransteht.
2026-09-16 22:06:38 +02:00
6681dcda77 Flag people who cannot simply be dismissed
Works council members, expectant mothers, parents on leave, registered
disabled employees, apprentices — each has its own rules that come
before a dismissal. The tool does not judge whether one is lawful, but
it must not stay quiet about it either, and looking it up on the
contract tab is exactly the step that gets skipped under time pressure.

So: a checkbox, an optional end date, and a red warning at the top of
the termination panel naming the date — or saying plainly that no end
was recorded. It shows for a no-show too; the protection runs from the
start of the contract, not the first day worked.

The date is optional on purpose. A works council mandate has a known
end, a pregnancy does not, and a mandatory field would force an invented
number. A constraint says only what cannot be: an end date without the
flag, which would be a leftover nobody could interpret.

The field goes the whole way through — hire, data change, contract
sheet, export, report criteria (as a yes/no and as a date range), and
the import. A field that exists in one screen and not the next is how
people stop trusting the numbers.

Terminating is now offered for planned entries as well, labelled "Nicht
angetreten", with No Show preselected. Without it a person who never
turned up stayed a planned entry forever, since nothing else can end
one.

One finding worth recording: tsc has been reporting success on a broken
program. A generated file under .next got corrupted when a build ran
against a live dev server, and its syntax errors suppressed semantic
checking everywhere else — two genuine type errors in this change went
unreported until I typechecked with .next excluded. The file is removed
and the ordinary typecheck is meaningful again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:00 +02:00
c23df08648 Ask for the optional things separately, and stop claiming numbers are issued
A round of interface corrections from use, plus one schema change behind
them.

The private email address is now optional. It was NOT NULL — the wrong
default for a private detail: someone without one had to invent one, and
invented data in a personnel file is worse than missing data. Both fields
are relabelled to say whose they are, "Private E-Mail" and "Private
Telefonnummer", because the company address does not exist until the person
starts. Uniqueness stays; several NULLs coexist in a Postgres unique index,
which is exactly what is wanted.

The summary step still promised that "Personalnummer und
Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any
more. Removed rather than reworded — the step lists what was entered, and a
banner claiming otherwise is worse than no banner.

Dependents move into the wizard as step three, optional. They can only be
attached after the hire, because add_employee_dependent needs an id that
does not exist while the form is open, so they are collected in the draft
and written afterwards. That puts them outside the transaction the person is
created in: if one fails the person still exists, so the message names who
is missing instead of failing silently, and the SV number is checked in the
step rather than after.

The emergency contact gets its own step, second to last, and its
relationship is a dropdown of the common ones rather than free text —
otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can
be counted. "Sonstige" is there because a closed list would otherwise be
presumptuous.

On the master-data tab it now sits below the dependents rather than above:
both are people around the employee, and this is the one you reach for in a
hurry.

Returning from a long absence: the choice read "unverändert", which made you
open the file to find out what you were agreeing to. It now reads "Wie vor
Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is
"Reduziert" — whose hours field starts empty on purpose. A number already
filled in gets confirmed rather than read off the agreement it comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:47:04 +02:00
9d754359e0 Enter the personnel number, tell the two kinds of company car apart, record who to call
Three requests from use, one of which changes the schema's mind about
something.

The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.

Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.

Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.

Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.

Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:

  - The first CHECK on the car type would have permitted exactly the case it
    was written against. `art in (…)` yields NULL rather than false when the
    column is null, and a CHECK counts NULL as satisfied. It needs an
    explicit `is not null` in front.
  - The constraint was added before the backfill, so it rejected every
    existing row with a car.

Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.

hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.

Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:27:00 +02:00
3926f1bb80 Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.

Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.

Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.

Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:

  - weekly_hours is bound to employment type by a CHECK constraint: full time
    is exactly 38.5. The import reached the insert and was rolled back. Now
    it is a finding with a row number.
  - Titles are restricted to a fixed list by another CHECK. Same treatment.
  - setval() needs UPDATE on the sequence, which `usage, select` does not
    grant. Migration 20260803120000 adds it; until it is applied, an import
    containing people will fail at the last step and take itself back.

I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.

Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:33 +02:00