Compare commits

...
35 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 42ceb33844 feat: allow correcting honey measurements and queen evaluations
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s
Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:15:54 +02:00
Nick SzaunigandClaude Opus 5 b0e05daa42 feat: open the last locked fields for editing, keeping their history intact
Four fields were still frozen because changing them would have bypassed the
record that makes the change traceable, and a user's login data could not be
corrected at all. Locking a field is the wrong trade: a wrong value stays in
the system forever. The fix is to let the edit write the same history entry the
dedicated endpoint writes.

- A colony's apiary is editable through PATCH and writes the same
  ColonyApiaryHistory entry POST /colonies/:id/move writes, so the location
  history never skips a stand the colony was recorded on.
- A honey batch's storage container is editable through PATCH and writes the
  same HoneyBatchRelocation entry POST /honey-batches/:id/relocate writes.
- A stock item's quantity is editable and books the difference as an ADJUSTMENT
  movement, so the stored level stays the sum of its movements.
- A role's key is editable (system roles were already rejected outright by
  RolesService.update, which keeps the seed's ADMIN lookup intact).
- A user's e-mail - the login identity - is editable. A typo there previously
  locked the account out for good.
- New POST /users/:id/reset-password lets an administrator set someone else's
  password: there is no self-service reset flow, so a forgotten password was
  the one thing no edit could repair. It revokes every refresh token and clears
  the permission cache exactly as a self-service change does, refuses to act on
  your own account (that goes through /profile/password, which proves ownership
  by asking for the current one), and never writes the password to the log.

Frontend:
- Users had no edit UI at all, although PATCH /users/:id existed. New
  EditUserDialog covers name, e-mail, role and an optional new password.
- Colony, honey batch and stock item forms gained the newly editable fields,
  each with a note saying the correction is recorded as a move, a relocation
  or a stock adjustment.
- The role dialog gained the key field.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:13:00 +02:00
Nick SzaunigandClaude Opus 5 bee2199647 feat: make the parent relation of every record correctable after entry
An inspection, treatment, feeding, varroa measurement or swarm control check
filed against the wrong colony could not be repaired: the colony was frozen at
creation time, and with no delete endpoint either, the only remedy was leaving
a wrong record in the colony's history. The same held for a harvest booked
against the wrong apiary or with a colony forgotten, for an extraction linked
to the wrong harvests, and for a honey batch whose number or source extraction
was mistyped.

Backend:
- The five colony-scoped update DTOs now cover the full create DTO, colonyId
  included.
- Harvest update accepts apiaryId and colonyIds, extraction update accepts
  harvestIds. Both validate the ids first and replace the whole join-row set
  inside the existing transaction; the join rows carry no data of their own,
  so replacing beats diffing.
- Honey batch update accepts batchNumber, extractionId and status. Status was
  previously only ever set to BOTTLED by the bottling module, so SOLD and
  CONSUMED were unreachable through the API.
- Colony and storage container codes, and a task's relatedType/relatedId, are
  editable.
- apiaryId on a colony and storageContainerId on a honey batch stay out of
  PATCH on purpose: both have dedicated endpoints that also write the history
  entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain
  depends on.
- AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a
  404. Services write straight against the foreign keys with no application
  level existence check, so before this a stale or mistyped relation id came
  back as a bare 500 and filled the log with fake server errors.

Frontend:
- New shared fields: ColonySelectField (used by all five colony-scoped edit
  dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField.
- Re-filing a record invalidates the lists of both the colony it left and the
  one it joined, otherwise one of the two stays stale.
- The harvest dialog gained an apiary select and a colony checklist that keeps
  already-linked colonies visible even when they moved or were dissolved; the
  honey batch dialog gained batch number, source extraction and status.
- Extraction and harvest forms refuse to submit an empty relation set instead
  of letting the backend reject it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:01:05 +02:00
Nick SzaunigandClaude Opus 5 2b15795a60 feat: make every field of a bottling batch correctable and deletable
A bottling batch could only be corrected in four of its seven fields after
creation: honey batch, jar size and jar count were frozen at creation time,
an assigned product could not be cleared again, and there was no way to
remove a record entered by mistake at all. A mistyped jar count is a
data-entry error, not a fact of production, so it has to stay correctable.

Backend:
- UpdateBottlingBatchDto now covers every field. `productId: null`
  explicitly clears the product assignment; omitting it leaves it alone.
- BottlingService.update rebalances the affected honey batch amounts:
  the previously bottled amount is given back before the corrected amount
  is taken again, so a correction never double-charges the source batch.
  Moving a batch to a different honey batch credits the old one and debits
  the new one in the same transaction.
- New DELETE /bottling-batches/:id gives the bottled amount back to the
  honey batch and keeps the full record in the audit log (action DELETE,
  beforeData). The honey batch status is deliberately left untouched - the
  status it held before being bottled is not recoverable here.
- New permission bottling:delete, checked server-side. The catalog sync
  grants it to system roles on the next boot.

Frontend:
- The edit dialog exposes all fields, offers "Kein Produkt" to clear the
  assignment, keeps a discontinued product selectable so opening the dialog
  cannot silently drop it, and shows backend errors inline.
- The form is mounted only while the dialog is open, so a cancelled edit
  never leaks its values into the next one.
- New delete button behind a confirmation that names the amount credited
  back and warns that the public origin page for the lot number stops
  resolving.
- Edit and delete are additionally hidden without the matching permission.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:50:36 +02:00
Nick SzaunigandClaude Sonnet 5 961ca04a40 fix: translate duplicate-code errors into clean 409 responses
No code anywhere caught Prisma's P2002 (unique constraint violation).
Colony/apiary/queen codes, product SKUs, batch numbers, and equipment
inventory numbers are all @unique DB columns with no application-level
duplicate check ahead of the insert, relying on the constraint itself
- so a beekeeper reusing a code by mistake got a bare 500 "Internal
server error" with zero indication what went wrong, and the incident
polluted server logs as a fake error rather than a routine 4xx.

AllExceptionsFilter now recognizes P2002, returns 409 with a specific
German message naming the conflicting field when it's a single known
column (falling back to a generic duplicate message otherwise), and
logs it at warn level like any other client-side rejection.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:47:28 +02:00
Nick SzaunigandClaude Sonnet 5 dfb4de81f8 fix: surface remaining silent mutation failures
Four more mutations had no error feedback: equipment condition change,
task completion, and notification mark-as-read all failed silently.
More importantly, the offline sync badge computed a result.failed
count from syncPendingInspections() but never displayed it - an entry
rejected by the server (e.g. a validation rule) would sit in the
offline queue indefinitely with zero indication to the beekeeper why
it never syncs. Both the failed-count and outright sync-mutation
rejection now surface a toast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:41:36 +02:00
Nick SzaunigandClaude Sonnet 5 997dadfaaa fix: surface specific business-rule error messages to beekeepers
Backend business-rule guards (inactive apiary blocking colony
create/move, active colonies blocking apiary deactivation, negative
quantity on IN/OUT stock movements, unavailable queen blocking colony
assignment, inactive colony blocking split/merge/move) threw English,
UUID-laden exception text that the frontend either discarded in favor
of a generic message or - in colony-actions.tsx's move/split/merge/
assign-queen dialogs - didn't handle at all, so the guard fired with
zero visible feedback. Translated the messages to plain German and
wired the affected mutations to display them, so a beekeeper sees the
actual reason a save was rejected instead of silence or "failed".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:38:35 +02:00
Nick SzaunigandClaude Sonnet 5 de5df72491 fix: handle document download and clipboard copy failures
Two unguarded async calls silently swallowed errors: document download
used a bare "void downloadDocument(...)" with no catch, so a failed
download (network error, expired session) did nothing visible; the
webhook secret copy button had no try/catch around
navigator.clipboard.writeText, which can throw on denied permission or
an insecure context. Both now show a toast on failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:31:15 +02:00
Nick SzaunigandClaude Sonnet 5 4ec1468eb4 fix: add error feedback to PDF/CSV export mutations
Export mutations (annual report PDFs, colony/honey-batch CSV exports)
had no onError handler, so a failed download (network error, expired
session) silently did nothing with zero user feedback. Same silent-
failure class as the earlier onError sweep on entity-save mutations,
here applied to the 5 remaining export/download call sites.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:29:01 +02:00
Nick SzaunigandClaude Sonnet 5 b4caa2ad5a fix: show an error toast when editing any entity fails
Nearly every *-edit-dialog.tsx and *-edit-card.tsx in the app - the
save mutation for editing a colony, queen, treatment, inspection,
feeding, varroa measurement, swarm control check, harvest, extraction
(both variants), honey batch, bottling batch, task, stock item,
storage container, or product - had an onSuccess handler but no
onError. A failed save (a validation error, including several added
earlier this session, a permission error, a network hiccup) produced
no feedback at all: no toast, no inline message, the dialog just sat
there having silently done nothing. The user's most likely read on
that is "it saved," when it didn't.

Added the same onError: () => toast.error("<Entity> konnte nicht
gespeichert werden") pattern already used elsewhere in the app (e.g.
apiary deactivate/reactivate) to every one of these mutations. Also
covers assign-colony-dialog.tsx, which is the exact spot where the
queen-availability guard added earlier this session would otherwise
have failed with zero visible feedback.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:22:11 +02:00
Nick SzaunigandClaude Sonnet 5 96207d0096 feat: mirror the new backend numeric bounds as HTML min/max on inputs
The previous two commits added @Min(0)/@Max(100) validation and a
sign-inversion guard on the backend, but the number inputs themselves
had no min/max attributes - a user could still type a negative weight,
quantity, or an out-of-range percentage and only find out via a
generic "failed" toast after submitting, with no indication of why.

Added min={0} (and max={100} on water-content percentages) to every
input for a field the backend now bounds: harvest weights/super
count, honey-batch amount/water content, honey measurements, feeding
amount, extraction amount/water content, stock item quantity/min
threshold, and varroa mite count/duration/calculated value - across
both the create and edit form for each.

The stock-movement quantity input is the one exception requiring
care: min is only applied when type !== "ADJUSTMENT", matching the
backend guard, since a correction movement must still be allowed to
go negative. Added a short explanatory line under that field too.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:12:24 +02:00
Nick SzaunigandClaude Sonnet 5 b208f52fd1 fix: reject negative weights/quantities and out-of-range percentages
None of these numeric business fields had a lower bound, so a
negative honey weight, jar count, feeding amount, mite count, or
stock quantity was silently accepted - each would corrupt whatever
total or statistic it later feeds into. Added @Min(0):

- Harvest: honeySuperCount, grossWeight, netWeight
- HoneyBatch: amount (+ @Max(100) on waterContent, a percentage)
- HoneyMeasurement: waterContent (+ @Max(100))
- Feeding: amount
- Extraction: amount (+ @Max(100) on waterContent)
- StockItem: quantity, minThreshold
- VarroaMeasurement: durationMinutes, miteCount, calculatedValue

Left temperature fields alone (extraction, inspection) - genuinely
can be negative (winter inspections, outdoor storage) and no
confident domain bound exists to apply instead. Update DTOs for these
entities all extend PartialType(PickType(Create...Dto, [...])) from
@nestjs/swagger, which preserves validation decorators, so no
separate change was needed there.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:05:13 +02:00
Nick SzaunigandClaude Sonnet 5 93d0f2b208 fix: reject a negative quantity on IN/OUT stock movements
addMovement() negates quantity internally for OUT (delta = -quantity)
and applies it directly for IN. Nothing stopped a negative quantity
from being submitted, so e.g. { type: 'OUT', quantity: -5 } would
compute delta = -(-5) = +5 - silently ADDING stock while being
recorded in the audit trail as a removal. The inverse held for IN.
ADJUSTMENT is the one type that must stay allowed to be negative,
since that's the only way to correct a stock count downward, so this
is a service-level guard (BadRequestException) rather than a DTO
validator - class-validator's @ValidateIf skips every validator on a
property when its condition is false, including @IsNumber, so it
can't express "always a number, but only sometimes positive" cleanly;
verified this empirically before choosing the service-level approach.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 11:01:45 +02:00
Nick SzaunigandClaude Sonnet 5 8bf9aa467d feat: add error boundaries so a crash never blanks the whole app
No error.tsx existed anywhere in the app - an uncaught render error on
any page (a beekeeper's most field-critical moment: mid-inspection,
no signal, nowhere to go) showed nothing recoverable, not even a
message, let alone a way back to a working page.

Two boundaries, matching Next.js App Router's error-boundary scoping:
- (app)/error.tsx covers every authenticated page. It only replaces
  the crashed page's content - the sidebar/header/bottom nav stay
  mounted and usable, so the user can navigate to a different page
  without a full reload.
- app/error.tsx is the outer fallback (login, the public Herkunft
  page, or a crash in (app)/layout.tsx itself, which only a strictly
  higher boundary can catch). Its "Zum Dashboard" action is a plain
  <a> reload rather than client-side navigation, since a crash this
  high may have left client state corrupted.

Both are in German, matching the app's existing card/honeycomb visual
language, and explicitly reassure the user that already-saved data is
unaffected.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:55:47 +02:00
Nick SzaunigandClaude Sonnet 5 2e6846285d fix: include Wirkstoff, Charge, and Wartezeit in the annual treatment report
The colony annual PDF report's treatment section only ever printed
date/product/amount - activeIngredient, batchNumber, and
withdrawalPeriodDays (added earlier this session specifically for
Wartezeit compliance) existed on the Treatment record but never made
it into the one document a beekeeper would actually hand to a
Veterinäramt inspector or keep as their Bestandsbuch-style compliance
record. Silently missing the withdrawal period from the printed
record undermines the whole point of tracking it.

Also adds the first tests for annual-reports.ts (previously untested).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 10:50:46 +02:00
Nick SzaunigandClaude Sonnet 5 fb4713e320 fix: cache backend API data so beekeepers can view it with no signal
The service worker only ever cached same-origin static assets/pages -
every real data call (colonies, apiaries, tasks, honey batches, ...)
was excluded, both because it targets a different origin (API_URL
points at a separate backend service, per .env.example) and because
of an explicit /api path exclusion. In practice this meant a
beekeeper opening the app at an apiary with no signal saw nothing at
all: not even data fetched minutes earlier on the same device, despite
the offline write-queue built earlier assuming the app itself stays
usable while offline.

The backend can't be matched by origin the way static assets are (the
service worker has no static knowledge of the runtime-configured
API_URL), so it's matched by path instead - every backend call goes
through /api/v1/. /api/runtime-config is explicitly excluded and
always fetched live, since a stale cached value would point the app
at the wrong backend. Error responses (4xx/5xx) are now never cached
either, closing a latent bug that could otherwise serve a stale 401
back after a token refresh.

Since the cache now holds real business data, it's cleared on logout
so it can't leak into the next person's session on a shared device.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:45:11 +02:00
Nick SzaunigandClaude Sonnet 5 3678a01319 fix: prevent new backend guards from ever surfacing as raw errors
The colony-apiary and queen-availability validation just added
backend-side had no matching frontend guard, so a user could still
pick an inactive apiary or open the assign-colony dialog on an
unavailable queen and only find out it fails after submitting:

- Colony create/move apiary pickers now fetch only ACTIVE apiaries.
- The queen "Volk zuweisen" button is disabled once her status is
  LOST/REPLACED/SOLD/DECEASED (QUEEN_UNAVAILABLE_STATUSES, mirroring
  the backend's QUEEN_NO_LONGER_AVAILABLE_STATUSES), instead of only
  failing after the form is filled out and submitted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:39:47 +02:00
Nick SzaunigandClaude Sonnet 5 3e179391b0 fix: block assigning a colony to a queen who is no longer available
assignColony() never checked the queen's own status, so a queen marked
LOST/DECEASED/REPLACED/SOLD - exactly the statuses the queen-loss fix
now treats as "not really available" and releases her from her old
colony for - could still be assigned to a brand new colony, which
makes no domain sense (you can't put a dead queen in a hive).

Rejects with ConflictException up front, before even looking up the
destination colony; correcting a mistaken status update first is the
intended fix path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:35:32 +02:00
Nick SzaunigandClaude Sonnet 5 5c5c7b756f fix: block creating or moving a colony into an inactive/missing apiary
Colony.apiaryId was never checked against the target apiary at all -
create() would hit a raw Prisma FK-constraint error for a nonexistent
apiary instead of a clean 404, and both create() and move() would
silently succeed against a deactivated apiary, orphaning the colony
the same way (just from the opposite direction) as the apiary-side gap
fixed in the previous commit.

Adds a shared assertApiaryActive() check, following the same
findUnique-then-status pattern already used for cross-entity checks
elsewhere (e.g. QueensService.assertMotherQueenExists), used by both
create() and move().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:32:53 +02:00
Nick SzaunigandClaude Sonnet 5 961a237335 fix: block deactivating an apiary while it still has active colonies
Colony.apiaryId is a plain FK, never checked against the apiary's own
status. Without a guard, deactivating an apiary silently orphaned any
colonies still living there: they kept showing as ACTIVE, but their
apiary had disappeared from every "active apiaries" list, dashboard
count, and colony-creation picker - a confusing half-state with no
warning at all.

ApiariesService.deactivate() now throws ConflictException when active
colonies remain, so the beekeeper has to move or dissolve them first -
matching the same assertActive-style guard pattern already used
elsewhere (colonies, e.g. move()/merge()/split()).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:30:19 +02:00
Nick SzaunigandClaude Sonnet 5 20981ec1ce fix: release a colony's queen when it is dissolved or merged away
Colony.currentQueenId is unique across colonies. dissolve() and merge()
set status to DISSOLVED/MERGED but never cleared currentQueenId, so a
colony that still had a queen when it was dissolved or merged kept
permanently "owning" her - any later attempt to assign that queen to a
different colony (e.g. requeening another colony with her) would fail
with a unique-constraint conflict, since the dead colony's stale
reference was still occupying that slot.

Both operations now clear currentQueenId and close the open
QueenColonyHistory row for that queen, mirroring the bookkeeping
assignColony() and the queen-status-transition fix already do
elsewhere. The merge survivor keeps its own queen; a merged-away
colony's queen is released, not auto-transferred, since which queen
"wins" a merge is a beekeeper decision.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 20:58:53 +02:00
Nick SzaunigandClaude Sonnet 5 cc46fa4d9e fix: mark a colony QUEENLESS when its queen is lost, dies, or is replaced
A very common real-world event - a queen dying over winter, absconding,
being superseded, sold, or replaced - had no side effect on the colony
record at all. Colony.currentQueenId kept pointing at a queen who was
no longer really there, colony.status stayed whatever it was, and the
QUEENLESS dashboard alert could never fire from this path.

QueensService.update() now detects a transition into
LOST/DECEASED/REPLACED/SOLD and, inside the same transaction, clears
currentQueenId on whichever colony currently points at that queen,
flips its status to QUEENLESS (unless the colony is already in a
terminal lifecycle status like DISSOLVED, which is left alone), and
closes the open QueenColonyHistory row - mirroring the bookkeeping
assignColony() already does when a queen changes colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 13:30:18 +02:00
Nick SzaunigandClaude Sonnet 5 32f11e5b39 fix: clamp {SEQ:N} width in number-format to prevent resource exhaustion
formatGeneratedNumber() passed the admin-supplied {SEQ:N} width straight
into String.padStart() with no upper bound. A careless or malicious
format value (e.g. {SEQ:999999999}) would try to allocate a
correspondingly huge string - on the backend that runs on every single
honey-batch/bottling creation once such a format is saved, and in the
newly added frontend preview it would run on every keystroke while
typing the format. Caps the effective width at 20 digits, far beyond
any realistic sequence number, in both the backend implementation and
its frontend port so they stay identical.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:41:49 +02:00
Nick SzaunigandClaude Sonnet 5 1c16db114e feat: show a live example number for active number-format rules
Admins configuring the honey-batch/bottling auto-numbering format
(WH-{YYYY}-{SEQ:4}) had no way to see what it would actually produce
before saving - a typo'd placeholder or unexpected format silently
breaks every batch number from then on. When the rule is active, the
format card now renders the next number it would generate, live as
the format is edited, using the same formatGeneratedNumber logic the
backend uses (ported to the frontend, no shared package between the
two apps) and the real next sequence value (sequenceValue + 1).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:36:06 +02:00
Nick SzaunigandClaude Sonnet 5 1dddc387ba fix: expose every backend-supported field in all edit forms
Audit requested by the user after finding queens couldn't set
hatchYear: many edit dialogs (and some create forms) were missing
fields the backend DTOs already accept, so that data could never be
entered or corrected anywhere in the UI. Fixes per entity:

- Colony: status (WEAK/QUEENLESS/CONSPICUOUS) was never settable
  anywhere, so the COLONY_ATTENTION dashboard alert could never fire.
  Added as a dropdown restricted to the non-terminal statuses;
  DISSOLVED/SOLD/DECEASED/MERGED stay on their dedicated confirm
  actions, which also set deactivatedAt.
- Apiary: description was missing from the edit form.
- Inspection: 10 fields (weather, temperature, broodAmount,
  broodPattern, openBroodPresent, cappedBroodPresent,
  queenCellsPresent, swarmMood, gentleness, combBehavior,
  honeySuperStatus) existed in the schema/DTOs but nowhere in the UI -
  added to both the create and edit forms.
- Varroa measurement: durationMinutes was on the create form but
  missing from edit.
- Feeding: batchNumber missing from both create and edit.
- Treatment: startDate, endDate, activeIngredient, batchNumber
  missing from both create and edit.
- Task: assignedUserId had no UI anywhere; added to create/edit,
  degrading gracefully if the current user's role lacks users:read.
- Extraction (honey-batch traceability variant): amount was missing,
  while the other edit dialog for the same entity already had it.
- Bottling: productId missing from edit.
- Harvest: grossWeight missing from edit; grossWeight and
  honeySuperCount missing from create.
- Queen: breeder, matingType, matingStatus, inseminationData,
  motherLine, fatherLine added to the edit form (hatchYear/
  markingColor/markingNumber were already fixed in a prior commit).

Left alone deliberately: fields the backend DTO itself excludes via
PickType (e.g. HoneyBatch's jarSize/jarCount/totalAmount, Bottling's
quantities) since mutating those post-hoc would desync inventory
deduction, and Colony's terminal statuses/code which have dedicated
lifecycle actions.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:20:18 +02:00
Nick SzaunigandClaude Sonnet 5 792806b967 fix: compute mites/day for natural mite fall varroa measurements
VarroaMeasurement.calculatedValue was already used as "Milben/Tag" by
the VARROA_THRESHOLD automation (thresholdMitesPerDay) and the annual
PDF report, but no client ever actually computed it - the frontend
form only collects raw miteCount and durationMinutes. That meant the
threshold check was comparing a raw, un-normalized mite count against
a per-day threshold: e.g. 12 mites counted over 5 days (2.4/day,
healthy) would incorrectly trigger the same alert as 12 mites counted
over 1 day (a real problem).

VarroaService now derives calculatedValue = miteCount / duration in
days for NATURAL_MITE_FALL measurements, on create and (only when no
value exists yet) on update, without ever overwriting an explicitly
provided or already-stored value. Other methods (Puderzucker,
Waschmethode, Drohnenbrutkontrolle) use a different, non-time-based
metric and are left for manual entry rather than guessing an
unverified formula.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 12:01:02 +02:00
Nick SzaunigandClaude Sonnet 5 985ea09556 fix: allow entering a queen's hatch year and marking color/number
The backend has always accepted hatchYear/markingColor/markingNumber
(Queen DTOs, Prisma schema), and the queens list even already renders
a "Schlupfjahr" field - but neither the create nor the edit form ever
exposed inputs for them, so no user could actually set hatchYear
through the UI. That silently broke the OLD_QUEEN dashboard alert
just added: it can only ever fire for colonies whose queen has a
hatchYear, and there was no way to record one.

Both forms now include Schlupfjahr, Zeichenfarbe and Zeichennummer.
Marking color is auto-suggested from the hatch year using the
internationally standardized queen marking color convention (last
digit of the year -> white/yellow/red/green/blue), editable like any
other suggestion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 21:44:29 +02:00
Nick SzaunigandClaude Sonnet 5 edf13d3ae4 feat: warn on the dashboard when a queen is 3+ years old
Queen.hatchYear was already captured but never used anywhere - no
signal existed for "this colony's queen is overdue for evaluation or
replacement," despite that being one of the most common productivity
and swarming risk factors in beekeeping. Adds an OLD_QUEEN dashboard
alert alongside the existing overdue-task/swarm-check ones, flagging
any active colony whose current queen has completed 3+ seasons.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 18:43:37 +02:00
Nick SzaunigandClaude Sonnet 5 8c4ae64e76 docs: record the Wartezeit design assumption in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:51:15 +02:00
Nick SzaunigandClaude Sonnet 5 d42f8331f9 feat: warn against harvesting honey during a treatment's Wartezeit
Treatments (Ameisensäure, Oxalsäure, etc.) can require a legally
mandated withdrawal period before honey from that colony may be sold.
Nothing in the app tracked this before, so a beekeeper had no
safeguard against harvesting into an active withdrawal window.

Treatments now optionally record withdrawalPeriodDays (from the
product's Packungsbeilage). The new harvest form fetches each
candidate colony's active withdrawal window and shows an inline
warning naming the product and the date it clears, computed as
max(endDate ?? date) + withdrawalPeriodDays across a colony's
treatments, restricted to windows still in the future.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:50:34 +02:00
Nick SzaunigandClaude Sonnet 5 0881a38800 feat: warn on the dashboard when a swarm check is overdue
Schwarmkontrolle already captures nextCheckDate per check, but it was
only ever visible by opening that one colony's tab. During swarm
season a missed follow-up check can mean losing half the colony, so
this now surfaces as a dashboard alert (OVERDUE_SWARM_CHECK) like
overdue tasks already do - only the most recent check per colony is
considered, so a completed follow-up clears the warning.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 02:35:17 +02:00
Nick SzaunigandClaude Sonnet 5 acbf5f16ce fix: add apple-touch-icon for iOS "Add to Home Screen"
iOS Safari ignores the web manifest for home-screen icons entirely and
needs its own apple-touch-icon link, which Next.js only emits when
app/apple-icon.tsx (or a literal apple-icon.png) exists. Without it,
iOS falls back to a screenshot of the open page instead of the brand
mark. Mirrors the existing public/icon.svg design via next/og.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 18:06:22 +02:00
Nick SzaunigandClaude Sonnet 5 81986b3275 feat: seed a starter annual task-template calendar
TaskTemplate had zero seed data - a fresh deployment showed a
completely empty recurring-task list with no hint of what to even put
there. Seeds 12 YEARLY templates, one per month, covering the standard
German-beekeeping annual cycle (Frühjahrsdurchsicht, Schwarmkontrolle,
Einfütterung, Varroa-Restentmilbung/Oxalsäure, Wintersitz-Kontrolle,
etc.) so a new beekeeper gets a ready-made starting point instead of a
blank page. Runs once (guarded by a taskTemplate.count() check, same
idempotency shape as the existing admin-user seed) and only ever adds
rows - admins can edit or deactivate any of them afterward.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:58:28 +02:00
Nick SzaunigandClaude Sonnet 5 c4f970f071 feat: add wind speed and a same-day hive-inspection verdict to weather
Temperature and rain were already tracked, but wind matters just as
much for whether it's safe/sensible to open a hive - bees get more
defensive and frames are harder to handle in wind. Adds
wind_speed_10m_max to the Open-Meteo request and a derived
inspectionSuitable flag per day (>=15C, <=1mm rain, <=25km/h wind;
null when a value is missing rather than guessing). The apiary weather
card now shows a clear "good/not good for a Durchsicht today" banner
plus wind alongside each day's forecast.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:54:31 +02:00
Nick SzaunigandClaude Sonnet 5 1f67cd5548 feat: extend offline queueing to all quick-capture forms
Only Durchsicht had an offline fallback (queue to IndexedDB, sync on
reconnect); Behandlung, Fütterung, Varroamessung, Schwarmkontrolle and
Ernte just failed outright with no signal - exactly the situation a
beekeeper is in at the actual hive. Generalizes lib/offline/db.ts and
sync.ts to six queues (version-bumped IndexedDB schema, existing queued
inspections preserved), wires the same "server rejected it vs. no
network" fallback into all five remaining */new pages, and updates the
sync badge to count/drain all six.

Also: doc-hygiene pass (ARCHITECTURE.md/TASKS.md) to replace the stale
pg_dump/CronJob backup plan with the in-app BackupModule that actually
shipped earlier this session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-05 17:45:32 +02:00
165 changed files with 5556 additions and 300 deletions

No files matched your search

+6 -5
View File
@@ -98,6 +98,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
## 7. Kubernetes
@@ -111,10 +112,10 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie:
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes.
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert wirklich alle Modelle (Betriebsdaten, Benutzer/Rollen/Rechte, Audit-Log, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei; Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen.
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
+1
View File
@@ -584,6 +584,7 @@ model Treatment {
reason String?
batchNumber String?
notes String?
withdrawalPeriodDays Int?
createdAt DateTime @default(now())
@@index([colonyId, date])
+161
View File
@@ -30,11 +30,172 @@ function printLoginBanner(email: string, password: string): void {
console.log(line);
}
// Duplicated from src/task-templates/recurrence.ts rather than imported:
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
// isolated compile, separate from the main Nest build), so a cross-rootDir
// import would fail to compile. Only the YEARLY case is needed here.
function nextYearlyOccurrenceUtc(
month: number,
dayOfMonth: number,
from: Date,
): Date {
const year = from.getUTCFullYear();
const clampedDay = (y: number): number =>
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
const today = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
);
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
if (candidate.getTime() >= today.getTime()) {
return candidate;
}
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
}
interface DefaultTaskTemplate {
title: string;
description: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
month: number;
dayOfMonth: number;
}
// The standard German-beekeeping annual cycle, one flagship task per month -
// gives a new deployment a ready-made starter calendar instead of a
// completely empty task-template list. Admins can edit/deactivate any of
// these afterward; this only ever runs once (see the count() guard below).
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
{
title: 'Wintersitz kontrollieren',
description:
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
priority: 'NORMAL',
month: 1,
dayOfMonth: 15,
},
{
title: 'Gemülldiagnose',
description:
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
priority: 'NORMAL',
month: 2,
dayOfMonth: 15,
},
{
title: 'Frühjahrsdurchsicht',
description:
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
priority: 'HIGH',
month: 3,
dayOfMonth: 15,
},
{
title: 'Volk erweitern',
description:
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
priority: 'NORMAL',
month: 4,
dayOfMonth: 1,
},
{
title: 'Schwarmkontrolle beginnen',
description:
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
priority: 'HIGH',
month: 5,
dayOfMonth: 1,
},
{
title: 'Honigernte vorbereiten',
description:
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
priority: 'NORMAL',
month: 6,
dayOfMonth: 15,
},
{
title: 'Varroa-Kontrolle nach der Ernte',
description:
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
priority: 'HIGH',
month: 7,
dayOfMonth: 15,
},
{
title: 'Einfütterung beginnen',
description:
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
priority: 'HIGH',
month: 8,
dayOfMonth: 1,
},
{
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
description:
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
priority: 'HIGH',
month: 9,
dayOfMonth: 15,
},
{
title: 'Fütterung abschließen, Mäusegitter anbringen',
description:
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
priority: 'NORMAL',
month: 10,
dayOfMonth: 1,
},
{
title: 'Winterbehandlung (Oxalsäure)',
description:
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
priority: 'HIGH',
month: 11,
dayOfMonth: 15,
},
{
title: 'Jahresrückblick und Gerätewartung',
description:
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
priority: 'LOW',
month: 12,
dayOfMonth: 15,
},
];
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
const existingCount = await prisma.taskTemplate.count();
if (existingCount > 0) {
return;
}
const now = new Date();
await prisma.taskTemplate.createMany({
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
title: template.title,
description: template.description,
priority: template.priority,
recurrenceType: 'YEARLY',
recurrenceMonth: template.month,
recurrenceDayOfMonth: template.dayOfMonth,
nextDueDate: nextYearlyOccurrenceUtc(
template.month,
template.dayOfMonth,
now,
),
})),
});
console.log(
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
);
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma);
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
const existing = await prisma.user.findUnique({ where: { email } });
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
create: jest.Mock;
update: jest.Mock;
};
colony: { count: jest.Mock };
};
let auditLog: { record: jest.Mock };
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
@@ -76,6 +78,16 @@ describe('ApiariesService', () => {
);
});
it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.apiary.update).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
@@ -76,6 +76,19 @@ export class ApiariesService {
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead.
const activeColonyCount = await this.prisma.colony.count({
where: { apiaryId: id, status: 'ACTIVE' },
});
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
@@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
],
1600,
),
@@ -28,4 +28,11 @@ describe('formatGeneratedNumber', () => {
expect(result).toBe('FIXED-NUMBER');
});
it('clamps a pathologically large {SEQ:N} width instead of allocating it', () => {
const result = formatGeneratedNumber('L-{SEQ:999999999}', 7, date);
expect(result).toBe(`L-${'0'.repeat(19)}7`);
expect(result.length).toBe(22);
});
});
@@ -1,3 +1,10 @@
// Far beyond any realistic business use (sequence numbers are a handful of
// digits) but small enough that padStart can never be tricked into
// allocating a pathologically large string - {SEQ:999999999} in a
// carelessly typed format would otherwise try to build a ~1GB string on
// every single batch/bottling creation from then on.
const MAX_SEQUENCE_PAD_WIDTH = 20;
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
// zero-padded to N digits. Example matching the lastenheft's own sample
@@ -18,7 +25,10 @@ export function formatGeneratedNumber(
.replace(/\{MM\}/g, mm)
.replace(/\{DD\}/g, dd)
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
String(sequence).padStart(Number(width), '0'),
String(sequence).padStart(
Math.min(Number(width), MAX_SEQUENCE_PAD_WIDTH),
'0',
),
)
.replace(/\{SEQ\}/g, String(sequence));
}
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
@@ -51,4 +52,10 @@ export class BottlingController {
) {
return this.bottlingService.update(id, dto, actor.id);
}
@RequirePermission('bottling:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.bottlingService.remove(id, actor.id);
}
}
@@ -20,13 +20,18 @@ describe('BottlingService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock };
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
honeyBatch: { update: jest.Mock };
};
let webhooks: { dispatch: jest.Mock };
beforeEach(() => {
tx = {
bottlingBatch: { create: jest.fn() },
bottlingBatch: {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
honeyBatch: { update: jest.fn() },
};
prisma = {
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new BottlingService(
prisma as unknown as PrismaService,
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
});
it('update writes an audit log entry with before and after data', async () => {
const before = { id: 'b1', lotNumber: 'L-1' };
const updated = { id: 'b1', lotNumber: 'L-2' };
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
const updated = { ...before, lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
prisma.bottlingBatch.update.mockResolvedValue(updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('clears the product assignment when productId is null', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
productId: 'p1',
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, productId: null });
await service.update('b1', { productId: null }, 'user-1');
expect(prisma.product.findUnique).not.toHaveBeenCalled();
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ where: { id: string }; data: { productId: string | null } }],
];
expect(updateArgs[0].where).toEqual({ id: 'b1' });
expect(updateArgs[0].data.productId).toBeNull();
});
it('releases the previous amount before re-reserving a corrected jar count', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 20,
totalAmount: 10,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
await service.update('b1', { jarCount: 10 }, 'user-1');
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ data: { jarCount: number; totalAmount: number } }],
];
expect(updateArgs[0].data.jarCount).toBe(10);
expect(updateArgs[0].data.totalAmount).toBe(5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 5 },
});
});
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('moves the amount between honey batches when the honey batch changes', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h2' },
data: { amount: 3, status: 'BOTTLED' },
});
});
it('remove gives the amount back and records a DELETE audit entry', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
totalAmount: 5,
};
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
const result = await service.remove('b1', 'user-1');
expect(result).toEqual({ id: 'b1' });
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'DELETE',
beforeData: before,
}),
);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'batch.updated',
before.honeyBatch,
);
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
@@ -20,6 +20,15 @@ export interface BottlingBatchFilter {
honeyBatchId?: string;
}
/**
* Amounts are kept in kilograms with gram precision. Rounding on every write
* keeps a honey batch's remaining amount from drifting when a bottling batch
* is corrected or removed and its amount is given back.
*/
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
@Injectable()
export class BottlingService {
constructor(
@@ -122,8 +131,16 @@ export class BottlingService {
return created;
}
/**
* Corrects any field of an existing bottling batch. Changing the honey
* batch, the jar size or the jar count also rebalances the remaining amount
* of the affected honey batch(es): the previously bottled amount is given
* back before the corrected amount is taken again, so a correction never
* double-charges the source batch.
*/
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id);
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
@@ -132,16 +149,87 @@ export class BottlingService {
throw new NotFoundException('Product not found');
}
}
await this.prisma.bottlingBatch.update({
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Capacity on the target batch, with this bottling batch's current amount
// released first when it already sits on that same batch.
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
}
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
// 409 with a field-specific message by AllExceptionsFilter.
await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.update({
where: { id },
data: {
...dto,
honeyBatchId,
productId: dto.productId === undefined ? undefined : dto.productId,
date: dto.date ? new Date(dto.date) : undefined,
jarSize,
jarCount,
totalAmount,
bestBeforeDate: dto.bestBeforeDate
? new Date(dto.bestBeforeDate)
: undefined,
lotNumber: dto.lotNumber,
},
});
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount:
before.honeyBatch.amount === null
? null
: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
status: 'BOTTLED',
},
});
} else if (totalAmount !== before.totalAmount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: {
amount:
availableOnTarget === null
? null
: roundKg(availableOnTarget - totalAmount),
},
});
}
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
@@ -151,6 +239,42 @@ export class BottlingService {
beforeData: before,
afterData: bottlingBatch,
});
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
return bottlingBatch;
}
/**
* Removes a bottling batch that was entered by mistake and gives its amount
* back to the honey batch. The full record is kept in the audit log
* (action DELETE, beforeData), so the history survives the row.
*
* The honey batch's status is deliberately left untouched: the status it
* held before being bottled is not recoverable here, and guessing it would
* write a fact that never happened.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.delete({ where: { id } });
if (before.honeyBatch.amount !== null) {
await tx.honeyBatch.update({
where: { id: before.honeyBatchId },
data: {
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
},
});
}
});
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
return { id };
}
}
@@ -1,11 +1,63 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
ValidateIf,
} from 'class-validator';
export class UpdateBottlingBatchDto extends PartialType(
PickType(CreateBottlingBatchDto, [
'productId',
'date',
'bestBeforeDate',
'lotNumber',
] as const),
) {}
/**
* Every field of a bottling batch stays correctable after the fact - a
* mistyped jar count or a batch bottled against the wrong honey batch is a
* data-entry error, not a fact of production. The service rebalances the
* affected honey batch amounts for any change to honeyBatchId/jarSize/
* jarCount, so the traceability chain stays consistent.
*
* `productId: null` explicitly clears the product assignment; omitting the
* field leaves it untouched.
*/
export class UpdateBottlingBatchDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
honeyBatchId?: string;
@ApiPropertyOptional({
nullable: true,
description: 'null entfernt die Produktzuordnung',
})
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
productId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
@IsOptional()
@IsNumber()
@IsPositive()
jarSize?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@IsPositive()
jarCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
bestBeforeDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lotNumber?: string;
}
@@ -11,6 +11,7 @@ describe('ColoniesService', () => {
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
};
let prisma: {
colony: {
@@ -19,6 +20,7 @@ describe('ColoniesService', () => {
create: jest.Mock;
update: jest.Mock;
};
apiary: { findUnique: jest.Mock };
colonyApiaryHistory: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
colonyLineage: { findMany: jest.Mock };
@@ -39,6 +41,7 @@ describe('ColoniesService', () => {
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
};
prisma = {
colony: {
@@ -47,6 +50,11 @@ describe('ColoniesService', () => {
create: jest.fn(),
update: jest.fn(),
},
apiary: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'apiary-2', status: 'ACTIVE' }),
},
colonyApiaryHistory: { findMany: jest.fn() },
queenColonyHistory: { findMany: jest.fn() },
colonyLineage: { findMany: jest.fn() },
@@ -117,7 +125,7 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2');
});
it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => {
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({
@@ -127,6 +135,7 @@ describe('ColoniesService', () => {
tx.colony.findUnique.mockResolvedValue({
id: 'colony-3',
status: 'ACTIVE',
currentQueenId: 'queen-2',
});
tx.colony.update.mockResolvedValue({});
tx.colonyLineage.create.mockResolvedValue({});
@@ -138,10 +147,31 @@ describe('ColoniesService', () => {
);
const [[updateArg]] = tx.colony.update.mock.calls as [
[{ where: { id: string }; data: { status: string } }],
[
{
where: { id: string };
data: { status: string; currentQueenId: string | null };
},
],
];
expect(updateArg.where.id).toBe('colony-3');
expect(updateArg.data.status).toBe('MERGED');
expect(updateArg.data.currentQueenId).toBeNull();
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
{
where: { queenId: string; colonyId: string; endDate: null };
data: { endDate: Date };
},
],
];
expect(historyCloseArg.where).toEqual({
queenId: 'queen-2',
colonyId: 'colony-3',
endDate: null,
});
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { type: string; fromColonyId: string; toColonyId: string } }],
@@ -161,9 +191,48 @@ describe('ColoniesService', () => {
).rejects.toBeInstanceOf(ConflictException);
});
it('move rejects a nonexistent destination apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(
service.move('colony-1', { apiaryId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('move rejects an inactive destination apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.apiary.findUnique.mockResolvedValue({
id: 'apiary-2',
status: 'INACTIVE',
});
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('create rejects an inactive apiary', async () => {
prisma.apiary.findUnique.mockResolvedValue({
id: 'apiary-1',
status: 'INACTIVE',
});
await expect(
service.create(
{
code: 'V-002',
apiaryId: 'apiary-1',
colonyType: 'PRODUCTION_COLONY',
},
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.create).not.toHaveBeenCalled();
});
it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.update.mockResolvedValue({
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
@@ -176,6 +245,67 @@ describe('ColoniesService', () => {
);
});
it("dissolve releases the colony's queen so she can be reassigned later", async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
currentQueenId: 'queen-1',
});
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
currentQueenId: null,
});
await service.dissolve('colony-1', 'user-1');
const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [
[
{
where: { id: string };
data: {
status: string;
deactivatedAt: Date;
currentQueenId: string | null;
};
},
],
];
expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' });
expect(dissolveUpdateArg.data.status).toBe('DISSOLVED');
expect(dissolveUpdateArg.data.currentQueenId).toBeNull();
expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date);
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
{
where: { queenId: string; colonyId: string; endDate: null };
data: { endDate: Date };
},
],
];
expect(historyCloseArg.where).toEqual({
queenId: 'queen-1',
colonyId: 'colony-1',
endDate: null,
});
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
});
it('dissolve does not touch queen history for a colony with no queen', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
currentQueenId: null,
});
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.dissolve('colony-1', 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect(
@@ -206,4 +336,32 @@ describe('ColoniesService', () => {
expect.objectContaining({ action: 'UPDATE' }),
);
});
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(tx.colonyApiaryHistory.create).toHaveBeenCalledWith({
data: {
colonyId: 'colony-1',
apiaryId: 'apiary-2',
movedByUserId: 'user-1',
notes: 'Korrektur über Volk bearbeiten',
},
});
});
it('writes no apiary-history entry when an edit leaves the apiary untouched', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'geändert' }, 'user-1');
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
});
@@ -46,12 +46,32 @@ export class ColoniesService {
private assertActive(colony: Colony): void {
if (colony.status !== 'ACTIVE') {
throw new ConflictException(
`Colony ${colony.id} is not active (status: ${colony.status})`,
`Das Volk ist nicht aktiv (Status: ${colony.status})`,
);
}
}
// Colony.apiaryId is a plain FK with no built-in status check - without
// this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would).
private async assertApiaryActive(apiaryId: string): Promise<void> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
});
if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
if (apiary.status !== 'ACTIVE') {
throw new ConflictException(
'Der Bienenstand ist nicht aktiv und kann daher keine Völker aufnehmen',
);
}
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Colony',
@@ -70,10 +90,32 @@ export class ColoniesService {
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
const colony = await this.prisma.colony.update({
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
});
// Correcting the apiary has to leave the same trace a deliberate move
// does - otherwise the colony's location history would silently skip a
// stand it was recorded on.
if (movesApiary) {
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
},
});
}
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
@@ -93,6 +135,7 @@ export class ColoniesService {
): Promise<Colony> {
const before = await this.findById(id);
this.assertActive(before);
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id },
@@ -169,13 +212,35 @@ export class ColoniesService {
where: { id: mergedColonyId },
});
if (!merged) {
throw new NotFoundException(`Colony ${mergedColonyId} not found`);
throw new NotFoundException(
'Zu vereinigendes Volk wurde nicht gefunden',
);
}
this.assertActive(merged);
// currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict.
// The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here).
await tx.colony.update({
where: { id: mergedColonyId },
data: { status: 'MERGED', deactivatedAt: new Date() },
data: {
status: 'MERGED',
deactivatedAt: new Date(),
currentQueenId: null,
},
});
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: merged.currentQueenId,
colonyId: mergedColonyId,
endDate: null,
},
data: { endDate: new Date() },
});
}
await tx.colonyLineage.create({
data: {
type: 'MERGE',
@@ -200,9 +265,26 @@ export class ColoniesService {
async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
const colony = await this.prisma.colony.update({
const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id },
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
data: {
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
});
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
@@ -3,8 +3,16 @@ import { IsEnum, IsOptional } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto';
/**
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
* a colony recorded on the wrong apiary is a data-entry error and must be
* correctable. The service writes the same ColonyApiaryHistory entry the move
* endpoint writes, so the traceability chain stays intact either way.
*/
class UpdatableColonyFieldsDto extends PartialType(
PickType(CreateColonyDto, [
'apiaryId',
'code',
'hiveType',
'originText',
'colonyType',
@@ -1,4 +1,5 @@
import { ArgumentsHost, BadRequestException, Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { AllExceptionsFilter } from './all-exceptions.filter';
describe('AllExceptionsFilter', () => {
@@ -67,4 +68,112 @@ describe('AllExceptionsFilter', () => {
expect(warnSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('translates a P2002 unique-constraint error on a known field to a 409 with a specific message', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`code`)',
{ code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } },
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(409);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 409,
message: 'Code wird bereits verwendet - bitte einen anderen Wert wählen',
});
});
it('falls back to a generic duplicate message for a P2002 on an unmapped or compound field', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`sourceType`,`sourceId`)',
{
code: 'P2002',
clientVersion: '6.0.0',
meta: { target: ['sourceType', 'sourceId'] },
},
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(409);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 409,
message:
'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen',
});
});
it('logs a P2002 error at warn level, not error level, since it is a user input problem', () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`code`)',
{ code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } },
);
filter.catch(exception, host);
expect(warnSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('translates a P2003 foreign-key error to a 400 naming the broken relation', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Foreign key constraint failed on the field: `colonyId`',
{
code: 'P2003',
clientVersion: '6.0.0',
meta: { field_name: 'colonyId' },
},
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 400,
message:
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen',
});
});
it('translates a P2025 missing-record error to a 404', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'An operation failed because it depends on one or more records that were required but not found.',
{ code: 'P2025', clientVersion: '6.0.0' },
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(404);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 404,
message: 'Datensatz nicht gefunden',
});
});
it('leaves an unmapped Prisma error as a generic 500, leaking nothing', () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Timed out fetching a new connection from the connection pool',
{ code: 'P2024', clientVersion: '6.0.0' },
);
filter.catch(exception, host);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 500,
message: 'Internal server error',
});
});
});
@@ -7,6 +7,71 @@ import {
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Prisma } from '../../../generated/prisma';
// Unique-constraint fields a beekeeper might plausibly retype by mistake
// (a hive code, a batch number) - translated so the P2002 branch below can
// name the actual field instead of a raw column identifier.
const UNIQUE_FIELD_LABELS: Record<string, string> = {
code: 'Code',
email: 'E-Mail-Adresse',
sku: 'Artikelnummer (SKU)',
batchNumber: 'Chargennummer',
lotNumber: 'Los-/Chargennummer',
inventoryNumber: 'Inventarnummer',
};
function duplicateFieldMessage(target: unknown): string {
const fields = Array.isArray(target)
? target.filter((value): value is string => typeof value === 'string')
: typeof target === 'string'
? [target]
: [];
const label =
fields.length === 1 ? UNIQUE_FIELD_LABELS[fields[0]] : undefined;
return label
? `${label} wird bereits verwendet - bitte einen anderen Wert wählen`
: 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen';
}
/**
* A relation pointed at a record that does not exist (Prisma P2003). Every
* module lets a record be re-pointed at a different parent - an inspection
* filed against the wrong colony, a harvest against the wrong apiary - and
* relies on the foreign key rather than an application-level existence check,
* so a stale or mistyped id has to come back as a clean 400.
*/
const FOREIGN_KEY_MESSAGE =
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen';
/**
* Translates the Prisma errors that are really user input problems into the
* status and message they deserve. Anything not listed here stays a 500 with
* a generic body, so no internal detail leaks.
*/
function prismaErrorResponse(
exception: unknown,
): { status: number; message: string } | null {
if (!(exception instanceof Prisma.PrismaClientKnownRequestError)) {
return null;
}
switch (exception.code) {
case 'P2002':
return {
status: HttpStatus.CONFLICT,
message: duplicateFieldMessage(exception.meta?.target),
};
case 'P2003':
return { status: HttpStatus.BAD_REQUEST, message: FOREIGN_KEY_MESSAGE };
case 'P2025':
return {
status: HttpStatus.NOT_FOUND,
message: 'Datensatz nicht gefunden',
};
default:
return null;
}
}
/**
* Replaces Nest's default exception handling so EVERY error - not just
@@ -20,7 +85,15 @@ import type { Request, Response } from 'express';
* Client-facing behaviour is unchanged: HttpExceptions still return
* exactly the body/status Nest would have produced. Non-HttpException
* errors still collapse to a generic 500 body - the real message and
* stack go to the log, never to the client.
* stack go to the log, never to the client. The exceptions are the Prisma
* errors listed in prismaErrorResponse: services write records straight
* against DB constraints (`@unique` columns like colony/apiary/queen codes,
* SKUs and batch numbers; foreign keys for every parent relation) with no
* application-level check, relying on the constraint itself - so without
* those branches, a beekeeper reusing a code or picking a record that was
* meanwhile removed saw a bare 500 "Internal server error" instead of a
* clean, specific message, and the log filled with fake server errors for
* what is really a user input problem.
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
@@ -32,12 +105,16 @@ export class AllExceptionsFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException;
const prismaResponse = prismaErrorResponse(exception);
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
const body = isHttpException
? exception.getResponse()
: { statusCode: status, message: 'Internal server error' };
: {
statusCode: status,
message: prismaResponse?.message ?? 'Internal server error',
};
const message =
exception instanceof Error ? exception.message : 'Unknown error';
const context = `${request.method} ${request.originalUrl} -> ${status}`;
@@ -10,6 +10,7 @@ describe('DashboardService', () => {
task: { count: jest.Mock; findMany: jest.Mock };
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
};
beforeEach(() => {
@@ -26,6 +27,7 @@ describe('DashboardService', () => {
},
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
@@ -63,4 +65,77 @@ describe('DashboardService', () => {
'LOW_STOCK',
]);
});
it('flags a colony whose most recent swarm check is overdue', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
nextCheckDate: new Date('2020-01-01'),
colony: { code: 'V-001' },
},
]);
const alerts = await service.alerts();
expect(alerts).toEqual([
{
type: 'OVERDUE_SWARM_CHECK',
message: 'Schwarmkontrolle für Volk V-001 überfällig',
relatedType: 'Colony',
relatedId: 'colony-1',
},
]);
});
it('ignores an overdue swarm check when a newer check already superseded it', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: new Date('2026-06-10'),
nextCheckDate: null,
colony: { code: 'V-001' },
},
{
colonyId: 'colony-1',
date: new Date('2026-06-01'),
nextCheckDate: new Date('2020-01-01'),
colony: { code: 'V-001' },
},
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(0);
});
describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => {
prisma.colony.findMany
.mockResolvedValueOnce([]) // attentionColonies
.mockResolvedValueOnce([
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(1);
expect(alerts[0].type).toBe('OLD_QUEEN');
expect(alerts[0].relatedId).toBe('colony-1');
});
it('does not flag a queen younger than the threshold', async () => {
const currentYear = new Date().getFullYear();
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'colony-1',
code: 'V-001',
currentQueen: { hatchYear: currentYear },
},
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(0);
});
});
});
@@ -11,13 +11,22 @@ export interface DashboardSummary {
}
export interface DashboardAlert {
type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK';
type:
| 'OVERDUE_TASK'
| 'COLONY_ATTENTION'
| 'LOW_STOCK'
| 'OVERDUE_SWARM_CHECK'
| 'OLD_QUEEN';
message: string;
relatedType: string;
relatedId: string;
}
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
// Queens rarely stay reliably productive past their third season - laying
// rate and swarming risk both worsen well before then, so this flags a
// colony for evaluation rather than demanding immediate requeening.
const OLD_QUEEN_AGE_YEARS = 3;
@Injectable()
export class DashboardService {
@@ -60,7 +69,13 @@ export class DashboardService {
}
async alerts(): Promise<DashboardAlert[]> {
const [overdueTasks, attentionColonies, stockItems] = await Promise.all([
const [
overdueTasks,
attentionColonies,
stockItems,
swarmChecks,
queenedColonies,
] = await Promise.all([
this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
@@ -73,6 +88,26 @@ export class DashboardService {
this.prisma.stockItem.findMany({
where: { minThreshold: { not: null } },
}),
this.prisma.swarmControlCheck.findMany({
where: { colony: { status: 'ACTIVE' } },
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }],
select: {
colonyId: true,
nextCheckDate: true,
colony: { select: { code: true } },
},
}),
this.prisma.colony.findMany({
where: {
status: 'ACTIVE',
currentQueen: { hatchYear: { not: null } },
},
select: {
id: true,
code: true,
currentQueen: { select: { hatchYear: true } },
},
}),
]);
const alerts: DashboardAlert[] = [];
@@ -102,6 +137,43 @@ export class DashboardService {
});
}
}
// swarmChecks is ordered by colonyId then date desc, so the first entry
// seen per colonyId is that colony's most recent check - only the most
// recent nextCheckDate matters, an older overdue one has been superseded.
const now = new Date();
const latestCheckByColony = new Map<string, (typeof swarmChecks)[number]>();
for (const check of swarmChecks) {
if (!latestCheckByColony.has(check.colonyId)) {
latestCheckByColony.set(check.colonyId, check);
}
}
for (const check of latestCheckByColony.values()) {
if (check.nextCheckDate && check.nextCheckDate < now) {
alerts.push({
type: 'OVERDUE_SWARM_CHECK',
message: `Schwarmkontrolle für Volk ${check.colony.code} überfällig`,
relatedType: 'Colony',
relatedId: check.colonyId,
});
}
}
const currentYear = now.getFullYear();
for (const colony of queenedColonies) {
// The where clause above already requires currentQueen and its
// hatchYear to exist, but the relation is still nullable in the type.
if (!colony.currentQueen?.hatchYear) {
continue;
}
const ageYears = currentYear - colony.currentQueen.hatchYear;
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
alerts.push({
type: 'OLD_QUEEN',
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
relatedType: 'Colony',
relatedId: colony.id,
});
}
}
return alerts;
}
}
@@ -0,0 +1,74 @@
import { NotFoundException } from '@nestjs/common';
import { buildColonyAnnualReport } from './annual-reports';
import { PrismaService } from '../prisma/prisma.service';
describe('buildColonyAnnualReport', () => {
let prisma: {
colony: { findUnique: jest.Mock };
inspection: { findMany: jest.Mock };
treatment: { findMany: jest.Mock };
varroaMeasurement: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
harvestColony: { findMany: jest.Mock };
};
const colony = {
id: 'colony-1',
code: 'V-001',
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
apiary: { id: 'apiary-1', name: 'Heimstand' },
};
beforeEach(() => {
prisma = {
colony: { findUnique: jest.fn().mockResolvedValue(colony) },
inspection: { findMany: jest.fn().mockResolvedValue([]) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
harvestColony: { findMany: jest.fn().mockResolvedValue([]) },
};
});
it('throws NotFoundException for an unknown colony', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
buildColonyAnnualReport(
prisma as unknown as PrismaService,
'missing',
2026,
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('carries activeIngredient, batchNumber and withdrawalPeriodDays through for each treatment', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
date: new Date('2026-05-01'),
product: 'Ameisensäure 60%',
amount: '15 ml',
activeIngredient: 'Ameisensäure',
batchNumber: 'CH-2026-01',
withdrawalPeriodDays: 0,
},
]);
const report = await buildColonyAnnualReport(
prisma as unknown as PrismaService,
'colony-1',
2026,
);
expect(report.treatments).toEqual([
{
date: new Date('2026-05-01'),
product: 'Ameisensäure 60%',
amount: '15 ml',
activeIngredient: 'Ameisensäure',
batchNumber: 'CH-2026-01',
withdrawalPeriodDays: 0,
},
]);
});
});
+19 -2
View File
@@ -37,7 +37,14 @@ export interface ColonyAnnualReport {
queenSeen: boolean | null;
broodAmount: string | null;
}[];
treatments: { date: Date; product: string; amount: string | null }[];
treatments: {
date: Date;
product: string;
amount: string | null;
activeIngredient: string | null;
batchNumber: string | null;
withdrawalPeriodDays: number | null;
}[];
varroaMeasurements: {
date: Date;
method: string;
@@ -141,6 +148,9 @@ export async function buildColonyAnnualReport(
date: entry.date,
product: entry.product,
amount: entry.amount,
activeIngredient: entry.activeIngredient,
batchNumber: entry.batchNumber,
withdrawalPeriodDays: entry.withdrawalPeriodDays,
})),
varroaMeasurements: varroaMeasurements.map((entry) => ({
date: entry.date,
@@ -262,7 +272,14 @@ export function renderColonyAnnualReportPdf(
}
for (const entry of report.treatments) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})`,
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})` +
(entry.activeIngredient
? `, Wirkstoff: ${entry.activeIngredient}`
: '') +
(entry.batchNumber ? `, Charge: ${entry.batchNumber}` : '') +
(entry.withdrawalPeriodDays !== null
? `, Wartezeit: ${entry.withdrawalPeriodDays} Tage`
: ''),
);
}
doc.moveDown();
@@ -6,6 +6,8 @@ import {
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class CreateExtractionDto {
@@ -22,6 +24,7 @@ export class CreateExtractionDto {
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@ApiPropertyOptional()
@@ -32,6 +35,8 @@ export class CreateExtractionDto {
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
waterContent?: number;
@ApiPropertyOptional()
@@ -1,13 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateExtractionDto } from './create-extraction.dto';
export class UpdateExtractionDto extends PartialType(
PickType(CreateExtractionDto, [
'date',
'amount',
'room',
'waterContent',
'temperature',
'notes',
] as const),
) {}
/**
* Every field stays correctable, the set of harvests included: a Schleuderung
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the
* only way to repair the traceability chain without deleting the record.
*/
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {}
@@ -12,16 +12,19 @@ describe('ExtractionsService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
extraction: { create: jest.Mock };
extractionHarvest: { createMany: jest.Mock };
extraction: { create: jest.Mock; update: jest.Mock };
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
};
const extraction = { id: 'extraction-1', date: new Date() };
beforeEach(() => {
tx = {
extraction: { create: jest.fn().mockResolvedValue(extraction) },
extractionHarvest: { createMany: jest.fn() },
extraction: {
create: jest.fn().mockResolvedValue(extraction),
update: jest.fn(),
},
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
};
prisma = {
extraction: { findUnique: jest.fn(), update: jest.fn() },
@@ -82,7 +85,6 @@ describe('ExtractionsService', () => {
prisma.extraction.findUnique
.mockResolvedValueOnce(extraction)
.mockResolvedValueOnce(updated);
prisma.extraction.update.mockResolvedValue(updated);
const result = await service.update(
'extraction-1',
@@ -100,5 +102,41 @@ describe('ExtractionsService', () => {
afterData: updated,
}),
);
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
await service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
);
expect(tx.extractionHarvest.deleteMany).toHaveBeenCalledWith({
where: { extractionId: 'extraction-1' },
});
const [[createArg]] = tx.extractionHarvest.createMany.mock.calls as [
[{ data: { extractionId: string; harvestId: string }[] }],
];
expect(createArg.data).toEqual([
{ extractionId: 'extraction-1', harvestId: 'harvest-1' },
{ extractionId: 'extraction-1', harvestId: 'harvest-2' },
]);
});
it('update rejects an unknown harvest id before touching the database', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
await expect(
service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'missing-harvest'] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -89,10 +89,32 @@ export class ExtractionsService {
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.extraction.update({
const { harvestIds, ...scalarFields } = dto;
if (harvestIds) {
await this.assertHarvestsExist(harvestIds);
}
await this.prisma.$transaction(async (tx) => {
await tx.extraction.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
data: {
...scalarFields,
date: dto.date ? new Date(dto.date) : undefined,
},
});
// The join rows carry no data of their own, so replacing the whole set
// is both simpler and safer than diffing it.
if (harvestIds) {
await tx.extractionHarvest.deleteMany({ where: { extractionId: id } });
await tx.extractionHarvest.createMany({
data: harvestIds.map((harvestId) => ({
extractionId: id,
harvestId,
})),
});
}
});
const extraction = await this.findById(id);
await this.auditLog.record({
entityType: 'Extraction',
@@ -5,6 +5,7 @@ import {
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { FeedType } from '../../../generated/prisma';
@@ -28,6 +29,7 @@ export class CreateFeedingDto {
@ApiProperty()
@IsNumber()
@Min(0)
amount!: number;
@ApiProperty()
@@ -1,14 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateFeedingDto } from './create-feeding.dto';
export class UpdateFeedingDto extends PartialType(
PickType(CreateFeedingDto, [
'date',
'feedType',
'product',
'amount',
'unit',
'batchNumber',
'notes',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {}
@@ -7,6 +7,7 @@ import {
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateHarvestDto {
@@ -32,16 +33,19 @@ export class CreateHarvestDto {
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
honeySuperCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeight?: number;
@ApiPropertyOptional()
@@ -1,13 +1,10 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateHarvestDto } from './create-harvest.dto';
export class UpdateHarvestDto extends PartialType(
PickType(CreateHarvestDto, [
'date',
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
] as const),
) {}
/**
* Every field stays correctable, the apiary and the set of colonies included:
* an Ernte booked against the wrong stand or with a colony forgotten is a
* data-entry error, and re-pointing it is the only way to repair the
* traceability chain without deleting the record.
*/
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {}
@@ -14,16 +14,19 @@ describe('HarvestsService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
harvest: { create: jest.Mock };
harvestColony: { createMany: jest.Mock };
harvest: { create: jest.Mock; update: jest.Mock };
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
};
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
beforeEach(() => {
tx = {
harvest: { create: jest.fn().mockResolvedValue(harvest) },
harvestColony: { createMany: jest.fn() },
harvest: {
create: jest.fn().mockResolvedValue(harvest),
update: jest.fn(),
},
harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() },
};
prisma = {
harvest: { findUnique: jest.fn(), update: jest.fn() },
@@ -96,7 +99,6 @@ describe('HarvestsService', () => {
prisma.harvest.findUnique
.mockResolvedValueOnce(harvest)
.mockResolvedValueOnce(updated);
prisma.harvest.update.mockResolvedValue(updated);
const result = await service.update(
'harvest-1',
@@ -105,6 +107,7 @@ describe('HarvestsService', () => {
);
expect(result).toEqual(updated);
expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Harvest',
@@ -115,4 +118,39 @@ describe('HarvestsService', () => {
}),
);
});
it('update replaces the whole colony set when colonyIds is given', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
await service.update(
'harvest-1',
{ colonyIds: ['colony-1', 'colony-2'] },
'user-1',
);
expect(tx.harvestColony.deleteMany).toHaveBeenCalledWith({
where: { harvestId: 'harvest-1' },
});
const [[createArg]] = tx.harvestColony.createMany.mock.calls as [
[{ data: { harvestId: string; colonyId: string }[] }],
];
expect(createArg.data).toEqual([
{ harvestId: 'harvest-1', colonyId: 'colony-1' },
{ harvestId: 'harvest-1', colonyId: 'colony-2' },
]);
});
it('update rejects an unknown colony id before touching the database', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
await expect(
service.update(
'harvest-1',
{ colonyIds: ['colony-1', 'missing-colony'] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -93,10 +93,29 @@ export class HarvestsService {
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.harvest.update({
const { colonyIds, ...scalarFields } = dto;
if (colonyIds) {
await this.assertColoniesExist(colonyIds);
}
await this.prisma.$transaction(async (tx) => {
await tx.harvest.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
data: {
...scalarFields,
date: dto.date ? new Date(dto.date) : undefined,
},
});
// The join rows carry no data of their own, so replacing the whole set
// is both simpler and safer than diffing it.
if (colonyIds) {
await tx.harvestColony.deleteMany({ where: { harvestId: id } });
await tx.harvestColony.createMany({
data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })),
});
}
});
const harvest = await this.findById(id);
await this.auditLog.record({
entityType: 'Harvest',
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateHoneyBatchDto {
@ApiPropertyOptional({
@@ -22,11 +22,14 @@ export class CreateHoneyBatchDto {
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
waterContent?: number;
@ApiPropertyOptional()
@@ -1,9 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator';
import {
IsDateString,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class CreateHoneyMeasurementDto {
@ApiProperty()
@IsNumber()
@Min(0)
@Max(100)
waterContent!: number;
@ApiProperty()
@@ -1,10 +1,33 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { HoneyBatchStatus } from '../../../generated/prisma';
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
export class UpdateHoneyBatchDto extends PartialType(
/**
* `storageContainerId` is editable here too, not only through
* POST /honey-batches/:id/relocate: a batch recorded in the wrong container is
* a data-entry error and must be correctable. The service writes the same
* HoneyBatchRelocation entry the relocate endpoint writes.
*/
class UpdatableHoneyBatchFieldsDto extends PartialType(
PickType(CreateHoneyBatchDto, [
'batchNumber',
'extractionId',
'honeyType',
'amount',
'waterContent',
'storageContainerId',
] as const),
) {}
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
/**
* Set automatically to BOTTLED when the batch is bottled, but a beekeeper
* still has to be able to record that a batch was sold or consumed, or to
* undo a status that was set by mistake.
*/
@ApiPropertyOptional({ enum: HoneyBatchStatus })
@IsOptional()
@IsEnum(HoneyBatchStatus)
status?: HoneyBatchStatus;
}
@@ -0,0 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
/**
* A water-content reading typed in wrong has to be correctable: it decides
* whether a batch may be sold as honey at all, so a wrong figure left standing
* is worse than one that was never recorded.
*/
export class UpdateHoneyMeasurementDto extends PartialType(
CreateHoneyMeasurementDto,
) {}
@@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { HoneyBatchesService } from './honey-batches.service';
@@ -117,6 +118,20 @@ export class HoneyBatchesController {
}
@RequirePermission('honey-batches:measure')
@RequirePermission('honey-batches:measure')
@Patch('measurements/:measurementId')
updateMeasurement(
@Param('measurementId') measurementId: string,
@Body() dto: UpdateHoneyMeasurementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.updateMeasurement(
measurementId,
dto,
actor.id,
);
}
@Post(':id/measurements')
addMeasurement(
@Param('id') id: string,
@@ -14,7 +14,11 @@ describe('HoneyBatchesService', () => {
create: jest.Mock;
update: jest.Mock;
};
honeyMeasurement: { create: jest.Mock };
honeyMeasurement: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
honeyBatchContainerAllocation: {
@@ -60,7 +64,11 @@ describe('HoneyBatchesService', () => {
create: jest.fn(),
update: jest.fn(),
},
honeyMeasurement: { create: jest.fn() },
honeyMeasurement: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
honeyBatchContainerAllocation: {
@@ -354,4 +362,85 @@ describe('HoneyBatchesService', () => {
);
});
});
describe('update', () => {
beforeEach(() => {
prisma.$transaction.mockImplementation(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
);
});
it('records a relocation when an edit corrects the storage container', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
storageContainerId: 'container-1',
});
await service.update(
'batch-1',
{ storageContainerId: 'container-2' },
'user-1',
);
expect(prisma.honeyBatchRelocation.create).toHaveBeenCalledWith({
data: {
honeyBatchId: 'batch-1',
fromStorageContainerId: 'container-1',
toStorageContainerId: 'container-2',
userId: 'user-1',
notes: 'Korrektur über Honigcharge bearbeiten',
},
});
});
it('records no relocation when an edit leaves the container untouched', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
storageContainerId: 'container-1',
});
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
});
});
it('updateMeasurement corrects a reading and logs before/after', async () => {
const before = {
id: 'measurement-1',
honeyBatchId: 'batch-1',
waterContent: 22,
};
prisma.honeyMeasurement.findUnique.mockResolvedValue(before);
prisma.honeyMeasurement.update.mockResolvedValue({
...before,
waterContent: 17.5,
});
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
await service.updateMeasurement(
'measurement-1',
{ waterContent: 17.5 },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyMeasurement',
entityId: 'measurement-1',
action: 'UPDATE',
beforeData: before,
}),
);
});
it('updateMeasurement throws NotFoundException for an unknown reading', async () => {
prisma.honeyMeasurement.findUnique.mockResolvedValue(null);
await expect(
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -11,6 +11,7 @@ import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
@@ -86,10 +87,30 @@ export class HoneyBatchesService {
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.honeyBatch.update({
const movesContainer =
dto.storageContainerId !== undefined &&
dto.storageContainerId !== before.storageContainerId;
await this.prisma.$transaction(async (tx) => {
await tx.honeyBatch.update({
where: { id },
data: { ...dto },
});
// Correcting the container has to leave the same trace a deliberate
// relocation does - otherwise the batch's storage history would skip a
// container it was recorded in.
if (movesContainer) {
await tx.honeyBatchRelocation.create({
data: {
honeyBatchId: id,
fromStorageContainerId: before.storageContainerId,
toStorageContainerId: dto.storageContainerId!,
userId: actingUserId,
notes: 'Korrektur über Honigcharge bearbeiten',
},
});
}
});
const updated = await this.findById(id);
await this.auditLog.record({
entityType: 'HoneyBatch',
@@ -102,6 +123,35 @@ export class HoneyBatchesService {
return updated;
}
async updateMeasurement(
measurementId: string,
dto: UpdateHoneyMeasurementDto,
actingUserId: string,
) {
const before = await this.prisma.honeyMeasurement.findUnique({
where: { id: measurementId },
});
if (!before) {
throw new NotFoundException('Honey measurement not found');
}
const measurement = await this.prisma.honeyMeasurement.update({
where: { id: measurementId },
data: {
...dto,
measuredAt: dto.measuredAt ? new Date(dto.measuredAt) : undefined,
},
});
await this.auditLog.record({
entityType: 'HoneyMeasurement',
entityId: measurementId,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: measurement,
});
return this.findById(before.honeyBatchId);
}
async addMeasurement(
id: string,
dto: CreateHoneyMeasurementDto,
@@ -1,27 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateInspectionDto } from './create-inspection.dto';
export class UpdateInspectionDto extends PartialType(
PickType(CreateInspectionDto, [
'date',
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {}
@@ -0,0 +1,10 @@
import { PartialType } from '@nestjs/swagger';
import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
/**
* Breeding decisions are made from these scores, so a rating entered against
* the wrong queen or with a slipped digit has to be correctable.
*/
export class UpdateQueenEvaluationDto extends PartialType(
CreateQueenEvaluationDto,
) {}
@@ -16,6 +16,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto';
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
import { QueensService } from './queens.service';
@ApiTags('queens')
@@ -81,6 +82,16 @@ export class QueensController {
}
@RequirePermission('queens:evaluate')
@RequirePermission('queens:evaluate')
@Patch('evaluations/:evaluationId')
updateEvaluation(
@Param('evaluationId') evaluationId: string,
@Body() dto: UpdateQueenEvaluationDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.queensService.updateEvaluation(evaluationId, dto, actor.id);
}
@Post(':id/evaluations')
addEvaluation(
@Param('id') id: string,
@@ -1,4 +1,8 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { QueensService } from './queens.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -8,8 +12,9 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
describe('QueensService', () => {
let service: QueensService;
let tx: {
queen: { update: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
colony: { update: jest.Mock };
colony: { findUnique: jest.Mock; update: jest.Mock };
};
let prisma: {
queen: {
@@ -30,8 +35,12 @@ describe('QueensService', () => {
beforeEach(() => {
tx = {
queen: { update: jest.fn().mockResolvedValue(queen) },
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
colony: { update: jest.fn() },
colony: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
};
prisma = {
queen: {
@@ -73,6 +82,17 @@ describe('QueensService', () => {
).rejects.toBeInstanceOf(NotFoundException);
});
it('assignColony rejects a queen who is no longer available', async () => {
prisma.queen.findUnique.mockResolvedValue({
...queen,
status: 'DECEASED',
});
await expect(
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
});
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue({
@@ -163,6 +183,64 @@ describe('QueensService', () => {
expect(prisma.queen.update).not.toHaveBeenCalled();
});
describe('when a queen becomes unavailable', () => {
it('clears currentQueenId and marks the colony QUEENLESS', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue({
id: 'colony-1',
status: 'ACTIVE',
});
await service.update('queen-1', { status: 'DECEASED' }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { currentQueenId: null, status: 'QUEENLESS' },
});
const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[{ where: { queenId: string; colonyId: string; endDate: null } }],
];
expect(closeArg.where).toEqual({
queenId: 'queen-1',
colonyId: 'colony-1',
endDate: null,
});
});
it('clears currentQueenId but leaves an already-terminal colony status alone', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue({
id: 'colony-1',
status: 'DISSOLVED',
});
await service.update('queen-1', { status: 'LOST' }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { currentQueenId: null, status: undefined },
});
});
it('does nothing when the queen is not currently assigned to a colony', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue(null);
await service.update('queen-1', { status: 'SOLD' }, 'user-1');
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('does not touch the colony for a status change that keeps the queen available', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
await service.update('queen-1', { status: 'RESERVE' }, 'user-1');
expect(tx.colony.findUnique).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
});
});
it('getLineage builds the ancestor chain and the descendant tree', async () => {
const grandmother = {
id: 'queen-gm',
+75 -1
View File
@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -12,6 +13,7 @@ import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto';
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
export interface QueenFilter {
status?: QueenStatus;
@@ -40,6 +42,19 @@ export interface QueenLineage {
const MAX_LINEAGE_DEPTH = 8;
// Once a queen reaches one of these statuses she is no longer really "in"
// whatever colony currently points at her - a colony's currentQueenId and
// status would otherwise silently go stale (still showing a dead/lost
// queen as current, never flagged QUEENLESS) until someone happens to
// notice and fix it by hand.
const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [
'LOST',
'DECEASED',
'REPLACED',
'SOLD',
];
const TERMINAL_COLONY_STATUSES = ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'];
@Injectable()
export class QueensService {
constructor(
@@ -92,10 +107,38 @@ export class QueensService {
}
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.update({
const becameUnavailable =
dto.status !== undefined &&
dto.status !== before.status &&
QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(dto.status);
const queen = await this.prisma.$transaction(async (tx) => {
const updated = await tx.queen.update({
where: { id },
data: { ...dto },
});
if (becameUnavailable) {
const colony = await tx.colony.findUnique({
where: { currentQueenId: id },
});
if (colony) {
await tx.colony.update({
where: { id: colony.id },
data: {
currentQueenId: null,
status: TERMINAL_COLONY_STATUSES.includes(colony.status)
? undefined
: 'QUEENLESS',
},
});
await tx.queenColonyHistory.updateMany({
where: { queenId: id, colonyId: colony.id, endDate: null },
data: { endDate: new Date() },
});
}
}
return updated;
});
await this.auditLog.record({
entityType: 'Queen',
entityId: id,
@@ -113,6 +156,11 @@ export class QueensService {
actingUserId: string,
): Promise<Queen> {
const queen = await this.findById(id);
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await this.prisma.colony.findUnique({
where: { id: dto.colonyId },
});
@@ -180,6 +228,32 @@ export class QueensService {
});
}
async updateEvaluation(
evaluationId: string,
dto: UpdateQueenEvaluationDto,
actingUserId: string,
) {
const before = await this.prisma.queenEvaluation.findUnique({
where: { id: evaluationId },
});
if (!before) {
throw new NotFoundException('Queen evaluation not found');
}
const evaluation = await this.prisma.queenEvaluation.update({
where: { id: evaluationId },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'QueenEvaluation',
entityId: evaluationId,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: evaluation,
});
return evaluation;
}
async addEvaluation(
id: string,
dto: CreateQueenEvaluationDto,
@@ -15,6 +15,18 @@ import {
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
export class UpdateRoleDto {
/**
* Correctable like every other field. System roles are out of reach anyway:
* RolesService.update rejects them outright, which also keeps the seed's
* lookup of the ADMIN role by key intact.
*/
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(50)
key?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
+1 -1
View File
@@ -158,7 +158,7 @@ export class RolesService {
const role = await this.prisma.$transaction(async (tx) => {
await tx.role.update({
where: { id },
data: { name: dto.name, description: dto.description },
data: { key: dto.key, name: dto.name, description: dto.description },
});
if (dto.permissionKeys) {
await this.replacePermissions(tx, id, dto.permissionKeys);
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { StockCategory } from '../../../generated/prisma';
export class CreateStockItemDto {
@@ -18,11 +18,13 @@ export class CreateStockItemDto {
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
minThreshold?: number;
@ApiPropertyOptional()
@@ -7,7 +7,11 @@ export class CreateStockMovementDto {
@IsEnum(StockMovementType)
type!: StockMovementType;
@ApiProperty()
@ApiProperty({
description:
'Für IN/OUT immer positiv angeben - die Richtung ergibt sich aus "type". ' +
'Nur ADJUSTMENT erlaubt einen negativen Wert, um den Bestand direkt zu korrigieren.',
})
@IsNumber()
quantity!: number;
@@ -1,12 +1,10 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateStockItemDto } from './create-stock-item.dto';
export class UpdateStockItemDto extends PartialType(
PickType(CreateStockItemDto, [
'name',
'category',
'unit',
'minThreshold',
'notes',
] as const),
) {}
/**
* `quantity` is editable here, not only through stock movements: a stock level
* typed in wrong has to be correctable directly. The service books the
* difference as an ADJUSTMENT movement, so the movement history still adds up
* to the stored quantity and nothing is corrected behind its back.
*/
export class UpdateStockItemDto extends PartialType(CreateStockItemDto) {}
@@ -3,6 +3,7 @@ import { CreateStorageContainerDto } from './create-storage-container.dto';
export class UpdateStorageContainerDto extends PartialType(
PickType(CreateStorageContainerDto, [
'code',
'containerType',
'capacity',
'location',
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { StorageService } from './storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -100,4 +100,60 @@ describe('StorageService', () => {
expect(tx.stockItem.update).not.toHaveBeenCalled();
});
it('addMovement rejects a negative quantity for OUT instead of silently adding stock', async () => {
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
await expect(
service.addMovement('item-1', { type: 'OUT', quantity: -3 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.stockItem.update).not.toHaveBeenCalled();
});
it('addMovement rejects a negative quantity for IN', async () => {
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
await expect(
service.addMovement('item-1', { type: 'IN', quantity: -3 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('addMovement allows a negative quantity for ADJUSTMENT', async () => {
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
await service.addMovement(
'item-1',
{ type: 'ADJUSTMENT', quantity: -3 },
'user-1',
);
const [[updateArg]] = tx.stockItem.update.mock.calls as [
[{ data: { quantity: { increment: number } } }],
];
expect(updateArg.data.quantity.increment).toBe(-3);
});
it('books the difference as an ADJUSTMENT movement when the stock quantity is corrected', async () => {
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
await service.updateStockItem('item-1', { quantity: 7 }, 'user-1');
expect(tx.stockMovement.create).toHaveBeenCalledWith({
data: {
stockItemId: 'item-1',
type: 'ADJUSTMENT',
quantity: -3,
userId: 'user-1',
notes: 'Korrektur über Artikel bearbeiten',
},
});
});
it('books no movement when an edit leaves the stock quantity untouched', async () => {
prisma.stockItem.findUnique.mockResolvedValue(stockItem);
await service.updateStockItem('item-1', { name: 'Futtersirup' }, 'user-1');
expect(tx.stockMovement.create).not.toHaveBeenCalled();
});
});
+36 -2
View File
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
@@ -102,10 +106,30 @@ export class StorageService {
actingUserId: string,
): Promise<StockItem> {
const before = await this.findStockItemById(id);
const stockItem = await this.prisma.stockItem.update({
const correction =
dto.quantity === undefined ? 0 : dto.quantity - before.quantity;
const stockItem = await this.prisma.$transaction(async (tx) => {
const updated = await tx.stockItem.update({
where: { id },
data: { ...dto },
});
// Booking the difference keeps the movement history consistent with the
// stored quantity - without it, a corrected stock level would no longer
// be the sum of its movements.
if (correction !== 0) {
await tx.stockMovement.create({
data: {
stockItemId: id,
type: 'ADJUSTMENT',
quantity: correction,
userId: actingUserId,
notes: 'Korrektur über Artikel bearbeiten',
},
});
}
return updated;
});
await this.auditLog.record({
entityType: 'StockItem',
entityId: id,
@@ -123,6 +147,16 @@ export class StorageService {
actingUserId: string,
) {
await this.findStockItemById(id);
// IN/OUT direction comes entirely from `type`, which then negates
// quantity for OUT below - a negative quantity submitted for OUT would
// therefore silently ADD stock while being logged as a removal,
// inverting the audit trail. Only ADJUSTMENT may be negative, since
// that is the only way to correct a stock count downward.
if (dto.type !== 'ADJUSTMENT' && dto.quantity <= 0) {
throw new BadRequestException(
'Menge muss bei Eingang/Ausgang positiv sein - die Richtung wird durch den Bewegungstyp bestimmt',
);
}
const delta =
dto.type === 'IN' || dto.type === 'ADJUSTMENT'
? dto.quantity
@@ -1,14 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateSwarmControlCheckDto extends PartialType(
PickType(CreateSwarmControlCheckDto, [
'date',
'queenCellsFound',
'queenCellsCapped',
'swarmMood',
'actionTaken',
'notes',
'nextCheckDate',
] as const),
CreateSwarmControlCheckDto,
) {}
@@ -1,6 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskPriority, TaskStatus } from '../../../generated/prisma';
import {
TaskPriority,
TaskRelatedType,
TaskStatus,
} from '../../../generated/prisma';
export class UpdateTaskDto {
@ApiPropertyOptional()
@@ -32,4 +36,14 @@ export class UpdateTaskDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@ApiPropertyOptional({ enum: TaskRelatedType })
@IsOptional()
@IsEnum(TaskRelatedType)
relatedType?: TaskRelatedType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
relatedId?: string;
}
@@ -1,5 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateTreatmentDto {
@ApiProperty()
@@ -53,4 +59,13 @@ export class CreateTreatmentDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Wartezeit in Tagen ab Behandlungsende, bevor Honig aus diesem Volk geerntet werden darf.',
})
@IsOptional()
@IsInt()
@Min(0)
withdrawalPeriodDays?: number;
}
@@ -1,17 +1,9 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateTreatmentDto } from './create-treatment.dto';
export class UpdateTreatmentDto extends PartialType(
PickType(CreateTreatmentDto, [
'date',
'startDate',
'endDate',
'product',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'notes',
] as const),
) {}
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {}
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -19,6 +27,13 @@ export class TreatmentsController {
return this.treatmentsService.findAll();
}
@RequirePermission('treatments:read')
@Get('withdrawal-status')
getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) {
const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : [];
return this.treatmentsService.getWithdrawalStatus(ids);
}
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -97,4 +97,58 @@ describe('TreatmentsService', () => {
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
describe('getWithdrawalStatus', () => {
it('returns an empty array without querying when no colony ids are given', async () => {
const result = await service.getWithdrawalStatus([]);
expect(result).toEqual([]);
expect(prisma.treatment.findMany).not.toHaveBeenCalled();
});
it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
const now = Date.now();
const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000);
const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000);
prisma.treatment.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: inTenDays,
endDate: null,
withdrawalPeriodDays: 0,
product: 'Ameisensäure',
},
{
colonyId: 'colony-1',
date: inFiveDays,
endDate: null,
withdrawalPeriodDays: 21,
product: 'Api Bioxal',
},
]);
const result = await service.getWithdrawalStatus(['colony-1']);
expect(result).toHaveLength(1);
expect(result[0].colonyId).toBe('colony-1');
expect(result[0].product).toBe('Api Bioxal');
});
it('omits a colony whose withdrawal window has already passed', async () => {
const longAgo = new Date('2020-01-01');
prisma.treatment.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: longAgo,
endDate: null,
withdrawalPeriodDays: 14,
product: 'Ameisensäure',
},
]);
const result = await service.getWithdrawalStatus(['colony-1']);
expect(result).toEqual([]);
});
});
});
@@ -6,6 +6,12 @@ import { Treatment } from '../../generated/prisma';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
export interface WithdrawalStatus {
colonyId: string;
activeUntil: string;
product: string;
}
@Injectable()
export class TreatmentsService {
constructor(
@@ -33,6 +39,44 @@ export class TreatmentsService {
});
}
// For each colony, returns the treatment whose withdrawal window (its
// end date, or start date if the treatment is still ongoing, plus its
// withdrawalPeriodDays) reaches furthest into the future - only when
// that window is still active. Colonies with no active window are
// omitted rather than returned as null, since most colonies have none.
async getWithdrawalStatus(colonyIds: string[]): Promise<WithdrawalStatus[]> {
if (colonyIds.length === 0) {
return [];
}
const treatments = await this.prisma.treatment.findMany({
where: {
colonyId: { in: colonyIds },
withdrawalPeriodDays: { not: null },
},
});
const now = new Date();
const activeByColony = new Map<string, WithdrawalStatus>();
for (const treatment of treatments) {
const referenceDate = treatment.endDate ?? treatment.date;
const activeUntil = new Date(referenceDate);
activeUntil.setUTCDate(
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
);
if (activeUntil <= now) {
continue;
}
const current = activeByColony.get(treatment.colonyId);
if (!current || activeUntil > new Date(current.activeUntil)) {
activeByColony.set(treatment.colonyId, {
colonyId: treatment.colonyId,
activeUntil: activeUntil.toISOString(),
product: treatment.product,
});
}
}
return [...activeByColony.values()];
}
async create(
dto: CreateTreatmentDto,
actingUserId: string,
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
/**
* An administrator setting a new password for someone else. There is no
* self-service password-reset flow, so without this a user who forgets their
* password is locked out of the account for good - the one correction the
* generic user update cannot make, since the password is never readable.
*/
export class ResetUserPasswordDto {
@ApiProperty({ minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
}
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateUserDto {
@ApiPropertyOptional()
@@ -7,6 +7,18 @@ export class UpdateUserDto {
@IsString()
name?: string;
/**
* The e-mail is the login identity, so changing it changes which address
* signs in as this account. It is still correctable: a typo would otherwise
* lock the account out for good, and there is no password-reset flow that a
* changed address could be abused to trigger. A duplicate is rejected by the
* unique constraint and surfaces as a 409.
*/
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ description: 'Id of an active Role' })
@IsOptional()
@IsString()
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UsersService } from './users.service';
@ApiTags('users')
@@ -53,6 +54,16 @@ export class UsersController {
return this.usersService.activate(id, actor.id);
}
@RequirePermission('users:update')
@Post(':id/reset-password')
resetPassword(
@Param('id') id: string,
@Body() dto: ResetUserPasswordDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.usersService.resetPassword(id, dto, actor.id);
}
@RequirePermission('users:update')
@Post(':id/mfa/reset')
resetMfa(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
@@ -21,6 +21,7 @@ describe('UsersService', () => {
role: {
findUnique: jest.Mock;
};
refreshToken: { updateMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let permissionResolver: {
@@ -77,6 +78,7 @@ describe('UsersService', () => {
role: {
findUnique: jest.fn().mockResolvedValue(staffRole),
},
refreshToken: { updateMany: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
@@ -379,4 +381,53 @@ describe('UsersService', () => {
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
describe('resetPassword', () => {
it('revokes every refresh token and clears the permission cache', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.user.update.mockResolvedValue(existingUser);
await usersService.resetPassword(
'user-1',
{ password: 'neuesPasswort1' },
'user-admin',
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', revokedAt: null },
data: { revokedAt: expect.any(Date) as Date },
});
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith('user-1');
});
it('stores a hash, never the password itself', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.user.update.mockResolvedValue(existingUser);
await usersService.resetPassword(
'user-1',
{ password: 'neuesPasswort1' },
'user-admin',
);
const [[updateArgs]] = prisma.user.update.mock.calls as [
[{ data: { passwordHash: string } }],
];
expect(updateArgs.data.passwordHash).not.toBe('neuesPasswort1');
expect(updateArgs.data.passwordHash).toMatch(/^\$2[aby]\$/);
});
it('refuses to reset your own password, which belongs to /profile/password', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
await expect(
usersService.resetPassword(
'user-1',
{ password: 'neuesPasswort1' },
'user-1',
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
});
@@ -11,6 +11,7 @@ import { PermissionResolverService } from '../auth/permissions/permission-resolv
import { Prisma } from '../../generated/prisma';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
const BCRYPT_SALT_ROUNDS = 12;
@@ -194,6 +195,7 @@ export class UsersService {
where: { id },
data: {
name: dto.name,
email: dto.email,
roleId: dto.roleId,
},
...USER_WITH_ROLE,
@@ -240,6 +242,49 @@ export class UsersService {
* a stolen access token alone (no password) would be enough to strip the
* victim's own MFA protection via this admin route.
*/
/**
* Sets a new password for another user. Mirrors ProfileService.changePassword
* in what it invalidates: every refresh token is revoked and the permission
* cache is cleared, so sessions opened with the old password stop working at
* once rather than living on until their access token expires.
*
* Your own password goes through /profile/password, which requires the
* current one - otherwise anyone holding users:update could silently take
* over their own account's credentials without proving they own it.
*/
async resetPassword(
id: string,
dto: ResetUserPasswordDto,
actingUserId?: string,
): Promise<UserSummary> {
if (actingUserId && id === actingUserId) {
throw new ForbiddenException(
'Use /profile/password to change your own password',
);
}
await this.findById(id);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.update({
where: { id },
data: { passwordHash, passwordChangedAt: new Date() },
...USER_WITH_ROLE,
});
await this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
this.permissionResolver.invalidateUser(id);
// The new password never reaches the audit log - only the fact that it
// was replaced, and by whom.
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
});
return this.toSummary(user);
}
async resetMfa(id: string, actingUserId?: string): Promise<UserSummary> {
if (actingUserId && id === actingUserId) {
throw new ForbiddenException(
@@ -6,6 +6,7 @@ import {
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { VarroaMethod } from '../../../generated/prisma';
@@ -25,16 +26,19 @@ export class CreateVarroaMeasurementDto {
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
durationMinutes?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
miteCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
calculatedValue?: number;
@ApiPropertyOptional()
@@ -1,14 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { PartialType } from '@nestjs/swagger';
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
*/
export class UpdateVarroaMeasurementDto extends PartialType(
PickType(CreateVarroaMeasurementDto, [
'date',
'method',
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
] as const),
CreateVarroaMeasurementDto,
) {}
@@ -100,4 +100,106 @@ describe('VarroaService', () => {
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
describe('calculatedValue auto-computation', () => {
it('computes mites/day for a natural mite fall count on create', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(15);
});
it('does not compute calculatedValue for a non-time-based method', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'POWDERED_SUGAR',
miteCount: 8,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBeUndefined();
});
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 99,
},
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(99);
});
it('backfills calculatedValue on update when it was never set', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: null,
durationMinutes: null,
calculatedValue: null,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update(
'varroa-1',
{ miteCount: 20, durationMinutes: 24 * 60 },
'user-1',
);
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBe(20);
});
it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 15,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update('varroa-1', { assessment: 'stabil' }, 'user-1');
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBeUndefined();
});
});
});
+54 -2
View File
@@ -6,6 +6,30 @@ import { VarroaMeasurement } from '../../generated/prisma';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60;
// Natural mite fall is counted on a sticky-board insert left in the hive
// for some duration and is conventionally normalized to a daily rate
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
// (thresholdMitesPerDay) and the annual PDF report already assume
// calculatedValue is in that unit. The other methods (Puderzucker,
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
// metric (mites per fixed bee sample); there is no single verified
// formula for those here, so calculatedValue is left for the user to
// enter manually for those methods instead of guessing one.
function computeNaturalMiteFallPerDay(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
): number | undefined {
if (miteCount === null || miteCount === undefined) {
return undefined;
}
if (!durationMinutes || durationMinutes <= 0) {
return undefined;
}
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
}
@Injectable()
export class VarroaService {
constructor(
@@ -41,8 +65,18 @@ export class VarroaService {
dto: CreateVarroaMeasurementDto,
actingUserId: string,
): Promise<VarroaMeasurement> {
const calculatedValue =
dto.calculatedValue ??
(dto.method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
: undefined);
const measurement = await this.prisma.varroaMeasurement.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
data: {
...dto,
calculatedValue,
date: new Date(dto.date),
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
@@ -61,9 +95,27 @@ export class VarroaService {
actingUserId: string,
): Promise<VarroaMeasurement> {
const before = await this.findById(id);
// Only auto-fill calculatedValue when nothing has set it yet - never
// overwrite a value the user (or a prior auto-fill) already stored,
// even if this edit only touches an unrelated field like notes.
const shouldAutoCompute =
dto.calculatedValue === undefined && before.calculatedValue === null;
const method = dto.method ?? before.method;
const calculatedValue =
dto.calculatedValue ??
(shouldAutoCompute && method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(
dto.miteCount ?? before.miteCount,
dto.durationMinutes ?? before.durationMinutes,
)
: undefined);
const measurement = await this.prisma.varroaMeasurement.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
data: {
...dto,
calculatedValue,
date: dto.date ? new Date(dto.date) : undefined,
},
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
@@ -8,6 +8,7 @@ function openMeteoResponse(
tempMax: number | null;
tempMin: number | null;
precipitation: number | null;
wind?: number | null;
}[],
): unknown {
return {
@@ -16,6 +17,7 @@ function openMeteoResponse(
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
precipitation_sum: days.map((day) => day.precipitation),
wind_speed_10m_max: days.map((day) => day.wind ?? 10),
},
};
}
@@ -83,13 +85,68 @@ describe('WeatherService', () => {
const result = await weatherService.getWeather('apiary-1');
expect(result.daily).toEqual([
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
{
date: '2026-06-01',
tempMaxC: 22,
tempMinC: 12,
precipitationMm: 0,
windSpeedMaxKmh: 10,
inspectionSuitable: true,
},
]);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.toString()).toContain('latitude=52.5');
expect(url.toString()).toContain('longitude=13.4');
});
it.each([
['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }],
['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }],
['too windy', { tempMax: 20, tempMin: 12, precipitation: 0, wind: 40 }],
])('marks a day unsuitable for inspection when %s', async (_label, day) => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(openMeteoResponse([{ date: '2026-06-01', ...day }])),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].inspectionSuitable).toBe(false);
});
it('leaves inspectionSuitable null when a required value is missing', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: null,
tempMin: null,
precipitation: 0,
wind: 5,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].inspectionSuitable).toBeNull();
});
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
+40 -7
View File
@@ -15,11 +15,23 @@ const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
const POOR_TEMP_THRESHOLD_C = 12;
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
// Rule-of-thumb minimums for opening a hive without unduly stressing the
// colony or making the inspection unpleasant/risky for the beekeeper:
// below ~14-15°C bees cluster and brood chills when the hive is opened,
// noticeable rain and strong wind both make bees more defensive and the
// frames harder to handle safely.
const INSPECTION_MIN_TEMP_C = 15;
const INSPECTION_MAX_PRECIPITATION_MM = 1;
const INSPECTION_MAX_WIND_KMH = 25;
export interface DailyWeather {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
// null when a required value is missing (rather than a misleading guess).
inspectionSuitable: boolean | null;
}
export interface ApiaryWeather {
@@ -41,6 +53,7 @@ interface OpenMeteoDaily {
temperature_2m_max: (number | null)[];
temperature_2m_min: (number | null)[];
precipitation_sum: (number | null)[];
wind_speed_10m_max: (number | null)[];
}
interface OpenMeteoResponse {
@@ -95,8 +108,9 @@ export class WeatherService {
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
);
url.searchParams.set('wind_speed_unit', 'kmh');
url.searchParams.set('past_days', String(PAST_DAYS));
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
url.searchParams.set('timezone', 'auto');
@@ -116,14 +130,33 @@ export class WeatherService {
if (!body.daily) {
return [];
}
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
body.daily;
return time.map((date, index) => ({
const {
time,
temperature_2m_max,
temperature_2m_min,
precipitation_sum,
wind_speed_10m_max,
} = body.daily;
return time.map((date, index) => {
const tempMaxC = temperature_2m_max[index] ?? null;
const precipitationMm = precipitation_sum[index] ?? null;
const windSpeedMaxKmh = wind_speed_10m_max[index] ?? null;
return {
date,
tempMaxC: temperature_2m_max[index] ?? null,
tempMaxC,
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm: precipitation_sum[index] ?? null,
}));
precipitationMm,
windSpeedMaxKmh,
inspectionSuitable:
tempMaxC === null ||
precipitationMm === null ||
windSpeedMaxKmh === null
? null
: tempMaxC >= INSPECTION_MIN_TEMP_C &&
precipitationMm <= INSPECTION_MAX_PRECIPITATION_MM &&
windSpeedMaxKmh <= INSPECTION_MAX_WIND_KMH,
};
});
}
private getSeason(month: number): { season: string; inBloom: boolean } {
+28 -3
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "apicore-shell-v2";
const CACHE_NAME = "apicore-shell-v3";
self.addEventListener("install", () => {
self.skipWaiting();
@@ -23,14 +23,33 @@ self.addEventListener("fetch", (event) => {
}
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith("/api")) {
// /api/runtime-config tells the app which backend origin to use - it
// must always be fetched live. A stale cached value would point the app
// at the wrong (or no longer running) backend, which is worse than no
// cache at all.
if (url.pathname === "/api/runtime-config") {
return;
}
// Same-origin static assets/pages, or the real backend API. The backend
// runs on its own origin (API_URL, injected at runtime via
// /api/runtime-config) rather than this app's own origin, so it can't be
// matched by origin the way static assets are - matched by path instead,
// since every backend call goes through /api/v1/. Without this, colonies,
// apiaries, tasks etc. were never cached at all: a beekeeper opening the
// app with no signal at an apiary saw nothing, not even data they had
// already loaded minutes earlier on the same device.
const isCacheable =
url.origin === self.location.origin || url.pathname.includes("/api/v1/");
if (!isCacheable) {
return;
}
// Page navigations must never be answered with a synthetic Response.error() -
// Chrome/Edge render that as a native "This page couldn't load" interstitial
// instead of the app. Let the browser handle navigations directly; only
// static assets (JS/CSS/images) go through the offline-cache fallback below.
// static assets/API data go through the offline-cache fallback below.
if (request.mode === "navigate") {
return;
}
@@ -38,6 +57,12 @@ self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(request)
.then((response) => {
// Never cache an error response (e.g. a 401 hit right as an access
// token expired, before refresh could kick in) - it would otherwise
// get served back later as if it were still the current state.
if (!response.ok) {
return response;
}
const responseClone = response.clone();
return caches
.open(CACHE_NAME)
@@ -5,6 +5,7 @@ import { MapPin } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api";
import { ApiError } from "@/lib/auth/api-client";
import { Apiary } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -22,6 +23,7 @@ import {
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const queryClient = useQueryClient();
const [name, setName] = useState(apiary.name);
const [description, setDescription] = useState(apiary.description ?? "");
const [location, setLocation] = useState(apiary.location ?? "");
const [latitude, setLatitude] = useState(apiary.latitude?.toString() ?? "");
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
@@ -32,6 +34,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
mutationFn: () =>
updateApiary(apiary.id, {
name,
description: description || undefined,
location: location || undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
@@ -41,6 +44,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
},
onError: () => toast.error("Bienenstand konnte nicht gespeichert werden"),
});
const deactivateMutation = useMutation({
@@ -51,7 +55,10 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
description: "Er kann jederzeit über „Reaktivieren“ wiederhergestellt werden.",
});
},
onError: () => toast.error("Bienenstand konnte nicht deaktiviert werden"),
onError: (error) =>
toast.error(
error instanceof ApiError ? error.message : "Bienenstand konnte nicht deaktiviert werden",
),
});
const reactivateMutation = useMutation({
@@ -95,6 +102,14 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-description">Beschreibung</Label>
<Textarea
id="apiary-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-location">Standort</Label>
<Input
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
@@ -22,6 +23,7 @@ export default function ApiaryDetailPage() {
const exportReportMutation = useMutation({
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
});
if (isLoading) {
@@ -1,6 +1,6 @@
"use client";
import { CloudSun } from "lucide-react";
import { CloudSun, Wind, CheckCircle2, XCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
import { TrachtprognoseRating } from "@/lib/weather/types";
@@ -73,6 +73,7 @@ export function WeatherCard({
const today = new Date().toISOString().slice(0, 10);
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null;
return (
<Card>
@@ -104,6 +105,27 @@ export function WeatherCard({
{weatherLoading ? (
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
) : forecastDays.length > 0 ? (
<>
{todaySuitability !== null ? (
<div
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
todaySuitability
? "border-primary/30 bg-primary/5 text-primary"
: "border-warning/40 bg-warning/10 text-warning-foreground"
}`}
>
{todaySuitability ? (
<CheckCircle2 className="size-4.5 shrink-0" />
) : (
<XCircle className="size-4.5 shrink-0" />
)}
<span>
{todaySuitability
? "Heute gut für eine Durchsicht geeignet."
: "Heute eher ungeeignet für eine Durchsicht (zu kalt, nass oder windig)."}
</span>
</div>
) : null}
<div className="grid grid-cols-3 gap-2 border-t border-border/50 pt-3 sm:grid-cols-7">
{forecastDays.map((day) => (
<div key={day.date} className="flex flex-col items-center gap-0.5 text-center">
@@ -121,9 +143,16 @@ export function WeatherCard({
{day.precipitationMm.toFixed(0)} mm
</span>
) : null}
{day.windSpeedMaxKmh !== null ? (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<Wind className="size-3" />
{Math.round(day.windSpeedMaxKmh)}
</span>
) : null}
</div>
))}
</div>
</>
) : null}
</CardContent>
</Card>
@@ -0,0 +1,48 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deleteBottlingBatch } from "@/lib/bottling/api";
import { BottlingBatch } from "@/lib/bottling/types";
import { ConfirmDialog } from "@/components/confirm-dialog";
/**
* Deleting a bottling batch is the correction path for a mis-entered record:
* the bottled amount goes back to the honey batch and the full row is kept in
* the audit log. It also retires the lot number, so the public origin page
* /herkunft/<lotNumber> stops resolving - hence the explicit confirmation.
*/
export function BottlingDeleteButton({
bottlingBatch,
}: {
bottlingBatch: BottlingBatch;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => deleteBottlingBatch(bottlingBatch.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success("Abfüllung gelöscht");
router.push("/bottling");
},
onError: (error: Error) =>
toast.error(error.message || "Abfüllung konnte nicht gelöscht werden"),
});
return (
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
title="Abfüllung löschen"
description={`Abfüllung ${bottlingBatch.lotNumber} wird entfernt und ${bottlingBatch.totalAmount} kg werden der Honigcharge ${bottlingBatch.honeyBatch.batchNumber} wieder gutgeschrieben. Die öffentliche Herkunftsseite zu dieser Los-/Chargennummer ist danach nicht mehr erreichbar.`}
confirmLabel="Endgültig löschen"
onConfirm={() => mutation.mutate()}
isPending={mutation.isPending}
/>
);
}
@@ -2,12 +2,22 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateBottlingBatch } from "@/lib/bottling/api";
import { BottlingBatch } from "@/lib/bottling/types";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -16,34 +26,19 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingBatch }) {
const queryClient = useQueryClient();
/**
* Sentinel for "no product": the select needs a selectable item to clear the
* assignment again, and an empty string would be read as "nothing chosen yet"
* and render the placeholder instead.
*/
const NO_PRODUCT = "__none__";
export function BottlingEditDialog({
bottlingBatch,
}: {
bottlingBatch: BottlingBatch;
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
const [bestBeforeDate, setBestBeforeDate] = useState(
bottlingBatch.bestBeforeDate.slice(0, 10),
);
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
const mutation = useMutation({
mutationFn: () =>
updateBottlingBatch(bottlingBatch.id, {
date: new Date(date).toISOString(),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
lotNumber,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["bottling-batches", bottlingBatch.id],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -59,25 +54,188 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
<DialogHeader>
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
</DialogHeader>
{/* Mounted only while the dialog is open, so every reopen starts from
the saved values instead of what a cancelled edit left behind. */}
{open ? (
<BottlingEditForm
bottlingBatch={bottlingBatch}
onSaved={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
function BottlingEditForm({
bottlingBatch,
onSaved,
}: {
bottlingBatch: BottlingBatch;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [honeyBatchId, setHoneyBatchId] = useState(bottlingBatch.honeyBatchId);
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
const [bestBeforeDate, setBestBeforeDate] = useState(
bottlingBatch.bestBeforeDate.slice(0, 10),
);
const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize));
const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount));
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
const [productId, setProductId] = useState(
bottlingBatch.productId ?? NO_PRODUCT,
);
const [error, setError] = useState<string | null>(null);
const { data: honeyBatches } = useQuery({
queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches(),
});
const { data: products } = useQuery({
queryKey: ["products", { status: "ACTIVE" }],
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
// The assigned product may have been discontinued since bottling - keep it
// selectable so opening the dialog does not silently drop the assignment.
const productOptions = [
...(products ?? []).map((product) => ({
id: product.id,
name: product.name,
})),
...(bottlingBatch.product &&
!(products ?? []).some(
(product) => product.id === bottlingBatch.product?.id,
)
? [bottlingBatch.product]
: []),
];
const honeyBatchOptions = [
...(honeyBatches ?? []).map((honeyBatch) => ({
id: honeyBatch.id,
batchNumber: honeyBatch.batchNumber,
})),
...((honeyBatches ?? []).some(
(honeyBatch) => honeyBatch.id === bottlingBatch.honeyBatchId,
)
? []
: [bottlingBatch.honeyBatch]),
];
const mutation = useMutation({
mutationFn: () =>
updateBottlingBatch(bottlingBatch.id, {
honeyBatchId,
date: new Date(date).toISOString(),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
jarSize: Number(jarSize),
jarCount: Number(jarCount),
lotNumber,
productId: productId === NO_PRODUCT ? null : productId,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Abfüllung konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
<Select
value={honeyBatchId}
onValueChange={(value) => setHoneyBatchId(value ?? honeyBatchId)}
>
<SelectTrigger id="bottling-edit-honey-batch">
<SelectValue placeholder="Charge wählen" />
</SelectTrigger>
<SelectContent>
{honeyBatchOptions.map((honeyBatch) => (
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
{honeyBatch.batchNumber}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-date">Datum</Label>
<Input
id="bottling-edit-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-best-before">Mindesthaltbarkeitsdatum</Label>
<Label htmlFor="bottling-edit-best-before">
Mindesthaltbarkeitsdatum
</Label>
<Input
id="bottling-edit-best-before"
type="date"
required
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-jar-size">Glasgröße (g)</Label>
<Input
id="bottling-edit-jar-size"
type="number"
min="1"
required
value={jarSize}
onChange={(event) => setJarSize(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-jar-count">Anzahl Gläser</Label>
<Input
id="bottling-edit-jar-count"
type="number"
min="1"
required
value={jarCount}
onChange={(event) => setJarCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-product">Produkt</Label>
<Select
value={productId}
onValueChange={(value) => setProductId(value ?? NO_PRODUCT)}
>
<SelectTrigger id="bottling-edit-product">
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRODUCT}>Kein Produkt</SelectItem>
{productOptions.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
<Input
@@ -87,11 +245,14 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -7,11 +7,14 @@ import { useQuery } from "@tanstack/react-query";
import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { BottlingEditDialog } from "./bottling-edit-dialog";
import { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card";
export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const { data: bottlingBatch, isLoading, isError } = useQuery({
queryKey: ["bottling-batches", params.id],
queryFn: () => fetchBottlingBatch(params.id),
@@ -43,7 +46,12 @@ export default function BottlingBatchDetailPage() {
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
</div>
<div className="flex items-center gap-2">
{hasPermission("bottling:update") ? (
<BottlingEditDialog bottlingBatch={bottlingBatch} />
) : null}
{hasPermission("bottling:delete") ? (
<BottlingDeleteButton bottlingBatch={bottlingBatch} />
) : null}
<QrCodeDialog
entityType="bottling-batches"
id={bottlingBatch.id}
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import {
dissolveColony,
mergeColony,
@@ -35,6 +36,10 @@ import {
SelectValue,
} from "@/components/ui/select";
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function invalidateColony(
queryClient: ReturnType<typeof useQueryClient>,
colonyId: string,
@@ -48,7 +53,10 @@ function MoveDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState("");
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const { data: apiaries } = useQuery({
queryKey: ["apiaries", { status: "ACTIVE" }],
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
});
const mutation = useMutation({
mutationFn: () => moveColony(colony.id, { apiaryId }),
@@ -56,6 +64,8 @@ function MoveDialog({ colony }: { colony: Colony }) {
invalidateColony(queryClient, colony.id);
setOpen(false);
},
onError: (error) =>
toast.error(errorMessage(error, "Volk konnte nicht umgesetzt werden")),
});
return (
@@ -119,6 +129,8 @@ function SplitDialog({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
setOpen(false);
},
onError: (error) =>
toast.error(errorMessage(error, "Ableger konnte nicht angelegt werden")),
});
return (
@@ -193,6 +205,8 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["queens"] });
setOpen(false);
},
onError: (error) =>
toast.error(errorMessage(error, "Königin konnte nicht zugewiesen werden")),
});
return (
@@ -256,6 +270,8 @@ function MergeDialog({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
setOpen(false);
},
onError: (error) =>
toast.error(errorMessage(error, "Völker konnten nicht vereinigt werden")),
});
return (
@@ -2,13 +2,16 @@
import { FormEvent, useState } from "react";
import { Layers } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateColony } from "@/lib/colonies/api";
import { Colony, ColonyCondition, ColonyType } from "@/lib/colonies/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types";
import {
COLONY_CONDITIONS,
COLONY_CONDITION_LABELS,
COLONY_CONDITION_TONE_CLASS,
COLONY_EDITABLE_STATUSES,
COLONY_STATUS_LABELS,
COLONY_STRENGTH_OPTIONS,
COLONY_TYPES,
@@ -74,6 +77,9 @@ function ConditionSelect({
export function ColonyEditCard({ colony }: { colony: Colony }) {
const queryClient = useQueryClient();
const [apiaryId, setApiaryId] = useState(colony.apiaryId);
const [code, setCode] = useState(colony.code);
const [status, setStatus] = useState<ColonyStatus>(colony.status);
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
const [originText, setOriginText] = useState(colony.originText ?? "");
@@ -89,9 +95,17 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
);
const [notes, setNotes] = useState(colony.notes ?? "");
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const updateMutation = useMutation({
mutationFn: () =>
updateColony(colony.id, {
apiaryId,
code,
status,
colonyType,
hiveType: hiveType || undefined,
originText: originText || undefined,
@@ -104,7 +118,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] });
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
},
onError: (error: Error) =>
toast.error(error.message || "Volk konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -128,6 +145,52 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-apiary">Bienenstand</Label>
<Select
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
>
<SelectTrigger id="colony-apiary">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
festgehalten.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-code">Volksnummer</Label>
<Input
id="colony-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
<SelectTrigger id="colony-status">
<SelectValue>{COLONY_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_EDITABLE_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{COLONY_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-type">Volktyp</Label>
<Select
@@ -49,6 +49,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{treatment.method ? ` · ${treatment.method}` : ""}
</p>
{treatment.reason ? <p>Grund: {treatment.reason}</p> : null}
{treatment.withdrawalPeriodDays !== null ? (
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
) : null}
</div>
))}
</CardContent>
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateFeeding } from "@/lib/feedings/api";
import { Feeding, FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
@@ -17,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -28,29 +30,38 @@ import {
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(feeding.colonyId);
const [date, setDate] = useState(feeding.date.slice(0, 10));
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
const [product, setProduct] = useState(feeding.product ?? "");
const [amount, setAmount] = useState(feeding.amount.toString());
const [unit, setUnit] = useState(feeding.unit);
const [batchNumber, setBatchNumber] = useState(feeding.batchNumber ?? "");
const [notes, setNotes] = useState(feeding.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateFeeding(feeding.id, {
colonyId,
date: new Date(date).toISOString(),
feedType,
product: product || undefined,
amount: amount ? Number(amount) : undefined,
unit,
batchNumber: batchNumber || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([feeding.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", feeding.colonyId, "feedings"],
queryKey: ["colonies", affectedColonyId, "feedings"],
});
}
setOpen(false);
},
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -73,6 +84,11 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
<DialogTitle>Fütterung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="feeding-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-date">Datum</Label>
<Input
@@ -106,6 +122,7 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
<Input
id="feeding-edit-amount"
type="number"
min={0}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
@@ -127,6 +144,14 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
onChange={(event) => setProduct(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-batch">Chargennummer</Label>
<Input
id="feeding-edit-batch"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-notes">Notizen</Label>
<Textarea
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types";
@@ -22,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -33,13 +35,29 @@ import {
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(inspection.colonyId);
const [date, setDate] = useState(inspection.date.slice(0, 10));
const [weather, setWeather] = useState(inspection.weather ?? "");
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
const [colonyStrength, setColonyStrength] = useState(
inspection.colonyStrength?.toString() ?? "",
);
const [broodAmount, setBroodAmount] = useState(inspection.broodAmount ?? "");
const [broodPattern, setBroodPattern] = useState(inspection.broodPattern?.toString() ?? "");
const [openBroodPresent, setOpenBroodPresent] = useState(inspection.openBroodPresent ?? false);
const [cappedBroodPresent, setCappedBroodPresent] = useState(
inspection.cappedBroodPresent ?? false,
);
const [queenSeen, setQueenSeen] = useState(inspection.queenSeen ?? false);
const [eggsPresent, setEggsPresent] = useState(inspection.eggsPresent ?? true);
const [queenCellsPresent, setQueenCellsPresent] = useState(
inspection.queenCellsPresent ?? false,
);
const [swarmMood, setSwarmMood] = useState(inspection.swarmMood?.toString() ?? "");
const [gentleness, setGentleness] = useState(inspection.gentleness?.toString() ?? "");
const [combBehavior, setCombBehavior] = useState(inspection.combBehavior?.toString() ?? "");
const [feedStatus, setFeedStatus] = useState(inspection.feedStatus ?? "ausreichend");
const [honeySuperStatus, setHoneySuperStatus] = useState(inspection.honeySuperStatus ?? "");
const [healthIssues, setHealthIssues] = useState(inspection.healthIssues ?? "");
const [varroaIssues, setVarroaIssues] = useState(inspection.varroaIssues ?? "");
const [measures, setMeasures] = useState(inspection.measures ?? "");
@@ -48,22 +66,39 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
const mutation = useMutation({
mutationFn: () =>
updateInspection(inspection.id, {
colonyId,
date: new Date(date).toISOString(),
weather: weather || undefined,
temperature: temperature ? Number(temperature) : undefined,
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
broodAmount: broodAmount || undefined,
broodPattern: broodPattern ? Number(broodPattern) : undefined,
openBroodPresent,
cappedBroodPresent,
queenSeen,
eggsPresent,
queenCellsPresent,
swarmMood: swarmMood ? Number(swarmMood) : undefined,
gentleness: gentleness ? Number(gentleness) : undefined,
combBehavior: combBehavior ? Number(combBehavior) : undefined,
feedStatus,
honeySuperStatus: honeySuperStatus || undefined,
healthIssues: healthIssues || undefined,
varroaIssues: varroaIssues || undefined,
measures: measures || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([inspection.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
queryKey: ["colonies", affectedColonyId, "inspections"],
});
}
setOpen(false);
},
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -86,6 +121,11 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="inspection-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-date">Datum</Label>
<Input
@@ -95,6 +135,24 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-weather">Wetter</Label>
<Input
id="inspection-edit-weather"
value={weather}
onChange={(event) => setWeather(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-temperature">Temperatur (°C)</Label>
<Input
id="inspection-edit-temperature"
type="number"
inputMode="decimal"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label>
<Input
@@ -104,6 +162,48 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
onChange={(event) => setColonyStrength(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-brood-amount">Brutmenge</Label>
<Input
id="inspection-edit-brood-amount"
value={broodAmount}
onChange={(event) => setBroodAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-brood-pattern">Brutbild (1–5)</Label>
<Input
id="inspection-edit-brood-pattern"
type="number"
min={1}
max={5}
value={broodPattern}
onChange={(event) => setBroodPattern(event.target.value)}
/>
</div>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-open-brood">
<input
id="inspection-edit-open-brood"
type="checkbox"
className="size-5"
checked={openBroodPresent}
onChange={(event) => setOpenBroodPresent(event.target.checked)}
/>
Offene Brut vorhanden
</label>
<label
className="flex items-center gap-3 text-sm"
htmlFor="inspection-edit-capped-brood"
>
<input
id="inspection-edit-capped-brood"
type="checkbox"
className="size-5"
checked={cappedBroodPresent}
onChange={(event) => setCappedBroodPresent(event.target.checked)}
/>
Verdeckelte Brut vorhanden
</label>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-queen">
<input
id="inspection-edit-queen"
@@ -124,6 +224,52 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
/>
Stifte vorhanden
</label>
<label
className="flex items-center gap-3 text-sm"
htmlFor="inspection-edit-queen-cells"
>
<input
id="inspection-edit-queen-cells"
type="checkbox"
className="size-5"
checked={queenCellsPresent}
onChange={(event) => setQueenCellsPresent(event.target.checked)}
/>
Weiselzellen vorhanden
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-swarm-mood">Schwarmstimmung (1–5)</Label>
<Input
id="inspection-edit-swarm-mood"
type="number"
min={1}
max={5}
value={swarmMood}
onChange={(event) => setSwarmMood(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-gentleness">Sanftmut (1–5)</Label>
<Input
id="inspection-edit-gentleness"
type="number"
min={1}
max={5}
value={gentleness}
onChange={(event) => setGentleness(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-comb-behavior">Wabensitz (1–5)</Label>
<Input
id="inspection-edit-comb-behavior"
type="number"
min={1}
max={5}
value={combBehavior}
onChange={(event) => setCombBehavior(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-feed">Futterstatus</Label>
<Select
@@ -140,6 +286,14 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-honey-super">Honigraumstatus</Label>
<Input
id="inspection-edit-honey-super"
value={honeySuperStatus}
onChange={(event) => setHoneySuperStatus(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-varroa">Varroa-Befund</Label>
<Input
@@ -2,6 +2,7 @@
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
@@ -27,6 +28,7 @@ export default function ColonyDetailPage() {
const exportReportMutation = useMutation({
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
});
if (isLoading) {
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types";
@@ -17,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -28,6 +30,7 @@ import {
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(check.colonyId);
const [date, setDate] = useState(check.date.slice(0, 10));
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
@@ -39,6 +42,7 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
const mutation = useMutation({
mutationFn: () =>
updateSwarmControlCheck(check.id, {
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
queenCellsCapped,
@@ -48,11 +52,16 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
notes: notes || undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([check.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
queryKey: ["colonies", affectedColonyId, "swarm-control-checks"],
});
}
setOpen(false);
},
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -75,6 +84,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="swarm-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="swarm-edit-date">Datum</Label>
<Input
@@ -3,12 +3,14 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateTreatment } from "@/lib/treatments/api";
import { Treatment } from "@/lib/treatments/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -20,29 +22,48 @@ import {
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(treatment.colonyId);
const [date, setDate] = useState(treatment.date.slice(0, 10));
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
const [product, setProduct] = useState(treatment.product);
const [activeIngredient, setActiveIngredient] = useState(treatment.activeIngredient ?? "");
const [amount, setAmount] = useState(treatment.amount ?? "");
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [batchNumber, setBatchNumber] = useState(treatment.batchNumber ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
treatment.withdrawalPeriodDays?.toString() ?? "",
);
const mutation = useMutation({
mutationFn: () =>
updateTreatment(treatment.id, {
colonyId,
date: new Date(date).toISOString(),
startDate: startDate ? new Date(startDate).toISOString() : undefined,
endDate: endDate ? new Date(endDate).toISOString() : undefined,
product,
activeIngredient: activeIngredient || undefined,
amount: amount || undefined,
method: method || undefined,
reason: reason || undefined,
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([treatment.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
queryKey: ["colonies", affectedColonyId, "treatments"],
});
}
setOpen(false);
},
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -65,6 +86,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
<DialogTitle>Behandlung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="treatment-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-date">Datum</Label>
<Input
@@ -74,6 +100,24 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-start-date">Beginn</Label>
<Input
id="treatment-edit-start-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-end-date">Ende</Label>
<Input
id="treatment-edit-end-date"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-product">Mittel</Label>
<Input
@@ -83,6 +127,14 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setProduct(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-active-ingredient">Wirkstoff</Label>
<Input
id="treatment-edit-active-ingredient"
value={activeIngredient}
onChange={(event) => setActiveIngredient(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-amount">Menge</Label>
<Input
@@ -107,6 +159,25 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setReason(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-batch">Chargennummer</Label>
<Input
id="treatment-edit-batch"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-withdrawal">Wartezeit (Tage)</Label>
<Input
id="treatment-edit-withdrawal"
type="number"
inputMode="numeric"
min={0}
value={withdrawalPeriodDays}
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
@@ -17,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -28,9 +30,13 @@ import {
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [colonyId, setColonyId] = useState(measurement.colonyId);
const [date, setDate] = useState(measurement.date.slice(0, 10));
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
const [durationMinutes, setDurationMinutes] = useState(
measurement.durationMinutes?.toString() ?? "",
);
const [calculatedValue, setCalculatedValue] = useState(
measurement.calculatedValue?.toString() ?? "",
);
@@ -40,19 +46,26 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const mutation = useMutation({
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
colonyId,
date: new Date(date).toISOString(),
method,
miteCount: miteCount ? Number(miteCount) : undefined,
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
calculatedValue: calculatedValue ? Number(calculatedValue) : undefined,
assessment: assessment || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
// show a stale list until each is invalidated.
for (const affectedColonyId of new Set([measurement.colonyId, colonyId])) {
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
queryKey: ["colonies", affectedColonyId, "varroa-measurements"],
});
}
setOpen(false);
},
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -75,6 +88,11 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<ColonySelectField
id="varroa-edit-colony"
value={colonyId}
onChange={setColonyId}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-date">Datum</Label>
<Input
@@ -107,16 +125,28 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
<Input
id="varroa-edit-count"
type="number"
min={0}
value={miteCount}
onChange={(event) => setMiteCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-duration">Zeitraum (Minuten)</Label>
<Input
id="varroa-edit-duration"
type="number"
min={0}
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label>
<Input
id="varroa-edit-calculated"
type="number"
step="0.1"
min={0}
value={calculatedValue}
onChange={(event) => setCalculatedValue(event.target.value)}
/>
@@ -24,7 +24,10 @@ import { Card, CardContent } from "@/components/ui/card";
export default function NewColonyPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const { data: apiaries } = useQuery({
queryKey: ["apiaries", { status: "ACTIVE" }],
queryFn: () => fetchApiaries({ status: "ACTIVE" }),
});
const [code, setCode] = useState("");
const [apiaryId, setApiaryId] = useState("");
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { Layers } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchColonies } from "@/lib/colonies/api";
import { ColonyStatus } from "@/lib/colonies/types";
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
@@ -35,7 +36,10 @@ export default function ColoniesPage() {
queryFn: () => fetchColonies({}),
});
const exportMutation = useMutation({ mutationFn: exportColoniesCsv });
const exportMutation = useMutation({
mutationFn: exportColoniesCsv,
onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"),
});
const activeColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt === null);
const historyColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt !== null);
@@ -12,6 +12,7 @@ import {
Layers,
PackageSearch,
ShieldAlert,
Sprout,
} from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
@@ -60,6 +61,16 @@ const ALERT_META: Record<
icon: PackageSearch,
className: "border-warning/40 bg-warning/10",
},
OVERDUE_SWARM_CHECK: {
label: "Schwarmkontrolle überfällig",
icon: Sprout,
className: "border-destructive/30 bg-destructive/5",
},
OLD_QUEEN: {
label: "Königin überaltert",
icon: Crown,
className: "border-warning/40 bg-warning/10",
},
};
export default function DashboardPage() {
@@ -3,6 +3,7 @@
import { useParams } from "next/navigation";
import { Wrench } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
import { EquipmentCondition } from "@/lib/equipment/types";
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
@@ -32,6 +33,7 @@ export default function EquipmentDetailPage() {
void queryClient.invalidateQueries({ queryKey: ["equipment", params.id] });
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
},
onError: () => toast.error("Zustand konnte nicht gespeichert werden"),
});
if (isLoading) {
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader>
<div className="flex items-center gap-2.5">
<span className="flex size-9 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</span>
<CardTitle className="text-lg">Etwas ist schiefgelaufen</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Diese Seite konnte nicht angezeigt werden. Bereits gespeicherte Daten sind davon nicht
betroffen. Über die Navigation kommst du zu jeder anderen Seite.
</p>
<Button onClick={() => reset()} className="w-fit">
Erneut versuchen
</Button>
</CardContent>
</Card>
</main>
);
}
@@ -3,12 +3,14 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateExtraction } from "@/lib/extractions/api";
import { Extraction } from "@/lib/extractions/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { HarvestSelectField } from "@/components/harvest-select-field";
import {
Dialog,
DialogContent,
@@ -20,6 +22,9 @@ import {
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [harvestIds, setHarvestIds] = useState<string[]>(
extraction.harvests.map((entry) => entry.harvestId),
);
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
const [room, setRoom] = useState(extraction.room ?? "");
@@ -30,6 +35,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
@@ -41,10 +47,16 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
setOpen(false);
},
onError: (error: Error) =>
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (harvestIds.length === 0) {
toast.error("Bitte mindestens eine Ernte auswählen.");
return;
}
mutation.mutate();
}
@@ -63,6 +75,11 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<HarvestSelectField
idPrefix="extraction-edit-harvest"
value={harvestIds}
onChange={setHarvestIds}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
@@ -78,6 +95,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
<Input
id="extraction-edit-amount"
type="number"
min={0}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
@@ -97,6 +115,8 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
<Input
id="extraction-edit-water"
type="number"
min={0}
max={100}
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
@@ -123,6 +123,7 @@ export default function NewExtractionPage() {
id="extraction-amount"
type="number"
inputMode="decimal"
min={0}
className="h-11"
value={amount}
onChange={(event) => setAmount(event.target.value)}
@@ -143,6 +144,8 @@ export default function NewExtractionPage() {
id="extraction-water"
type="number"
inputMode="decimal"
min={0}
max={100}
className="h-11"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
@@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api";
import { createFeeding } from "@/lib/feedings/api";
import { FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueFeeding } from "@/lib/offline/db";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -44,6 +46,7 @@ export default function NewFeedingPage() {
const [amount, setAmount] = useState("");
const [unit, setUnit] = useState("l");
const [product, setProduct] = useState("");
const [batchNumber, setBatchNumber] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -59,8 +62,15 @@ export default function NewFeedingPage() {
toast.success("Fütterung gespeichert");
router.push(`/colonies/${feeding.colonyId}`);
},
onError: () => {
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setError("Fütterung konnte nicht gespeichert werden.");
return;
}
void queueFeeding(variables).then(() => {
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
router.push(`/colonies/${variables.colonyId}`);
});
},
});
@@ -82,6 +92,7 @@ export default function NewFeedingPage() {
amount: Number(amount),
unit,
product: product || undefined,
batchNumber: batchNumber || undefined,
notes: notes || undefined,
});
}
@@ -147,6 +158,7 @@ export default function NewFeedingPage() {
id="feeding-amount"
type="number"
inputMode="decimal"
min={0}
required
className="h-11"
value={amount}
@@ -173,6 +185,15 @@ export default function NewFeedingPage() {
onChange={(event) => setProduct(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-batch">Chargennummer</Label>
<Input
id="feeding-batch"
className="h-11"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-notes">Notizen</Label>
<Textarea
@@ -2,13 +2,23 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHarvest } from "@/lib/harvests/api";
import { Harvest } from "@/lib/harvests/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -20,31 +30,69 @@ import {
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [apiaryId, setApiaryId] = useState(harvest.apiaryId);
const [colonyIds, setColonyIds] = useState<string[]>(
harvest.colonies.map((entry) => entry.colonyId),
);
const [date, setDate] = useState(harvest.date.slice(0, 10));
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
const [grossWeight, setGrossWeight] = useState(harvest.grossWeight?.toString() ?? "");
const [netWeight, setNetWeight] = useState(harvest.netWeight?.toString() ?? "");
const [honeySuperCount, setHoneySuperCount] = useState(
harvest.honeySuperCount?.toString() ?? "",
);
const [notes, setNotes] = useState(harvest.notes ?? "");
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
// Colonies of the chosen apiary, plus any colony already on this harvest -
// one that was meanwhile moved or dissolved must stay visible, otherwise
// saving would silently drop it from the harvest.
const selectableColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId || colonyIds.includes(colony.id),
);
function toggleColony(colonyId: string, checked: boolean) {
setColonyIds((current) =>
checked
? [...current, colonyId]
: current.filter((id) => id !== colonyId),
);
}
const mutation = useMutation({
mutationFn: () =>
updateHarvest(harvest.id, {
apiaryId,
colonyIds,
date: new Date(date).toISOString(),
honeyType: honeyType || undefined,
grossWeight: grossWeight ? Number(grossWeight) : undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["harvests", harvest.id] });
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
setOpen(false);
},
onError: (error: Error) =>
toast.error(error.message || "Ernte konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (colonyIds.length === 0) {
toast.error("Bitte mindestens ein Volk auswählen.");
return;
}
mutation.mutate();
}
@@ -63,6 +111,48 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
<DialogTitle>Ernte bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
<Select
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
>
<SelectTrigger id="harvest-edit-apiary" className="w-full">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
{selectableColonies.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Völker an diesem Bienenstand.
</p>
) : null}
{selectableColonies.map((colony) => (
<label
key={colony.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-edit-colony-${colony.id}`}
>
<input
id={`harvest-edit-colony-${colony.id}`}
type="checkbox"
className="size-5"
checked={colonyIds.includes(colony.id)}
onChange={(event) => toggleColony(colony.id, event.target.checked)}
/>
{colony.code}
</label>
))}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-date">Datum</Label>
<Input
@@ -81,11 +171,22 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="harvest-edit-gross-weight">Bruttogewicht (kg)</Label>
<Input
id="harvest-edit-gross-weight"
type="number"
min={0}
value={grossWeight}
onChange={(event) => setGrossWeight(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="harvest-edit-weight">Nettogewicht (kg)</Label>
<Input
id="harvest-edit-weight"
type="number"
min={0}
value={netWeight}
onChange={(event) => setNetWeight(event.target.value)}
/>
@@ -95,6 +196,7 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
<Input
id="harvest-edit-supers"
type="number"
min={0}
value={honeySuperCount}
onChange={(event) => setHoneySuperCount(event.target.value)}
/>
@@ -4,10 +4,13 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Boxes } from "lucide-react";
import { Boxes, AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { createHarvest } from "@/lib/harvests/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -38,6 +41,8 @@ export default function NewHarvestPage() {
const [date, setDate] = useState(todayIsoDate());
const [colonyIds, setColonyIds] = useState<string[]>([]);
const [honeyType, setHoneyType] = useState("");
const [honeySuperCount, setHoneySuperCount] = useState("");
const [grossWeight, setGrossWeight] = useState("");
const [netWeight, setNetWeight] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -45,6 +50,15 @@ export default function NewHarvestPage() {
const apiaryColonies = (colonies ?? []).filter(
(colony) => colony.apiaryId === apiaryId && colony.status === "ACTIVE",
);
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
const { data: withdrawalStatuses } = useQuery({
queryKey: ["treatments", "withdrawal-status", apiaryColonyIds],
queryFn: () => fetchWithdrawalStatus(apiaryColonyIds),
enabled: apiaryColonyIds.length > 0,
});
const withdrawalByColony = new Map(
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
);
const mutation = useMutation({
mutationFn: createHarvest,
@@ -53,8 +67,15 @@ export default function NewHarvestPage() {
toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`);
},
onError: () => {
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setError("Ernte konnte nicht gespeichert werden.");
return;
}
void queueHarvest(variables).then(() => {
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
router.push("/harvests");
});
},
});
@@ -80,6 +101,8 @@ export default function NewHarvestPage() {
date: new Date(date).toISOString(),
colonyIds,
honeyType: honeyType || undefined,
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
grossWeight: grossWeight ? Number(grossWeight) : undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
notes: notes || undefined,
});
@@ -135,9 +158,11 @@ export default function NewHarvestPage() {
</p>
) : null}
<div className="flex flex-col gap-2">
{apiaryColonies.map((colony) => (
{apiaryColonies.map((colony) => {
const withdrawal = withdrawalByColony.get(colony.id);
return (
<div key={colony.id} className="flex flex-col gap-1">
<label
key={colony.id}
className="flex h-11 items-center gap-3 text-sm"
htmlFor={`harvest-colony-${colony.id}`}
>
@@ -150,7 +175,16 @@ export default function NewHarvestPage() {
/>
{colony.code}
</label>
))}
{withdrawal ? (
<p className="flex items-center gap-1.5 pl-8 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Wartezeit ({withdrawal.product}) bis{" "}
{new Date(withdrawal.activeUntil).toLocaleDateString("de-DE")}
</p>
) : null}
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-2">
@@ -162,12 +196,37 @@ export default function NewHarvestPage() {
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-super-count">Honigräume</Label>
<Input
id="harvest-super-count"
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={honeySuperCount}
onChange={(event) => setHoneySuperCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-gross-weight">Bruttogewicht (kg)</Label>
<Input
id="harvest-gross-weight"
type="number"
inputMode="decimal"
min={0}
className="h-11"
value={grossWeight}
onChange={(event) => setGrossWeight(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-net-weight">Nettogewicht (kg)</Label>
<Input
id="harvest-net-weight"
type="number"
inputMode="decimal"
min={0}
className="h-11"
value={netWeight}
onChange={(event) => setNetWeight(event.target.value)}
@@ -70,6 +70,8 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
id="measurement-water"
type="number"
inputMode="decimal"
min={0}
max={100}
required
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
@@ -0,0 +1,175 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyMeasurement } from "@/lib/honey-batches/api";
import { HoneyMeasurement } from "@/lib/honey-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Corrects a water-content reading. The figure decides whether a batch may be
* sold as honey at all, so a slipped digit left standing is worse than a
* reading that was never recorded.
*/
function EditMeasurementForm({
measurement,
honeyBatchId,
onSaved,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [waterContent, setWaterContent] = useState(
measurement.waterContent.toString(),
);
const [measuredAt, setMeasuredAt] = useState(
measurement.measuredAt.slice(0, 10),
);
const [device, setDevice] = useState(measurement.device ?? "");
const [temperature, setTemperature] = useState(
measurement.temperature?.toString() ?? "",
);
const [notes, setNotes] = useState(measurement.notes ?? "");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateHoneyMeasurement(measurement.id, {
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatchId],
});
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Messung konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-water">Wassergehalt (%)</Label>
<Input
id="measurement-edit-water"
type="number"
step="0.1"
min={0}
max={100}
required
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-date">Gemessen am</Label>
<Input
id="measurement-edit-date"
type="date"
required
value={measuredAt}
onChange={(event) => setMeasuredAt(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-device">Gerät</Label>
<Input
id="measurement-edit-device"
value={device}
onChange={(event) => setDevice(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="measurement-edit-temp">Temperatur (°C)</Label>
<Input
id="measurement-edit-temp"
type="number"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="measurement-edit-notes">Notizen</Label>
<Textarea
id="measurement-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
export function EditMeasurementDialog({
measurement,
honeyBatchId,
}: {
measurement: HoneyMeasurement;
honeyBatchId: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Messung bearbeiten</DialogTitle>
</DialogHeader>
{open ? (
<EditMeasurementForm
measurement={measurement}
honeyBatchId={honeyBatchId}
onSaved={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -3,12 +3,14 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchExtraction, updateExtraction } from "@/lib/extractions/api";
import { Extraction } from "@/lib/extractions/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { HarvestSelectField } from "@/components/harvest-select-field";
import {
Dialog,
DialogContent,
@@ -27,7 +29,11 @@ function ExtractionEditForm({
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [harvestIds, setHarvestIds] = useState<string[]>(
extraction.harvests.map((entry) => entry.harvestId),
);
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
const [room, setRoom] = useState(extraction.room ?? "");
const [waterContent, setWaterContent] = useState(
extraction.waterContent?.toString() ?? "",
@@ -40,7 +46,9 @@ function ExtractionEditForm({
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
temperature: temperature ? Number(temperature) : undefined,
@@ -52,15 +60,26 @@ function ExtractionEditForm({
});
onSaved();
},
onError: (error: Error) =>
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (harvestIds.length === 0) {
toast.error("Bitte mindestens eine Ernte auswählen.");
return;
}
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<HarvestSelectField
idPrefix="batch-extraction-edit-harvest"
value={harvestIds}
onChange={setHarvestIds}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
@@ -70,6 +89,16 @@ function ExtractionEditForm({
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-amount">Menge (kg)</Label>
<Input
id="extraction-edit-amount"
type="number"
min={0}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-room">Raum</Label>
<Input
@@ -85,6 +114,8 @@ function ExtractionEditForm({
id="extraction-edit-water"
type="number"
step="0.1"
min={0}
max={100}
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
@@ -2,12 +2,23 @@
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api";
import { fetchStorageContainers } from "@/lib/storage/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -20,13 +31,32 @@ import {
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [batchNumber, setBatchNumber] = useState(honeyBatch.batchNumber);
const [extractionId, setExtractionId] = useState(honeyBatch.extractionId);
const [status, setStatus] = useState<HoneyBatchStatus>(honeyBatch.status);
const [storageContainerId, setStorageContainerId] = useState(
honeyBatch.storageContainerId ?? "",
);
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: () => fetchExtractions(),
});
const { data: containers } = useQuery({
queryKey: ["storage-containers"],
queryFn: () => fetchStorageContainers(),
});
const mutation = useMutation({
mutationFn: () =>
updateHoneyBatch(honeyBatch.id, {
batchNumber,
extractionId,
status,
storageContainerId: storageContainerId || undefined,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
@@ -36,6 +66,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
setOpen(false);
},
onError: () => toast.error("Honigcharge konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -58,6 +89,77 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-number">Chargennummer</Label>
<Input
id="batch-edit-number"
required
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
<Select
value={extractionId}
onValueChange={(value) => setExtractionId(value ?? extractionId)}
>
<SelectTrigger id="batch-edit-extraction" className="w-full">
<SelectValue placeholder="Schleuderung wählen" />
</SelectTrigger>
<SelectContent>
{(extractions ?? []).map((extraction) => (
<SelectItem key={extraction.id} value={extraction.id}>
{new Date(extraction.date).toLocaleDateString("de-DE")}
{extraction.amount !== null ? ` · ${extraction.amount} kg` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-container">Lagerbehälter</Label>
<Select
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="batch-edit-container" className="w-full">
<SelectValue placeholder="Behälter wählen" />
</SelectTrigger>
<SelectContent>
{(containers ?? []).map((container) => (
<SelectItem key={container.id} value={container.id}>
{container.code}
{container.location ? ` · ${container.location}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie
festgehalten.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-status">Status</Label>
<Select
value={status}
onValueChange={(value) => setStatus((value ?? status) as HoneyBatchStatus)}
>
<SelectTrigger id="batch-edit-status" className="w-full">
<SelectValue>{HONEY_BATCH_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(HONEY_BATCH_STATUS_LABELS) as HoneyBatchStatus[]
).map((option) => (
<SelectItem key={option} value={option}>
{HONEY_BATCH_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
<Input
@@ -72,6 +174,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
id="batch-edit-amount"
type="number"
inputMode="decimal"
min={0}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
@@ -82,6 +185,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
id="batch-edit-water"
type="number"
step="0.1"
min={0}
max={100}
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more