Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export

# Conflicts:
#	ARCHITECTURE.md
This commit is contained in:
Nick Szaunig committed 2026-10-13 05:58:30 +02:00
commit 9c332d4716
310 files changed
+12177 -3746

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import { Apiary, ApiaryInput, ApiaryStatus } from "./types";
import { Apiary, ApiaryInput, ApiaryStatus, UpdateApiaryInput } from "./types";
export interface ApiaryFilter {
status?: ApiaryStatus;
@@ -25,7 +25,7 @@ export function createApiary(input: ApiaryInput): Promise<Apiary> {
});
}
export function updateApiary(id: string, input: Partial<ApiaryInput>): Promise<Apiary> {
export function updateApiary(id: string, input: UpdateApiaryInput): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
+18 -6
View File
@@ -15,12 +15,24 @@ export interface Apiary {
deactivatedAt: string | null;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface ApiaryInput {
name: string;
description?: string;
location?: string;
latitude?: number;
longitude?: number;
address?: string;
notes?: string;
description?: string | null;
location?: string | null;
latitude?: number | null;
longitude?: number | null;
address?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateApiaryInput {
name?: string;
description?: string | null;
location?: string | null;
latitude?: number | null;
longitude?: number | null;
address?: string | null;
notes?: string | null;
}
+4 -7
View File
@@ -16,7 +16,7 @@ export function auditActionLabel(action: string): string {
return AUDIT_ACTION_LABELS[action] ?? action;
}
const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
export const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
Apiary: "Bienenstand",
AuthSession: "Sitzung",
AutomationRule: "Automatisierungsregel",
@@ -50,12 +50,9 @@ const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
};
/** Filter choices: one entry per area (duplicate spellings collapsed), A-Z. */
export const AUDIT_ENTITY_TYPE_OPTIONS: { value: string; label: string }[] = Object.entries(
AUDIT_ENTITY_TYPE_LABELS,
)
.filter(([value]) => value !== value.toUpperCase())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "de"));
export const AUDIT_ENTITY_TYPE_FILTER_VALUES: string[] = Object.keys(AUDIT_ENTITY_TYPE_LABELS)
.filter((value) => value !== value.toUpperCase())
.sort((a, b) => AUDIT_ENTITY_TYPE_LABELS[a].localeCompare(AUDIT_ENTITY_TYPE_LABELS[b], "de"));
export function auditEntityTypeLabel(entityType: string): string {
return AUDIT_ENTITY_TYPE_LABELS[entityType] ?? entityType;
+2 -1
View File
@@ -26,8 +26,9 @@ export interface AutomationRule {
updatedAt: string;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateAutomationRuleInput {
active?: boolean;
config?: Record<string, unknown>;
actionTemplate?: string;
actionTemplate?: string | null;
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { unsealedImportWarning } from "./import-summary";
describe("unsealedImportWarning", () => {
it("says nothing about a sealed import", () => {
expect(unsealedImportWarning({ imported: { apiary: 1 }, skipped: [], sealed: true })).toBeNull();
});
it("lists every table an unsealed file could not restore by its German name", () => {
const message = unsealedImportWarning({
imported: { apiary: 1 },
skipped: ["role", "batchMovement", "calendarSyncSettings", "backupSettings", "document"],
sealed: false,
});
expect(message).toContain("Rollen");
expect(message).toContain("Bewegungsbuch der Chargen");
expect(message).toContain("Kalender-Einstellungen");
expect(message).toContain("Backup-Einstellungen");
expect(message).toContain("Dokumente");
});
it("explains that existing records were left alone, even with nothing skipped", () => {
const message = unsealedImportWarning({ imported: { apiary: 1 }, skipped: [], sealed: false });
expect(message).toContain("nicht überschrieben");
expect(message).not.toContain("Nicht übernommen");
});
it("names a table it has no label for by its key", () => {
expect(
unsealedImportWarning({ imported: {}, skipped: ["futureTable"], sealed: false }),
).toContain("futureTable");
});
});
@@ -0,0 +1,45 @@
import { BackupImportSummary } from "./types";
// Every table an unsealed backup may not write (backend: everything outside
// LEGACY_UNSEALED_TABLES in backup.service.ts).
const SKIPPED_TABLE_LABELS: Record<string, string> = {
role: "Rollen",
user: "Benutzer",
rolePermission: "Rollenrechte",
webhookSubscription: "Webhooks",
auditLog: "Audit-Log",
automationRule: "Automatisierungen",
automationSequence: "Nummernkreise",
stockItem: "Lagerartikel",
stockMovement: "Lagerbewegungen",
batchMovement: "Bewegungsbuch der Chargen",
shopStockRequest: "Shop-Buchungen",
rawMaterialLot: "Rohstoff-Chargen",
productionBatch: "Herstellungschargen",
productionInput: "Rohstoffverbrauch",
honeyBatchContainerAllocation: "Behälter-Aufteilungen",
siteSettings: "Website-Einstellungen",
backupSettings: "Backup-Einstellungen",
calendarSyncSettings: "Kalender-Einstellungen",
calendarSyncOutboxEntry: "Kalender-Synchronisation",
document: "Dokumente",
notification: "Benachrichtigungen",
};
/**
* What an old, unencrypted backup left out - null for a sealed one. Such a
* file only adds missing master and field records and never overwrites
* existing ones; everything else it carried is listed by name.
*/
export function unsealedImportWarning(summary: BackupImportSummary): string | null {
if (summary.sealed !== false && summary.skipped.length === 0) {
return null;
}
const intro =
"Altes, unverschlüsseltes Backup: nur fehlende Stammdaten und Aufzeichnungen ergänzt, bestehende Datensätze nicht überschrieben.";
if (summary.skipped.length === 0) {
return intro;
}
const labels = summary.skipped.map((table) => SKIPPED_TABLE_LABELS[table] ?? table);
return `${intro} Nicht übernommen: ${labels.join(", ")}.`;
}
+9 -1
View File
@@ -1,7 +1,9 @@
export interface BackupImportSummary {
imported: Record<string, number>;
/** Tables an old, unencrypted backup may not restore (users, roles, webhooks, audit log). */
/** Tables the file carried that an old, unencrypted backup may not restore. */
skipped: string[];
/** False for an old, unencrypted backup: it only added missing records. */
sealed: boolean;
}
export interface BackupScheduleStatus {
@@ -10,6 +12,12 @@ export interface BackupScheduleStatus {
cronSchedule: string | null;
retentionCount: number | null;
lastRunAt: string | null;
/** Largest backup file the import accepts (BACKUP_MAX_IMPORT_BYTES). */
importLimitBytes: number;
/** Size of the newest automatic backup file, null while there is none. */
lastBackupSizeBytes: number | null;
/** That file could not be restored without a raised import limit. */
lastBackupExceedsImportLimit: boolean;
envOverrides: {
storagePath: boolean;
cronSchedule: boolean;
+3 -2
View File
@@ -15,14 +15,15 @@ export interface BottlingBatch {
product: { id: string; name: string } | null;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateBottlingBatchInput {
honeyBatchId: string;
productId?: string;
productId?: string | null;
date: string;
jarSize: number;
jarCount: number;
bestBeforeDate: string;
lotNumber?: string;
lotNumber?: string | null;
}
export interface UpdateBottlingBatchInput {
@@ -22,6 +22,7 @@ export function getCalendarSyncStatus(): Promise<CalendarSyncStatus> {
return apiFetch<CalendarSyncStatus>("/calendar/sync/status");
}
// Only starts the pass (202) - the returned status says running: true.
export function runCalendarSyncNow(): Promise<CalendarSyncStatus> {
return apiFetch<CalendarSyncStatus>("/calendar/sync/run-now", {
method: "POST",
@@ -29,4 +29,6 @@ export interface CalendarSyncStatus {
pendingCount: number;
failedCount: number;
syncedCount: number;
/** A pass is under way on the server ("Jetzt synchronisieren" or cron). */
running: boolean;
}
+17 -8
View File
@@ -1,4 +1,5 @@
import { ColonyCondition, ColonyStatus, ColonyType } from "./types";
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "./types";
import { LIVING_COLONY_STATUSES } from "./status";
// Werte und Reihenfolge aus docs/lastenheft.md Abschnitt 4.3 ("Moegliche Volkstypen"/"Moegliche Status").
export const COLONY_TYPES: ColonyType[] = [
@@ -24,12 +25,7 @@ export const COLONY_TYPE_LABELS: Record<ColonyType, string> = {
// transitions handled by their own dedicated confirm actions (colony-actions.tsx),
// which also set deactivatedAt - exposing them in a free dropdown here would
// let status and deactivatedAt drift out of sync.
export const COLONY_EDITABLE_STATUSES: ColonyStatus[] = [
"ACTIVE",
"WEAK",
"QUEENLESS",
"CONSPICUOUS",
];
export const COLONY_EDITABLE_STATUSES: readonly ColonyStatus[] = LIVING_COLONY_STATUSES;
export const COLONY_STATUS_LABELS: Record<ColonyStatus, string> = {
ACTIVE: "Aktiv",
@@ -42,6 +38,16 @@ export const COLONY_STATUS_LABELS: Record<ColonyStatus, string> = {
MERGED: "Vereinigt",
};
/**
* How a colony appears in a picker: the code, plus its status whenever it is
* not plain "Aktiv" - so a weak or queenless colony stands out where the
* beekeeper chooses it, and an ended one says how it ended.
*/
export function colonyOptionLabel(colony: Pick<Colony, "code" | "status">): string {
if (colony.status === "ACTIVE") return colony.code;
return `${colony.code} (${COLONY_STATUS_LABELS[colony.status]})`;
}
// Gemeinsame fuenfstufige Skala fuer Brut-, Futter- und Gesundheitsstatus,
// bester bis schlechtester Zustand.
export const COLONY_CONDITIONS: ColonyCondition[] = [
@@ -69,4 +75,7 @@ export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-foreground",
};
export const COLONY_STRENGTH_OPTIONS: number[] = Array.from({ length: 10 }, (_, index) => index + 1);
/** Volksstärke 1-10 (besetzte Waben), as select values named by themselves. */
export const COLONY_STRENGTH_LABELS: Record<string, string> = Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [String(index + 1), String(index + 1)]),
);
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { ColonyStatus } from "./types";
import { COLONY_EDITABLE_STATUSES, COLONY_STATUS_LABELS, colonyOptionLabel } from "./labels";
import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES, isLivingColony } from "./status";
const ALL_STATUSES = Object.keys(COLONY_STATUS_LABELS) as ColonyStatus[];
describe("isLivingColony", () => {
it.each<ColonyStatus>(["ACTIVE", "WEAK", "CONSPICUOUS", "QUEENLESS"])(
"treats a %s colony as still in the apiary",
(status) => {
expect(isLivingColony({ status })).toBe(true);
},
);
it.each<ColonyStatus>(["DISSOLVED", "SOLD", "DECEASED", "MERGED"])(
"treats a %s colony as ended",
(status) => {
expect(isLivingColony({ status })).toBe(false);
},
);
it("splits every status into exactly one of living or ended", () => {
const partitioned = [...LIVING_COLONY_STATUSES, ...ENDED_COLONY_STATUSES];
expect([...partitioned].sort()).toEqual([...ALL_STATUSES].sort());
expect(new Set(partitioned).size).toBe(partitioned.length);
});
it("lets the free status dropdown offer exactly the living statuses", () => {
expect(COLONY_EDITABLE_STATUSES).toEqual(LIVING_COLONY_STATUSES);
});
});
describe("colonyOptionLabel", () => {
it("shows only the code for an ordinary active colony", () => {
expect(colonyOptionLabel({ code: "V-01", status: "ACTIVE" })).toBe("V-01");
});
it("names the status of a colony that needs attention", () => {
expect(colonyOptionLabel({ code: "V-02", status: "QUEENLESS" })).toBe(
"V-02 (Weisellos)",
);
expect(colonyOptionLabel({ code: "V-03", status: "WEAK" })).toBe("V-03 (Schwach)");
});
it("names the real end of an ended colony instead of calling all of them dissolved", () => {
expect(colonyOptionLabel({ code: "V-04", status: "SOLD" })).toBe("V-04 (Verkauft)");
expect(colonyOptionLabel({ code: "V-05", status: "MERGED" })).toBe(
"V-05 (Vereinigt)",
);
});
});
@@ -0,0 +1,33 @@
import type { Colony, ColonyStatus } from "./types";
/**
* Mirrors ApiCore-Backend/src/colonies/colony-status.ts: after these a colony
* no longer exists as a living unit (it is also moved to the history tab).
*/
export const ENDED_COLONY_STATUSES: readonly ColonyStatus[] = [
"DISSOLVED",
"SOLD",
"DECEASED",
"MERGED",
];
/**
* A colony that is still in the apiary and gets worked with. Weak,
* conspicuous and queenless colonies belong here - they are the ones that
* need an inspection, feeding or treatment most.
*/
export const LIVING_COLONY_STATUSES: readonly ColonyStatus[] = [
"ACTIVE",
"WEAK",
"CONSPICUOUS",
"QUEENLESS",
];
export function isLivingColonyStatus(status: ColonyStatus): boolean {
return !ENDED_COLONY_STATUSES.includes(status);
}
/** Use this - never `status === "ACTIVE"` - to decide whether a colony can be picked or acted on. */
export function isLivingColony(colony: Pick<Colony, "status">): boolean {
return isLivingColonyStatus(colony.status);
}
+11 -10
View File
@@ -41,23 +41,24 @@ export interface CreateColonyInput {
code: string;
apiaryId: string;
colonyType: ColonyType;
hiveType?: string;
originText?: string;
notes?: string;
hiveType?: string | null;
originText?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateColonyInput {
/** Correcting the apiary also writes a move-history entry server-side. */
apiaryId?: string;
code?: string;
hiveType?: string;
originText?: string;
hiveType?: string | null;
originText?: string | null;
colonyType?: ColonyType;
strength?: number;
broodStatus?: ColonyCondition;
feedStatus?: ColonyCondition;
healthStatus?: ColonyCondition;
notes?: string;
strength?: number | null;
broodStatus?: ColonyCondition | null;
feedStatus?: ColonyCondition | null;
healthStatus?: ColonyCondition | null;
notes?: string | null;
status?: ColonyStatus;
}
+71
View File
@@ -0,0 +1,71 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { toApiDate, toLocalIsoDate, todayLocalIsoDate } from "./dates";
/**
* The beekeeper's phone runs in Europe/Berlin. Between 00:00 and 02:00 local
* time (01:00 in winter) the UTC date is still yesterday - a form opened then
* must still prefill today's date.
*/
describe("local calendar dates", () => {
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "Europe/Berlin";
});
afterAll(() => {
process.env.TZ = originalTz;
});
afterEach(() => {
vi.useRealTimers();
});
it("returns today's local date shortly after midnight in summer time", () => {
vi.useFakeTimers();
// 00:30 CEST on 12.10.2026 is still 11.10. in UTC.
vi.setSystemTime(new Date("2026-10-11T22:30:00.000Z"));
expect(todayLocalIsoDate()).toBe("2026-10-12");
});
it("returns today's local date shortly after midnight in winter time", () => {
vi.useFakeTimers();
// 00:15 CET on 16.01.2027 is still 15.01. in UTC.
vi.setSystemTime(new Date("2027-01-15T23:15:00.000Z"));
expect(todayLocalIsoDate()).toBe("2027-01-16");
});
it("keeps the date late in the evening", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-12T21:59:00.000Z")); // 23:59 CEST
expect(todayLocalIsoDate()).toBe("2026-10-12");
});
it("formats any date by its local calendar day with zero padding", () => {
expect(toLocalIsoDate(new Date(2026, 0, 5, 0, 10))).toBe("2026-01-05");
expect(toLocalIsoDate(new Date(2026, 11, 31, 23, 59))).toBe("2026-12-31");
});
it("sends a picked calendar day as midnight UTC of that same day", () => {
expect(toApiDate("2026-10-12")).toBe("2026-10-12T00:00:00.000Z");
expect(toApiDate("2027-01-01")).toBe("2027-01-01T00:00:00.000Z");
});
it("keeps the picked day on a phone far from UTC", () => {
process.env.TZ = "Pacific/Auckland";
try {
expect(toApiDate("2026-10-12")).toBe("2026-10-12T00:00:00.000Z");
} finally {
process.env.TZ = "Europe/Berlin";
}
});
it("refuses something that is not a calendar day instead of sending a wrong date", () => {
expect(() => toApiDate("")).toThrow();
expect(() => toApiDate("12.10.2026")).toThrow();
expect(() => toApiDate("2026-02-30")).toThrow();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The calendar day the beekeeper sees on their phone, as "YYYY-MM-DD".
*
* Not `toISOString().slice(0, 10)`: that is the UTC day, which in Germany is
* still yesterday until 01:00 (winter) or 02:00 (summer) - a form opened
* shortly after midnight would prefill the wrong date.
*/
export function toLocalIsoDate(date: Date): string {
const year = String(date.getFullYear()).padStart(4, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Today's local calendar day, e.g. the default date of a new record. */
export function todayLocalIsoDate(): string {
return toLocalIsoDate(new Date());
}
const CALENDAR_DAY = /^\d{4}-\d{2}-\d{2}$/;
/**
* A day picked in a date field ("YYYY-MM-DD") as the API stores calendar
* days: midnight UTC of exactly that day, whatever the phone's time zone.
* Throws on anything that is not a real calendar day rather than sending a
* shifted or invalid date.
*/
export function toApiDate(calendarDay: string): string {
if (!CALENDAR_DAY.test(calendarDay)) {
throw new RangeError(`Kein Kalendertag: "${calendarDay}"`);
}
const iso = new Date(`${calendarDay}T00:00:00.000Z`).toISOString();
if (iso.slice(0, 10) !== calendarDay) {
throw new RangeError(`Kein Kalendertag: "${calendarDay}"`);
}
return iso;
}
+13 -5
View File
@@ -13,16 +13,24 @@ export interface Equipment {
updatedAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateEquipmentInput {
inventoryNumber: string;
name: string;
manufacturer?: string;
purchaseDate?: string;
location?: string;
maintenanceDate?: string;
manufacturer?: string | null;
purchaseDate?: string | null;
location?: string | null;
maintenanceDate?: string | null;
}
export interface UpdateEquipmentInput extends Partial<CreateEquipmentInput> {
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateEquipmentInput {
inventoryNumber?: string;
name?: string;
manufacturer?: string | null;
purchaseDate?: string | null;
location?: string | null;
maintenanceDate?: string | null;
condition?: EquipmentCondition;
}
+12 -10
View File
@@ -24,23 +24,25 @@ export interface Extraction {
harvests: ExtractionHarvestEntry[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateExtractionInput {
date: string;
harvestIds: string[];
amount?: number;
room?: string;
waterContent?: number;
temperature?: number;
notes?: string;
amount?: number | null;
room?: string | null;
waterContent?: number | null;
temperature?: number | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateExtractionInput {
/** Replaces the whole set of source harvests. */
harvestIds?: string[];
date?: string;
amount?: number;
room?: string;
waterContent?: number;
temperature?: number;
notes?: string;
amount?: number | null;
room?: string | null;
waterContent?: number | null;
temperature?: number | null;
notes?: string | null;
}
+8 -6
View File
@@ -19,25 +19,27 @@ export interface FeedingSummaryEntry {
totalAmount: number;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateFeedingInput {
colonyId: string;
date: string;
feedType: FeedType;
product?: string;
product?: string | null;
amount: number;
unit: string;
batchNumber?: string;
notes?: string;
batchNumber?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateFeedingInput {
/** Re-files the feeding under a different colony. */
colonyId?: string;
date?: string;
feedType?: FeedType;
product?: string;
product?: string | null;
amount?: number;
unit?: string;
batchNumber?: string;
notes?: string;
batchNumber?: string | null;
notes?: string | null;
}
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { emptyToNull } from "./empty-to-null";
describe("emptyToNull", () => {
it.each(["", " ", "\n\t"])("turns the emptied input %j into null, which clears the field", (value) => {
expect(emptyToNull(value)).toBeNull();
expect(emptyToNull(value, Number)).toBeNull();
});
it("keeps text, without surrounding whitespace", () => {
expect(emptyToNull(" Buckfast ")).toBe("Buckfast");
});
it("parses a filled-in value, zero included", () => {
expect(emptyToNull("12", Number)).toBe(12);
expect(emptyToNull("0", Number)).toBe(0);
expect(emptyToNull("2026-06-01", (value) => new Date(value).toISOString())).toBe(
"2026-06-01T00:00:00.000Z",
);
});
it("does not parse an empty input", () => {
const parse = vi.fn();
emptyToNull(" ", parse);
expect(parse).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,31 @@
/**
* Turns an optional form input into its value for a create or update payload.
*
* In a PATCH an omitted field means "leave it as it is". An optional field the
* user emptied therefore has to go out as `null`: dropped as `undefined`, the
* old value would silently survive and a wrong entry could never be removed
* (owner rule: every field stays editable, clearing included). Create payloads
* use the same helper, so every form says "no value" the same way - the API's
* create DTOs accept `null` for optional fields, and values the server
* generates when none is given (batch/lot numbers) use `??`, so `null` also
* triggers the generation.
*
* Only for fields the database stores as nullable. A field that is required
* in the database but has a default (e.g. a stock item's `quantity`, default
* 0) must be omitted when empty - `null` would be rejected.
*
* Whitespace-only counts as empty; text is sent without surrounding
* whitespace. `parse` only runs on a filled-in value, so "0" stays 0.
*/
export function emptyToNull(value: string): string | null;
export function emptyToNull<T>(value: string, parse: (value: string) => T): T | null;
export function emptyToNull<T>(
value: string,
parse?: (value: string) => T,
): T | string | null {
const trimmed = value.trim();
if (trimmed === "") {
return null;
}
return parse ? parse(trimmed) : trimmed;
}
+12 -10
View File
@@ -21,25 +21,27 @@ export interface Harvest {
colonies: HarvestColonyEntry[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateHarvestInput {
apiaryId: string;
date: string;
colonyIds: string[];
honeyType?: string;
honeySuperCount?: number;
grossWeight?: number;
netWeight?: number;
notes?: string;
honeyType?: string | null;
honeySuperCount?: number | null;
grossWeight?: number | null;
netWeight?: number | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateHarvestInput {
apiaryId?: string;
/** Replaces the whole set of participating colonies. */
colonyIds?: string[];
date?: string;
honeyType?: string;
honeySuperCount?: number;
grossWeight?: number;
netWeight?: number;
notes?: string;
honeyType?: string | null;
honeySuperCount?: number | null;
grossWeight?: number | null;
netWeight?: number | null;
notes?: string | null;
}
@@ -1,4 +1,4 @@
import { HoneyBatchStatus } from "./types";
import { HoneyBatch, HoneyBatchStatus } from "./types";
export const HONEY_BATCH_STATUS_LABELS: Record<HoneyBatchStatus, string> = {
RAW: "Roh",
@@ -10,3 +10,8 @@ export const HONEY_BATCH_STATUS_LABELS: Record<HoneyBatchStatus, string> = {
SOLD: "Verkauft",
CONSUMED: "Verbraucht",
};
/** How a batch is named where one is chosen: number, plus how much is left. */
export function honeyBatchOptionLabel(batch: Pick<HoneyBatch, "batchNumber" | "amount">): string {
return batch.amount !== null ? `${batch.batchNumber} · ${batch.amount} kg` : batch.batchNumber;
}
+20 -15
View File
@@ -55,9 +55,10 @@ export interface HoneyBatchRelocation {
relocatedAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface RelocateHoneyBatchInput {
storageContainerId: string;
notes?: string;
notes?: string | null;
}
export interface Traceability {
@@ -81,37 +82,41 @@ export interface Traceability {
}[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateHoneyBatchInput {
batchNumber?: string;
batchNumber?: string | null;
extractionId: string;
honeyType?: string;
amount?: number;
waterContent?: number;
honeyType?: string | null;
amount?: number | null;
waterContent?: number | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateHoneyBatchInput {
batchNumber?: string;
extractionId?: string;
honeyType?: string;
amount?: number;
waterContent?: number;
honeyType?: string | null;
amount?: number | null;
waterContent?: number | null;
/** Correcting the container also writes a relocation entry server-side. */
storageContainerId?: string;
storageContainerId?: string | null;
status?: HoneyBatchStatus;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateHoneyMeasurementInput {
waterContent: number;
measuredAt: string;
device?: string;
temperature?: number;
notes?: string;
device?: string | null;
temperature?: number | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateHoneyMeasurementInput {
waterContent?: number;
measuredAt?: string;
device?: string;
temperature?: number;
notes?: string;
device?: string | null;
temperature?: number | null;
notes?: string | null;
}
+39 -38
View File
@@ -28,50 +28,51 @@ export interface Inspection {
export interface CreateInspectionInput {
colonyId: string;
date: string;
weather?: string;
temperature?: number;
colonyStrength?: number;
broodAmount?: string;
broodPattern?: number;
openBroodPresent?: boolean;
cappedBroodPresent?: boolean;
queenSeen?: boolean;
eggsPresent?: boolean;
queenCellsPresent?: boolean;
swarmMood?: number;
gentleness?: number;
combBehavior?: number;
feedStatus?: string;
honeySuperStatus?: string;
healthIssues?: string;
varroaIssues?: string;
measures?: string;
notes?: string;
weather?: string | null;
temperature?: number | null;
colonyStrength?: number | null;
broodAmount?: string | null;
broodPattern?: number | null;
openBroodPresent?: boolean | null;
cappedBroodPresent?: boolean | null;
queenSeen?: boolean | null;
eggsPresent?: boolean | null;
queenCellsPresent?: boolean | null;
swarmMood?: number | null;
gentleness?: number | null;
combBehavior?: number | null;
feedStatus?: string | null;
honeySuperStatus?: string | null;
healthIssues?: string | null;
varroaIssues?: string | null;
measures?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateInspectionInput {
/** Re-files the inspection under a different colony. */
colonyId?: string;
date?: string;
weather?: string;
temperature?: number;
colonyStrength?: number;
broodAmount?: string;
broodPattern?: number;
openBroodPresent?: boolean;
cappedBroodPresent?: boolean;
queenSeen?: boolean;
eggsPresent?: boolean;
queenCellsPresent?: boolean;
swarmMood?: number;
gentleness?: number;
combBehavior?: number;
feedStatus?: string;
honeySuperStatus?: string;
healthIssues?: string;
varroaIssues?: string;
measures?: string;
notes?: string;
weather?: string | null;
temperature?: number | null;
colonyStrength?: number | null;
broodAmount?: string | null;
broodPattern?: number | null;
openBroodPresent?: boolean | null;
cappedBroodPresent?: boolean | null;
queenSeen?: boolean | null;
eggsPresent?: boolean | null;
queenCellsPresent?: boolean | null;
swarmMood?: number | null;
gentleness?: number | null;
combBehavior?: number | null;
feedStatus?: string | null;
honeySuperStatus?: string | null;
healthIssues?: string | null;
varroaIssues?: string | null;
measures?: string | null;
notes?: string | null;
}
export interface CreateTaskFromInspectionInput {
+8
View File
@@ -0,0 +1,8 @@
/**
* The keys of a map keyed by an enum (labels, counts ...), typed:
* `Record<T, ...>` has exactly the keys `T`, so this is the one place that
* assertion is made - callers need no `Object.keys(...) as T[]` cast.
*/
export function keysOf<T extends string>(record: Readonly<Record<T, unknown>>): T[] {
return Object.keys(record) as T[];
}
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
initialInspectionValues,
toInspectionInput,
} from "@/app/(app)/inspections/new/inspection-form-values";
import { initialVarroaValues, toVarroaInput } from "@/app/(app)/varroa/new/varroa-form-values";
import {
initialFeedingValues,
toFeedingInput,
} from "@/app/(app)/feedings/new/feeding-form-values";
import {
initialTreatmentValues,
toTreatmentInput,
} from "@/app/(app)/treatments/new/treatment-form-values";
import { queueFeeding, queueInspection, queueTreatment, queueVarroaMeasurement } from "./db";
import { syncPendingInspections } from "./sync";
/**
* Stand-in for IndexedDB that stores entries the way the browser does - as a
* structured clone - so the test covers what really survives the queue.
*/
vi.mock("idb", () => {
const stores = new Map<string, Map<number, unknown>>();
let nextId = 1;
const db = {
add: async (store: string, value: object) => {
const id = nextId++;
const entries = stores.get(store) ?? new Map<number, unknown>();
entries.set(id, structuredClone({ ...value, id }));
stores.set(store, entries);
return id;
},
getAll: async (store: string) =>
[...(stores.get(store)?.values() ?? [])].map((entry) => structuredClone(entry)),
delete: async (store: string, id: number) => {
stores.get(store)?.delete(id);
},
count: async (store: string) => stores.get(store)?.size ?? 0,
};
return { openDB: vi.fn(async () => db) };
});
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(async () => ({ id: "saved" })),
}));
function sentBody(path: string): Record<string, unknown> {
const call = vi.mocked(apiFetch).mock.calls.find(([calledPath]) => calledPath === path);
expect(call, `request to ${path}`).toBeDefined();
return JSON.parse(String(call?.[1]?.body));
}
/** What the server receives when the record is sent directly (online). */
function asSentOnline(input: object): Record<string, unknown> {
return JSON.parse(JSON.stringify(input));
}
describe("offline queue keeps 'not filled in' as null from form to server", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
it("replays queued records exactly as they would have been sent online", async () => {
const inspection = toInspectionInput({
...initialInspectionValues("colony-1"),
eggsPresent: false,
});
const varroa = toVarroaInput({ ...initialVarroaValues("colony-1"), miteCount: "0" });
const feeding = toFeedingInput({ ...initialFeedingValues("colony-1"), amount: "2" });
const treatment = toTreatmentInput({
...initialTreatmentValues("colony-1"),
product: "Oxuvar",
});
await queueInspection(inspection, "key-inspection");
await queueVarroaMeasurement(varroa, "key-varroa");
await queueFeeding(feeding, "key-feeding");
await queueTreatment(treatment, "key-treatment");
const result = await syncPendingInspections();
expect(result).toEqual({ synced: 4, failed: 0 });
expect(sentBody("/inspections")).toEqual(asSentOnline(inspection));
expect(sentBody("/varroa-measurements")).toEqual(asSentOnline(varroa));
expect(sentBody("/feedings")).toEqual(asSentOnline(feeding));
expect(sentBody("/treatments")).toEqual(asSentOnline(treatment));
});
it("sends an untouched inspection observation as null, never as a finding", async () => {
await queueInspection(toInspectionInput(initialInspectionValues("colony-1")), "key-2");
await syncPendingInspections();
expect(sentBody("/inspections")).toMatchObject({
queenSeen: null,
eggsPresent: null,
feedStatus: null,
weather: null,
gentleness: null,
});
});
});
@@ -21,14 +21,15 @@ export interface ProductionBatch {
}[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateProductionBatchInput {
productId: string;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
lotNumber?: string | null;
date: string;
quantity: number;
bestBeforeDate?: string;
notes?: string;
bestBeforeDate?: string | null;
notes?: string | null;
inputs?: { rawMaterialLotId: string; quantity: number }[];
}
@@ -38,7 +39,7 @@ export interface UpdateProductionBatchInput {
quantity?: number;
/** `null` removes the date, `undefined` leaves it untouched. */
bestBeforeDate?: string | null;
notes?: string;
notes?: string | null;
}
export interface ProductionBatchFilter {
+16 -6
View File
@@ -20,18 +20,28 @@ export interface Product {
updatedAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateProductInput {
name: string;
description?: string;
sku?: string;
packagingSize?: number;
weight?: number;
salesUnit?: string;
description?: string | null;
sku?: string | null;
packagingSize?: number | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
shopCategory?: ShopCategory;
}
export interface UpdateProductInput extends Partial<CreateProductInput> {
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateProductInput {
name?: string;
description?: string | null;
sku?: string | null;
packagingSize?: number | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
shopCategory?: ShopCategory;
status?: ProductStatus;
}
+35 -27
View File
@@ -30,37 +30,39 @@ export interface Queen {
deactivatedAt: string | null;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateQueenInput {
code: string;
hatchYear?: number;
breed?: string;
origin?: string;
breeder?: string;
markingColor?: string;
markingNumber?: string;
matingType?: string;
matingStatus?: string;
inseminationData?: string;
motherLine?: string;
fatherLine?: string;
motherQueenId?: string;
notes?: string;
hatchYear?: number | null;
breed?: string | null;
origin?: string | null;
breeder?: string | null;
markingColor?: string | null;
markingNumber?: string | null;
matingType?: string | null;
matingStatus?: string | null;
inseminationData?: string | null;
motherLine?: string | null;
fatherLine?: string | null;
motherQueenId?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateQueenInput {
hatchYear?: number;
breed?: string;
origin?: string;
breeder?: string;
markingColor?: string;
markingNumber?: string;
matingType?: string;
matingStatus?: string;
inseminationData?: string;
motherLine?: string;
fatherLine?: string;
motherQueenId?: string;
notes?: string;
hatchYear?: number | null;
breed?: string | null;
origin?: string | null;
breeder?: string | null;
markingColor?: string | null;
markingNumber?: string | null;
matingType?: string | null;
matingStatus?: string | null;
inseminationData?: string | null;
motherLine?: string | null;
fatherLine?: string | null;
motherQueenId?: string | null;
notes?: string | null;
status?: QueenStatus;
}
@@ -125,4 +127,10 @@ export interface CreateQueenEvaluationInput {
notes?: string;
}
export type UpdateQueenEvaluationInput = Partial<CreateQueenEvaluationInput>;
/** `null` clears an optional field, an omitted one stays unchanged. */
export type UpdateQueenEvaluationInput = Partial<
Omit<CreateQueenEvaluationInput, "colonyId" | "notes">
> & {
colonyId?: string | null;
notes?: string | null;
};
@@ -26,12 +26,13 @@ export interface RawMaterialLot {
}[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateRawMaterialLotInput {
kind: RawMaterialKind;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
lotNumber?: string | null;
date: string;
quantity: number;
apiaryId?: string;
notes?: string;
apiaryId?: string | null;
notes?: string | null;
}
+4 -2
View File
@@ -23,16 +23,18 @@ export interface RoleSummary {
deactivatedAt: string | null;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateRoleInput {
key: string;
name: string;
description?: string;
description?: string | null;
permissionKeys: string[];
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateRoleInput {
key?: string;
name?: string;
description?: string;
description?: string | null;
permissionKeys?: string[];
}
+3 -1
View File
@@ -1,5 +1,7 @@
import type { ColonyStatus } from "@/lib/colonies/types";
export interface ColonyStats {
byStatus: { status: string; count: number }[];
byStatus: { status: ColonyStatus; count: number }[];
strengthOverTime: { colonyId: string; date: string; colonyStrength: number }[];
lossesByYear: Record<string, number>;
}
+2 -1
View File
@@ -39,10 +39,11 @@ export interface BatchStock {
movements: StockMovement[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface WithdrawInput {
quantity: number;
reason: WithdrawalReason;
notes?: string;
notes?: string | null;
}
export interface CorrectInput {
+8 -1
View File
@@ -1,4 +1,4 @@
import { StockCategory, StockMovementType, StorageContainerStatus } from "./types";
import { StockCategory, StockMovementType, StorageContainer, StorageContainerStatus } from "./types";
export const STORAGE_CONTAINER_STATUS_LABELS: Record<StorageContainerStatus, string> = {
EMPTY: "Leer",
@@ -23,3 +23,10 @@ export const STOCK_MOVEMENT_TYPE_LABELS: Record<StockMovementType, string> = {
TRANSFER: "Umlagerung",
ADJUSTMENT: "Korrektur",
};
/** How a container is named where one is chosen: code, plus where it stands. */
export function storageContainerOptionLabel(
container: Pick<StorageContainer, "code" | "location">,
): string {
return container.location ? `${container.code} · ${container.location}` : container.code;
}
+14 -10
View File
@@ -34,37 +34,41 @@ export interface StockItem {
movements?: StockMovement[];
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateStorageContainerInput {
code: string;
containerType?: string;
capacity?: number;
location?: string;
containerType?: string | null;
capacity?: number | null;
location?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateStorageContainerInput {
code?: string;
containerType?: string;
capacity?: number;
location?: string;
containerType?: string | null;
capacity?: number | null;
location?: string | null;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateStockItemInput {
name: string;
category: StockCategory;
unit: string;
quantity?: number;
minThreshold?: number;
notes?: string;
minThreshold?: number | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateStockItemInput {
name?: string;
/** Correcting the quantity books the difference as an ADJUSTMENT movement. */
quantity?: number;
category?: StockCategory;
unit?: string;
minThreshold?: number;
notes?: string;
minThreshold?: number | null;
notes?: string | null;
}
export interface CreateStockMovementInput {
@@ -20,25 +20,27 @@ export interface SwarmControlCheck {
createdAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateSwarmControlCheckInput {
colonyId: string;
date: string;
queenCellsFound?: number;
queenCellsFound?: number | null;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
notes?: string | null;
nextCheckDate?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateSwarmControlCheckInput {
/** Re-files the check under a different colony. */
colonyId?: string;
date?: string;
queenCellsFound?: number;
queenCellsFound?: number | null;
queenCellsCapped?: boolean;
swarmMood?: SwarmMood;
actionTaken?: SwarmControlAction;
notes?: string;
nextCheckDate?: string;
notes?: string | null;
nextCheckDate?: string | null;
}
@@ -0,0 +1,32 @@
import { TaskTemplateRecurrenceType } from "./types";
export const RECURRENCE_TYPE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
/** Month of a yearly template as the select holds it: "1" = January. */
export type Month = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12";
export const MONTH_LABELS: Record<Month, string> = {
"1": "Januar",
"2": "Februar",
"3": "März",
"4": "April",
"5": "Mai",
"6": "Juni",
"7": "Juli",
"8": "August",
"9": "September",
"10": "Oktober",
"11": "November",
"12": "Dezember",
};
export const MONTHS: Month[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
/** The stored month number as a select value; January when none is stored. */
export function toMonth(month: number | null | undefined): Month {
return MONTHS.find((candidate) => candidate === String(month)) ?? "1";
}
@@ -19,22 +19,24 @@ export interface TaskTemplate {
updatedAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateTaskTemplateInput {
title: string;
description?: string;
description?: string | null;
priority?: TaskPriority;
assignedUserId?: string;
assignedUserId?: string | null;
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
intervalDays?: number;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateTaskTemplateInput {
title?: string;
description?: string;
description?: string | null;
priority?: TaskPriority;
assignedUserId?: string;
assignedUserId?: string | null;
recurrenceType?: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
+9 -7
View File
@@ -34,21 +34,23 @@ export interface TaskFilter {
priority?: TaskPriority;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateTaskInput {
title: string;
description?: string;
description?: string | null;
priority?: TaskPriority;
dueDate?: string;
assignedUserId?: string;
dueDate?: string | null;
assignedUserId?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateTaskInput {
title?: string;
description?: string;
description?: string | null;
priority?: TaskPriority;
dueDate?: string;
assignedUserId?: string;
dueDate?: string | null;
assignedUserId?: string | null;
status?: TaskStatus;
relatedType?: TaskRelatedType;
relatedId?: string;
relatedId?: string | null;
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchWithdrawalStatus } from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => []) }));
describe("fetchWithdrawalStatus", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
it("asks for the withdrawal windows at the harvest date", async () => {
await fetchWithdrawalStatus(["colony-a", "colony-b"], "2026-07-15");
expect(apiFetch).toHaveBeenCalledWith(
"/treatments/withdrawal-status?colonyIds=colony-a%2Ccolony-b&at=2026-07-15",
);
});
it("leaves the date to the server when none is given", async () => {
await fetchWithdrawalStatus(["colony-a"]);
expect(apiFetch).toHaveBeenCalledWith("/treatments/withdrawal-status?colonyIds=colony-a");
});
it("keeps a crafted id inside its parameter - it cannot set the date", async () => {
await fetchWithdrawalStatus(["x&at=1999-01-01"], "2026-07-15");
const [path] = vi.mocked(apiFetch).mock.calls[0];
const query = new URLSearchParams(String(path).split("?")[1]);
expect(query.getAll("at")).toEqual(["2026-07-15"]);
expect(query.get("colonyIds")).toBe("x&at=1999-01-01");
});
it("does not call the server without colonies", async () => {
await expect(fetchWithdrawalStatus([], "2026-07-15")).resolves.toEqual([]);
expect(apiFetch).not.toHaveBeenCalled();
});
});
+9 -3
View File
@@ -11,15 +11,21 @@ export function fetchColonyTreatments(colonyId: string): Promise<Treatment[]> {
return apiFetch<Treatment[]>(`/colonies/${encodeURIComponent(colonyId)}/treatments`);
}
/**
* Active withdrawal windows of the given colonies at `at` (a harvest date,
* "YYYY-MM-DD"); without `at` the server checks "now". Built with
* URLSearchParams so an id can never add or override a query parameter.
*/
export function fetchWithdrawalStatus(
colonyIds: string[],
at?: string,
): Promise<WithdrawalStatus[]> {
if (colonyIds.length === 0) {
return Promise.resolve([]);
}
return apiFetch<WithdrawalStatus[]>(
`/treatments/withdrawal-status?colonyIds=${colonyIds.join(",")}`,
);
const params = new URLSearchParams({ colonyIds: colonyIds.join(",") });
if (at) params.set("at", at);
return apiFetch<WithdrawalStatus[]>(`/treatments/withdrawal-status?${params.toString()}`);
}
export function createTreatment(
+26 -24
View File
@@ -19,41 +19,43 @@ export interface Treatment {
createdAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateTreatmentInput {
colonyId: string;
date: string;
startDate?: string;
endDate?: string;
startDate?: string | null;
endDate?: string | null;
product: string;
activeIngredient?: string;
amount?: string;
method?: string;
reason?: string;
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
reason?: string | null;
batchNumber?: string | null;
notes?: string | null;
withdrawalPeriodDays?: number | null;
supplier?: string | null;
purchaseProof?: string | null;
prescribingVet?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateTreatmentInput {
/** Re-files the treatment under a different colony. */
colonyId?: string;
date?: string;
startDate?: string;
endDate?: string;
startDate?: string | null;
endDate?: string | null;
product?: string;
activeIngredient?: string;
amount?: string;
method?: string;
reason?: string;
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
reason?: string | null;
batchNumber?: string | null;
notes?: string | null;
withdrawalPeriodDays?: number | null;
supplier?: string | null;
purchaseProof?: string | null;
prescribingVet?: string | null;
}
export interface WithdrawalStatus {
+12 -10
View File
@@ -18,25 +18,27 @@ export interface VarroaMeasurement {
createdAt: string;
}
/** Optional fields: `null` or omitted both mean "no value". */
export interface CreateVarroaMeasurementInput {
colonyId: string;
date: string;
method: VarroaMethod;
durationMinutes?: number;
miteCount?: number;
calculatedValue?: number;
assessment?: string;
notes?: string;
durationMinutes?: number | null;
miteCount?: number | null;
calculatedValue?: number | null;
assessment?: string | null;
notes?: string | null;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
export interface UpdateVarroaMeasurementInput {
/** Re-files the measurement under a different colony. */
colonyId?: string;
date?: string;
method?: VarroaMethod;
durationMinutes?: number;
miteCount?: number;
calculatedValue?: number;
assessment?: string;
notes?: string;
durationMinutes?: number | null;
miteCount?: number | null;
calculatedValue?: number | null;
assessment?: string | null;
notes?: string | null;
}
@@ -1,3 +1,5 @@
import type { ColonyStatus } from "@/lib/colonies/types";
export type WinterCheckSeason = "AUTUMN" | "SPRING";
export type WinterOutcome = "SURVIVED" | "WEAK" | "LOST";
export type ColonyLossCause = "DEAD_OR_EMPTY" | "QUEEN_PROBLEM" | "NATURAL_DISASTER";
@@ -23,23 +25,25 @@ export interface WinterCheckWithColony extends WinterCheck {
colony: {
id: string;
code: string;
status: string;
status: ColonyStatus;
apiaryId: string;
apiary: { id: string; name: string };
};
}
/** `null` clears an optional field on an update, an omitted one stays unchanged. */
export interface WinterCheckInput {
colonyId: string;
season: WinterCheckSeason;
date: string;
winterYear?: number;
occupiedSeams?: number;
feedStockKg?: number;
queenPresent?: boolean;
outcome?: WinterOutcome;
lossCause?: ColonyLossCause;
notes?: string;
occupiedSeams?: number | null;
feedStockKg?: number | null;
/** null = not checked; on an update it clears an earlier answer. */
queenPresent?: boolean | null;
outcome?: WinterOutcome | null;
lossCause?: ColonyLossCause | null;
notes?: string | null;
/** With outcome LOST: also mark the colony as deceased. */
markColonyDeceased?: boolean;
}
@@ -25,7 +25,7 @@ function colony(overrides: Partial<Colony>): Colony {
};
}
const deadColony = {
const deadColony: WinterCheckWithColony["colony"] = {
id: "c1",
code: "V-01",
status: "DECEASED",
@@ -1,8 +1,7 @@
import type { Colony } from "@/lib/colonies/types";
import { isLivingColony } from "@/lib/colonies/status";
import type { WinterCheckWithColony } from "./types";
const TERMINAL_STATUSES = new Set(["DISSOLVED", "SOLD", "DECEASED", "MERGED"]);
/** From July on a date belongs to the winter starting that year. */
export function winterYearFor(date: Date): number {
return date.getMonth() >= 6 ? date.getFullYear() : date.getFullYear() - 1;
@@ -38,7 +37,7 @@ export function buildWinterRows(
const names = new Map(apiaryNames);
const rows = new Map<string, WinterRow & { apiaryId: string }>();
for (const colony of colonies) {
if (TERMINAL_STATUSES.has(colony.status)) continue;
if (!isLivingColony(colony)) continue;
rows.set(colony.id, {
colonyId: colony.id,
code: colony.code,
@@ -53,7 +52,7 @@ export function buildWinterRows(
colonyId: check.colonyId,
code: check.colony.code,
apiaryId: check.colony.apiaryId,
isLiving: !TERMINAL_STATUSES.has(check.colony.status),
isLiving: isLivingColony(check.colony),
autumn: null,
spring: null,
};