From 0c0906a2d934cc53ca98055f454a93e6d653ee95 Mon Sep 17 00:00:00 2001 From: wangxiaoxiang5599 Date: Sat, 12 Sep 2026 15:08:58 +1200 Subject: [PATCH 1/8] Add design document for Mini Farm Manager Covers domain model, business rules (capacity, movements, status), application layer, routes, frontend pages, testing strategy and the chosen additional feature (paddock capacity warning). Co-Authored-By: Claude Opus 5 --- docs/design.md | 268 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/design.md diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..aa6b5eb --- /dev/null +++ b/docs/design.md @@ -0,0 +1,268 @@ +# Mini Farm Manager — Design Document + +This document records the domain model, business rules, application structure and testing strategy for the Mini Farm Manager exercise. It was written *before* implementation so that decisions are made once, deliberately, and can be referenced from the README. + +Requirements: see [`requirements.md`](requirements.md). + +--- + +## 1. Overview and stack + +| Layer | Choice | Why | +|---|---|---| +| Backend | Laravel 13 / PHP 8.4 | Required by the brief | +| Frontend | Inertia 3 + Vue 3.5 + TypeScript | Server-driven pages without a separate JSON API: less glue code, validation errors flow straight into `useForm` | +| UI | Tailwind 4 + shadcn-vue components (`resources/js/components/ui`) | Already shipped with the starter kit; consistent look with minimal effort | +| Routing glue | Laravel Wayfinder | Typed route/action helpers in TypeScript, no hard-coded URLs | +| Database | SQLite | Zero setup for reviewers; schema is plain enough to run on MySQL/Postgres unchanged | +| Tests | PHPUnit 12 (feature tests) | Starter kit default | + +**Authentication**: the brief says authentication is not required. The starter kit ships with Fortify (login, registration, 2FA, passkeys). We keep it installed — removing it costs time and buys nothing — but all farm routes are registered **outside** the `auth` middleware group so a reviewer can use the app without logging in. + +--- + +## 2. Domain model + +``` +paddocks 1 ──< animals (animals.current_paddock_id, nullable) +animals 1 ──< animal_movements (from_paddock_id?, to_paddock_id?) +animals 1 ──< health_records +``` + +### `animals` + +| Column | Type | Constraints / notes | +|---|---|---| +| `id` | bigint PK | | +| `tag_number` | string | required, **unique** | +| `name` | string | nullable ("if applicable") | +| `species` | string (enum `Species`) | `cattle`, `sheep`, `goat`, `pig`, `horse`, `chicken`, `other` | +| `sex` | string (enum `Sex`) | `male`, `female` | +| `date_of_birth` | date | required, must not be in the future | +| `breed` | string | nullable | +| `status` | string (enum `AnimalStatus`) | `active` (default), `sold`, `deceased` | +| `notes` | text | nullable | +| `current_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| timestamps | | | + +### `paddocks` + +| Column | Type | Constraints / notes | +|---|---|---| +| `id` | bigint PK | | +| `name` | string | required, **unique** | +| `capacity` | unsigned int | required, `>= 1` | +| `notes` | text | nullable | +| timestamps | | | + +### `animal_movements` + +| Column | Type | Constraints / notes | +|---|---|---| +| `id` | bigint PK | | +| `animal_id` | FK → `animals` | `cascadeOnDelete` | +| `from_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| `to_paddock_id` | FK → `paddocks` | nullable, `nullOnDelete` | +| `moved_at` | datetime | required | +| `notes` | text | nullable | +| timestamps | | | + +Index on `(animal_id, moved_at)`. + +Semantics of the nullable ends: + +- `from = null, to = X` — initial placement (animal enters the farm / first assignment). +- `from = X, to = null` — animal leaves its paddock (status changed to `sold` or `deceased`). +- `from = X, to = Y` — ordinary move. + +### `health_records` + +| Column | Type | Constraints / notes | +|---|---|---| +| `id` | bigint PK | | +| `animal_id` | FK → `animals` | `cascadeOnDelete` | +| `recorded_on` | date | required, not in the future | +| `type` | string (enum `HealthRecordType`) | `vaccination`, `treatment`, `injury`, `checkup`, `other` | +| `description` | string | required, short summary | +| `notes` | text | nullable | +| timestamps | | | + +Index on `(animal_id, recorded_on)`. + +### Enums + +PHP backed enums in `app/Enums/` (`Species`, `Sex`, `AnimalStatus`, `HealthRecordType`), each with a `label()` method. Controllers pass them to Vue as `{ value, label }[]` option lists so the frontend never hard-codes the values. + +### Design decision: store `current_paddock_id` or derive it? + +**Stored.** Every read path — animal list, paddock page, dashboard, and the capacity check itself — needs "where is this animal now". Deriving it from the latest movement means a correlated subquery on every one of those queries. + +The trade-off is a denormalised column that can drift from the movement history. Mitigations: + +- `current_paddock_id` is **only ever written inside the same database transaction** that inserts the corresponding `animal_movements` row (see `MoveAnimal` below). No other code path touches it. +- A feature test asserts that after a sequence of moves, `current_paddock_id` equals the `to_paddock_id` of the animal's latest movement. + +The movement table remains the source of truth for *history*; the column is a cache of its most recent row. + +--- + +## 3. Business rules + +1. **Occupancy.** `occupancy(paddock) = count(animals where current_paddock_id = paddock.id AND status = active)`. Non-active animals never occupy a paddock (rule 3 guarantees they have no `current_paddock_id` anyway). + +2. **Moving an animal** into paddock *T* is allowed only when: + - the animal's status is `active`; + - *T* differs from the animal's current paddock; + - `occupancy(T) < T.capacity`. + + The move runs inside `DB::transaction()` and takes `lockForUpdate()` on the target paddock row before counting, so two concurrent moves cannot both pass the check and overfill the paddock. + +3. **Status change away from `active`** (`sold` or `deceased`) removes the animal from its paddock: a movement `(from = current, to = null)` is recorded and `current_paddock_id` is set to null, in one transaction. Changing status back to `active` leaves the animal unplaced; the user assigns a paddock through the normal move action (the edit form shows a hint explaining this). + +4. **Initial placement.** The "add animal" form has an optional paddock select. If provided, the animal is created and immediately placed via the same move logic, so the capacity rule applies from the very first record. + +5. **Editing a paddock's capacity** below its current occupancy is rejected with a validation error. This keeps "a paddock is never over capacity" as a hard invariant rather than a soft warning. + +6. **Capacity warning** *(the additional feature)*. Each paddock has a derived `occupancy_state`: + + | Condition | State | UI | + |---|---|---| + | `occupancy / capacity < threshold` | `ok` | neutral | + | `threshold <= ratio < 1` | `warning` | amber highlight | + | `ratio >= 1` | `full` | red highlight, move target disabled | + + `threshold` lives in `config/farm.php` (`capacity_warning_threshold`, default `0.8`). The state is computed server-side in one place (`Paddock::occupancyState()`, using `withCount` for lists) so every page agrees. + +7. **Deletion** is out of scope: the brief asks only for add / edit / view. No destroy routes are exposed. This also sidesteps "what happens to history when a paddock is deleted" — the `nullOnDelete` FKs are there only as a safety net. + +--- + +## 4. Application layer + +### Actions (single-purpose classes in `app/Actions/`) + +- **`MoveAnimal`** — the one non-trivial write in the system. + `handle(Animal $animal, ?Paddock $to, CarbonInterface $movedAt, ?string $notes = null): AnimalMovement` + Opens a transaction, locks the target paddock, checks rules 2/3, inserts the movement, updates `current_paddock_id`. Violations throw `ValidationException` keyed to the form field (`to_paddock_id`) so Inertia renders them inline. + +- **`ChangeAnimalStatus`** — applies a status change; when leaving `active` it delegates to `MoveAnimal` with `to = null`. + +### Form Requests (`app/Http/Requests/`) + +`StoreAnimalRequest`, `UpdateAnimalRequest`, `StorePaddockRequest`, `UpdatePaddockRequest` (includes the "capacity >= occupancy" rule), `StoreAnimalMovementRequest`, `StoreHealthRecordRequest`. + +Validation lives here; the Actions enforce the invariants that depend on database state. + +### Controllers (`app/Http/Controllers/`) + +| Controller | Methods | +|---|---| +| `DashboardController` | `__invoke` | +| `AnimalController` | `index`, `create`, `store`, `show`, `edit`, `update` | +| `PaddockController` | `index`, `create`, `store`, `show`, `edit`, `update` | +| `AnimalMovementController` | `store` | +| `HealthRecordController` | `store` | + +Controllers stay thin: validate via Form Request, call an Action or a simple Eloquent write, redirect with a flash message. + +### Models, factories, seeder + +- Models: `Animal`, `Paddock`, `AnimalMovement`, `HealthRecord` with enum casts and relationships. +- Factories with states: `AnimalFactory::active() / sold() / deceased() / inPaddock(Paddock)`, `PaddockFactory::withCapacity(int)`. +- `FarmSeeder` builds a realistic demo farm: a handful of paddocks (including one at ~85 % and one full), ~20 animals across species, a movement history and a few health records — so the dashboard and warnings are visible immediately after `php artisan migrate --seed`. + +--- + +## 5. Routes (`routes/web.php`, all public) + +``` +GET / → redirect to dashboard +GET /dashboard dashboard + +GET /animals animals.index filters: species, status, paddock, search (tag/name) +GET /animals/create animals.create +POST /animals animals.store +GET /animals/{animal} animals.show movement history, health history, move dialog, add health record +GET /animals/{animal}/edit animals.edit +PUT /animals/{animal} animals.update +POST /animals/{animal}/movements animals.movements.store +POST /animals/{animal}/health-records animals.health-records.store + +GET /paddocks paddocks.index occupancy + warning badge per paddock +GET /paddocks/create paddocks.create +POST /paddocks paddocks.store +GET /paddocks/{paddock} paddocks.show animals currently inside +GET /paddocks/{paddock}/edit paddocks.edit +PUT /paddocks/{paddock} paddocks.update +``` + +Fortify and settings routes remain as generated by the starter kit. The sidebar's `NavUser` component must tolerate a guest (`auth.user === null`) and show a "Log in" link instead of the user menu. + +--- + +## 6. Frontend + +Pages under `resources/js/pages/`: + +| Page | Content | +|---|---| +| `Dashboard.vue` | Stat cards (active animals, paddocks, paddocks needing attention); animals by species; paddock occupancy list with progress bars (amber ≥ 80 %, red when full); recent movements; capacity-warning panel | +| `animals/Index.vue` | Filterable table: tag, name, species, sex, age, status badge, current paddock | +| `animals/Create.vue`, `animals/Edit.vue` | Shared `AnimalForm`; create offers optional initial paddock | +| `animals/Show.vue` | Details, current paddock, "Move" dialog, movement history, health history + "Add record" form | +| `paddocks/Index.vue` | Cards/table with occupancy bar and state badge | +| `paddocks/Create.vue`, `paddocks/Edit.vue` | Shared `PaddockForm` | +| `paddocks/Show.vue` | Capacity summary and the list of animals currently inside | + +Shared components in `resources/js/components/farm/`: `AnimalForm`, `PaddockForm`, `MoveAnimalDialog`, `HealthRecordForm`, `OccupancyBar`, `AnimalStatusBadge`, `EmptyState`. + +Conventions: + +- Forms use Inertia `useForm` and Wayfinder action helpers (`@/actions/App/Http/Controllers/...`). +- Server flashes `success` through `HandleInertiaRequests`; the layout shows it with the existing `sonner` toaster. +- Sidebar navigation: Dashboard, Animals, Paddocks. +- Shared TypeScript types in `resources/js/types/farm.d.ts`. +- Empty states everywhere a list can be empty (new install without seeding). + +--- + +## 7. Testing strategy + +Feature tests (PHPUnit, `RefreshDatabase`, factories), covering behaviour the farmer would notice if it broke: + +| Test class | Cases | +|---|---| +| `AnimalTest` | create with valid data; duplicate tag rejected; DOB in the future rejected; create with initial paddock records a `null → paddock` movement; update basic fields; status → `sold` removes from paddock and writes a `paddock → null` movement | +| `PaddockTest` | create / update validation; capacity below current occupancy rejected; show lists only active animals currently inside | +| `AnimalMovementTest` | successful move updates `current_paddock_id` and appends history; rejected when target is full; rejected when target equals current; rejected when animal is not active; `current_paddock_id` matches latest movement after several moves | +| `HealthRecordTest` | store with valid data; validation failures; animal page lists records newest first | +| `DashboardTest` | animal counts and species breakdown; warnings panel includes a paddock at ≥ 80 % and excludes one below | +| `PaddockOccupancyTest` | `occupancyState()` boundaries: 79 % → ok, 80 % → warning, 100 % → full; sold animals do not count | + +Not tested: Fortify auth (already covered by starter kit tests), pure layout. + +--- + +## 8. Deliberately left out / production improvements + +Left out to respect the 3–4 hour budget: + +- Deleting animals, paddocks or records +- Recording *who* performed a movement (no auth in scope) +- Weight / medication history, photos, documents +- Bulk moves (move a whole mob between paddocks) +- Pagination beyond a simple paginator on the animals list + +Before production: + +- Real concurrency test of the capacity lock against MySQL/Postgres +- Authorisation (multiple farms / users) +- Soft deletes and an audit log +- Reporting exports (CSV) and date-range filters on history +- Accessibility and mobile review of the forms + +--- + +## 9. AI-assisted development + +Claude Code (Anthropic) was used to scaffold the project via `laravel new … --boost`, to draft this document from a guided discussion, and to generate boilerplate, pages and tests. Every business rule in §3 was decided in conversation, and all generated code is reviewed and verified by running the test suite and the app locally. Details go in the README's "How AI tools were used" section. From 54095a54bd633673411723cb305d84bb19c2bc85 Mon Sep 17 00:00:00 2001 From: wangxiaoxiang5599 Date: Sat, 12 Sep 2026 15:23:54 +1200 Subject: [PATCH 2/8] Stage 1: schema, enums, models and public app shell - Migrations for paddocks, animals, animal_movements, health_records - String-backed enums (Species, Sex, AnimalStatus, HealthRecordType, OccupancyState) with a shared HasOptions trait - Eloquent models with relationships, casts and paddock occupancy helpers - Factories with states for tests and seeding - config/farm.php with the capacity warning threshold - Farm routes are public: / redirects to /dashboard, dashboard moved out of the auth group, sidebar shows Animals/Paddocks and a Log in link for guests, Welcome page removed Co-Authored-By: Claude Opus 5 --- .env.example | 2 +- app/Enums/AnimalStatus.php | 20 + app/Enums/HasOptions.php | 33 ++ app/Enums/HealthRecordType.php | 14 + app/Enums/OccupancyState.php | 21 + app/Enums/Sex.php | 11 + app/Enums/Species.php | 16 + app/Models/Animal.php | 97 ++++ app/Models/AnimalMovement.php | 64 +++ app/Models/HealthRecord.php | 47 ++ app/Models/Paddock.php | 93 ++++ config/farm.php | 17 + database/factories/AnimalFactory.php | 81 ++++ database/factories/AnimalMovementFactory.php | 36 ++ database/factories/HealthRecordFactory.php | 36 ++ database/factories/PaddockFactory.php | 35 ++ ...026_09_12_031624_create_paddocks_table.php | 24 + ...2026_09_12_031625_create_animals_table.php | 36 ++ ...2_031626_create_animal_movements_table.php | 34 ++ ..._12_031627_create_health_records_table.php | 28 ++ resources/js/app.ts | 2 - resources/js/components/AppHeader.vue | 4 +- resources/js/components/AppSidebar.vue | 18 +- resources/js/components/NavUser.vue | 14 +- resources/js/pages/Welcome.vue | 443 ------------------ resources/js/pages/settings/Profile.vue | 3 +- resources/js/types/auth.ts | 2 +- routes/web.php | 13 +- tests/Feature/DashboardTest.php | 8 +- tests/Feature/ExampleTest.php | 4 +- 30 files changed, 789 insertions(+), 467 deletions(-) create mode 100644 app/Enums/AnimalStatus.php create mode 100644 app/Enums/HasOptions.php create mode 100644 app/Enums/HealthRecordType.php create mode 100644 app/Enums/OccupancyState.php create mode 100644 app/Enums/Sex.php create mode 100644 app/Enums/Species.php create mode 100644 app/Models/Animal.php create mode 100644 app/Models/AnimalMovement.php create mode 100644 app/Models/HealthRecord.php create mode 100644 app/Models/Paddock.php create mode 100644 config/farm.php create mode 100644 database/factories/AnimalFactory.php create mode 100644 database/factories/AnimalMovementFactory.php create mode 100644 database/factories/HealthRecordFactory.php create mode 100644 database/factories/PaddockFactory.php create mode 100644 database/migrations/2026_09_12_031624_create_paddocks_table.php create mode 100644 database/migrations/2026_09_12_031625_create_animals_table.php create mode 100644 database/migrations/2026_09_12_031626_create_animal_movements_table.php create mode 100644 database/migrations/2026_09_12_031627_create_health_records_table.php delete mode 100644 resources/js/pages/Welcome.vue diff --git a/.env.example b/.env.example index 40bb13b..bb2bfc2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Laravel +APP_NAME="Mini Farm Manager" APP_ENV=local APP_KEY= APP_DEBUG=true diff --git a/app/Enums/AnimalStatus.php b/app/Enums/AnimalStatus.php new file mode 100644 index 0000000..8f8f2e7 --- /dev/null +++ b/app/Enums/AnimalStatus.php @@ -0,0 +1,20 @@ +value); + } + + /** + * @return array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $case): array => ['value' => $case->value, 'label' => $case->label()], + self::cases(), + ); + } +} diff --git a/app/Enums/HealthRecordType.php b/app/Enums/HealthRecordType.php new file mode 100644 index 0000000..e346bbb --- /dev/null +++ b/app/Enums/HealthRecordType.php @@ -0,0 +1,14 @@ += 1.0 => self::Full, + $ratio >= $threshold => self::Warning, + default => self::Ok, + }; + } +} diff --git a/app/Enums/Sex.php b/app/Enums/Sex.php new file mode 100644 index 0000000..5c535a9 --- /dev/null +++ b/app/Enums/Sex.php @@ -0,0 +1,11 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'species' => Species::class, + 'sex' => Sex::class, + 'status' => AnimalStatus::class, + 'date_of_birth' => 'date', + ]; + } + + /** + * @return BelongsTo + */ + public function currentPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'current_paddock_id'); + } + + /** + * @return HasMany + */ + public function movements(): HasMany + { + return $this->hasMany(AnimalMovement::class); + } + + /** + * @return HasMany + */ + public function healthRecords(): HasMany + { + return $this->hasMany(HealthRecord::class); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function active(Builder $query): Builder + { + return $query->where('status', AnimalStatus::Active); + } + + public function isActive(): bool + { + return $this->status === AnimalStatus::Active; + } + + /** + * Tag number, with the name appended when the animal has one. + */ + public function displayName(): string + { + return $this->name ? "{$this->tag_number} ({$this->name})" : $this->tag_number; + } +} diff --git a/app/Models/AnimalMovement.php b/app/Models/AnimalMovement.php new file mode 100644 index 0000000..64eb300 --- /dev/null +++ b/app/Models/AnimalMovement.php @@ -0,0 +1,64 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'moved_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function animal(): BelongsTo + { + return $this->belongsTo(Animal::class); + } + + /** + * @return BelongsTo + */ + public function fromPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'from_paddock_id'); + } + + /** + * @return BelongsTo + */ + public function toPaddock(): BelongsTo + { + return $this->belongsTo(Paddock::class, 'to_paddock_id'); + } +} diff --git a/app/Models/HealthRecord.php b/app/Models/HealthRecord.php new file mode 100644 index 0000000..05a7afa --- /dev/null +++ b/app/Models/HealthRecord.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'recorded_on' => 'date', + 'type' => HealthRecordType::class, + ]; + } + + /** + * @return BelongsTo + */ + public function animal(): BelongsTo + { + return $this->belongsTo(Animal::class); + } +} diff --git a/app/Models/Paddock.php b/app/Models/Paddock.php new file mode 100644 index 0000000..d24d43c --- /dev/null +++ b/app/Models/Paddock.php @@ -0,0 +1,93 @@ + */ + use HasFactory; + + /** + * Animals whose current paddock is this one (any status). + * + * @return HasMany + */ + public function animals(): HasMany + { + return $this->hasMany(Animal::class, 'current_paddock_id'); + } + + /** + * Animals that currently occupy capacity in this paddock. + * + * @return HasMany + */ + public function activeAnimals(): HasMany + { + return $this->animals()->active(); + } + + /** + * Eager-load the occupancy count so list pages avoid N+1 queries. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function withOccupancy(Builder $query): Builder + { + return $query->withCount(['activeAnimals as occupancy']); + } + + /** + * Number of active animals in the paddock. Uses the eager-loaded + * count when present, otherwise falls back to a query. + */ + protected function occupancy(): Attribute + { + return Attribute::get( + fn (?int $value): int => $value ?? $this->activeAnimals()->count(), + ); + } + + public function occupancyRatio(): float + { + return $this->capacity > 0 ? $this->occupancy / $this->capacity : 1.0; + } + + public function occupancyState(): OccupancyState + { + return OccupancyState::fromRatio($this->occupancyRatio()); + } + + public function hasRoom(): bool + { + return $this->occupancy < $this->capacity; + } + + public function remainingCapacity(): int + { + return max(0, $this->capacity - $this->occupancy); + } +} diff --git a/config/farm.php b/config/farm.php new file mode 100644 index 0000000..5e0ef0e --- /dev/null +++ b/config/farm.php @@ -0,0 +1,17 @@ + (float) env('FARM_CAPACITY_WARNING_THRESHOLD', 0.8), + +]; diff --git a/database/factories/AnimalFactory.php b/database/factories/AnimalFactory.php new file mode 100644 index 0000000..909b6cd --- /dev/null +++ b/database/factories/AnimalFactory.php @@ -0,0 +1,81 @@ + + */ +class AnimalFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'tag_number' => fake()->unique()->bothify('??-####'), + 'name' => fake()->optional(0.6)->firstName(), + 'species' => fake()->randomElement(Species::cases()), + 'sex' => fake()->randomElement(Sex::cases()), + 'date_of_birth' => fake()->dateTimeBetween('-8 years', '-3 months'), + 'breed' => fake()->optional(0.7)->word(), + 'status' => AnimalStatus::Active, + 'notes' => null, + 'current_paddock_id' => null, + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Active, + ]); + } + + /** + * Sold animals never occupy a paddock. + */ + public function sold(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Sold, + 'current_paddock_id' => null, + ]); + } + + /** + * Deceased animals never occupy a paddock. + */ + public function deceased(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => AnimalStatus::Deceased, + 'current_paddock_id' => null, + ]); + } + + /** + * Place the animal directly in a paddock, bypassing movement history. + * Use MoveAnimal in application code; this is for test setup only. + */ + public function inPaddock(Paddock $paddock): static + { + return $this->state(fn (array $attributes): array => [ + 'current_paddock_id' => $paddock->id, + ]); + } + + public function species(Species $species): static + { + return $this->state(fn (array $attributes): array => [ + 'species' => $species, + ]); + } +} diff --git a/database/factories/AnimalMovementFactory.php b/database/factories/AnimalMovementFactory.php new file mode 100644 index 0000000..6cf299a --- /dev/null +++ b/database/factories/AnimalMovementFactory.php @@ -0,0 +1,36 @@ + + */ +class AnimalMovementFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'animal_id' => Animal::factory(), + 'from_paddock_id' => null, + 'to_paddock_id' => Paddock::factory(), + 'moved_at' => fake()->dateTimeBetween('-1 year', 'now'), + 'notes' => fake()->optional(0.3)->sentence(), + ]; + } + + public function between(?Paddock $from, ?Paddock $to): static + { + return $this->state(fn (array $attributes): array => [ + 'from_paddock_id' => $from?->id, + 'to_paddock_id' => $to?->id, + ]); + } +} diff --git a/database/factories/HealthRecordFactory.php b/database/factories/HealthRecordFactory.php new file mode 100644 index 0000000..f025fcf --- /dev/null +++ b/database/factories/HealthRecordFactory.php @@ -0,0 +1,36 @@ + + */ +class HealthRecordFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $type = fake()->randomElement(HealthRecordType::cases()); + + return [ + 'animal_id' => Animal::factory(), + 'recorded_on' => fake()->dateTimeBetween('-1 year', 'now'), + 'type' => $type, + 'description' => match ($type) { + HealthRecordType::Vaccination => fake()->randomElement(['5-in-1 vaccine', 'Clostridial booster', 'Leptospirosis vaccine']), + HealthRecordType::Treatment => fake()->randomElement(['Drenched for worms', 'Antibiotic course', 'Foot bath']), + HealthRecordType::Injury => fake()->randomElement(['Cut on hind leg', 'Lame front left', 'Horn injury']), + HealthRecordType::Checkup => fake()->randomElement(['Routine condition score', 'Pregnancy test', 'Weight check']), + HealthRecordType::Other => fake()->sentence(3), + }, + 'notes' => fake()->optional(0.4)->sentence(), + ]; + } +} diff --git a/database/factories/PaddockFactory.php b/database/factories/PaddockFactory.php new file mode 100644 index 0000000..4f682e4 --- /dev/null +++ b/database/factories/PaddockFactory.php @@ -0,0 +1,35 @@ + + */ +class PaddockFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->randomElement([ + 'North Paddock', 'South Paddock', 'East Paddock', 'West Paddock', + 'River Flat', 'Top Hill', 'Home Paddock', 'Back Block', + 'Creek Paddock', 'Long Paddock', 'Orchard Paddock', 'Ridge Paddock', + ]), + 'capacity' => fake()->numberBetween(5, 40), + 'notes' => null, + ]; + } + + public function withCapacity(int $capacity): static + { + return $this->state(fn (array $attributes): array => [ + 'capacity' => $capacity, + ]); + } +} diff --git a/database/migrations/2026_09_12_031624_create_paddocks_table.php b/database/migrations/2026_09_12_031624_create_paddocks_table.php new file mode 100644 index 0000000..7169f37 --- /dev/null +++ b/database/migrations/2026_09_12_031624_create_paddocks_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name')->unique(); + $table->unsignedInteger('capacity'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('paddocks'); + } +}; diff --git a/database/migrations/2026_09_12_031625_create_animals_table.php b/database/migrations/2026_09_12_031625_create_animals_table.php new file mode 100644 index 0000000..36408c3 --- /dev/null +++ b/database/migrations/2026_09_12_031625_create_animals_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('tag_number')->unique(); + $table->string('name')->nullable(); + $table->string('species'); + $table->string('sex'); + $table->date('date_of_birth'); + $table->string('breed')->nullable(); + $table->string('status')->default(AnimalStatus::Active->value); + $table->text('notes')->nullable(); + $table->foreignId('current_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->timestamps(); + + $table->index(['status', 'current_paddock_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('animals'); + } +}; diff --git a/database/migrations/2026_09_12_031626_create_animal_movements_table.php b/database/migrations/2026_09_12_031626_create_animal_movements_table.php new file mode 100644 index 0000000..de928df --- /dev/null +++ b/database/migrations/2026_09_12_031626_create_animal_movements_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('animal_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->foreignId('to_paddock_id') + ->nullable() + ->constrained('paddocks') + ->nullOnDelete(); + $table->dateTime('moved_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['animal_id', 'moved_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('animal_movements'); + } +}; diff --git a/database/migrations/2026_09_12_031627_create_health_records_table.php b/database/migrations/2026_09_12_031627_create_health_records_table.php new file mode 100644 index 0000000..1241395 --- /dev/null +++ b/database/migrations/2026_09_12_031627_create_health_records_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('animal_id')->constrained()->cascadeOnDelete(); + $table->date('recorded_on'); + $table->string('type'); + $table->string('description'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['animal_id', 'recorded_on']); + }); + } + + public function down(): void + { + Schema::dropIfExists('health_records'); + } +}; diff --git a/resources/js/app.ts b/resources/js/app.ts index 5872d1e..ed52afd 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -11,8 +11,6 @@ void createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { switch (true) { - case name === 'Welcome': - return null; case name.startsWith('auth/'): return AuthLayout; case name.startsWith('settings/'): diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index d58773b..2a7154c 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -238,7 +238,7 @@ const rightNavItems: NavItem[] = [ - + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a65f9d0..fb79b55 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ - - diff --git a/resources/js/pages/settings/Profile.vue b/resources/js/pages/settings/Profile.vue index b4498e8..60bd5fa 100644 --- a/resources/js/pages/settings/Profile.vue +++ b/resources/js/pages/settings/Profile.vue @@ -24,7 +24,8 @@ defineOptions({ }); const page = usePage(); -const user = computed(() => page.props.auth.user); +// This page sits behind the auth middleware, so a user is always present. +const user = computed(() => page.props.auth.user!);