Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts: # ARCHITECTURE.md
This commit is contained in:
commit
9c332d4716
310 files changed
+12177
-3746
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(", ")}.`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user