From 0c89516ec16e8453cf1c49b11e610d3d9aea6e8c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 29 Jun 2026 05:34:01 +0000 Subject: [PATCH] MICRA 1.0 --- frontend/src/components/AsideMenuLayer.tsx | 3 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/farm-ops.tsx | 538 +++++++++++++++++++++ frontend/src/pages/index.tsx | 276 +++++------ frontend/src/pages/search.tsx | 4 +- 7 files changed, 672 insertions(+), 161 deletions(-) create mode 100644 frontend/src/pages/farm-ops.tsx diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 55c8400..9d8b612 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index f5f6f5b..f24d1d4 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/farm-ops', + icon: 'mdiFishbowlOutline' in icon ? icon['mdiFishbowlOutline' as keyof typeof icon] : icon.mdiFish, + label: 'Farm Ops', + permissions: 'READ_FEEDING_LOGS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/farm-ops.tsx b/frontend/src/pages/farm-ops.tsx new file mode 100644 index 0000000..6a5b1ab --- /dev/null +++ b/frontend/src/pages/farm-ops.tsx @@ -0,0 +1,538 @@ +import { + mdiCalendarCheck, + mdiChartTimelineVariant, + mdiClipboardTextClock, + mdiFish, + mdiFoodDrumstick, + mdiStorefront, + mdiWaterPercent, +} from '@mdi/js' +import Head from 'next/head' +import React, { ReactElement, useEffect, useMemo, useState } from 'react' +import axios from 'axios' +import BaseButton from '../components/BaseButton' +import CardBox from '../components/CardBox' +import LayoutAuthenticated from '../layouts/Authenticated' +import SectionMain from '../components/SectionMain' +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import BaseIcon from '../components/BaseIcon' +import { getPageTitle } from '../config' +import { hasPermission } from '../helpers/userPermissions' +import { useAppSelector } from '../stores/hooks' + +type Option = { + id: string + label: string +} + +type FeedLog = { + id: string + fed_at?: string + quantity_kg?: string | number + feeding_method?: string + appetite?: string + notes?: string + batch?: { id?: string; batch_code?: string } + feed_product?: { id?: string; product_name?: string } + recorded_by?: { firstName?: string; lastName?: string; email?: string } + createdAt?: string +} + +type OpsForm = { + batch: string + feed_product: string + fed_at: string + quantity_kg: string + feeding_method: string + appetite: string + notes: string +} + +const initialForm: OpsForm = { + batch: '', + feed_product: '', + fed_at: '', + quantity_kg: '', + feeding_method: 'manual', + appetite: 'normal', + notes: '', +} + +const nowForDateTimeLocal = () => { + const date = new Date() + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()) + return date.toISOString().slice(0, 16) +} + +const formatDateTime = (value?: string) => { + if (!value) return 'Not scheduled' + + return new Intl.DateTimeFormat('en', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)) +} + +const formatQuantity = (value?: string | number) => { + if (value === undefined || value === null || value === '') return '0 kg' + + return `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })} kg` +} + +const getRecorder = (log?: FeedLog) => { + const firstName = log?.recorded_by?.firstName || '' + const lastName = log?.recorded_by?.lastName || '' + const fullName = `${firstName} ${lastName}`.trim() + + return fullName || log?.recorded_by?.email || 'Team member' +} + +const statusStyles = { + low: 'bg-amber-100 text-amber-800 ring-amber-200', + normal: 'bg-emerald-100 text-emerald-800 ring-emerald-200', + high: 'bg-sky-100 text-sky-800 ring-sky-200', +} + +const FarmOpsPage = () => { + const { currentUser } = useAppSelector((state) => state.auth) + const [logs, setLogs] = useState([]) + const [batchOptions, setBatchOptions] = useState([]) + const [feedOptions, setFeedOptions] = useState([]) + const [selectedLog, setSelectedLog] = useState(null) + const [form, setForm] = useState({ ...initialForm, fed_at: nowForDateTimeLocal() }) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + const canCreateFeedLog = currentUser && hasPermission(currentUser, 'CREATE_FEEDING_LOGS') + + const totalFedToday = useMemo(() => { + const today = new Date().toDateString() + + return logs + .filter((log) => log.fed_at && new Date(log.fed_at).toDateString() === today) + .reduce((sum, log) => sum + Number(log.quantity_kg || 0), 0) + }, [logs]) + + const activeBatchCount = useMemo(() => { + const ids = new Set(logs.map((log) => log.batch?.id).filter(Boolean)) + + return ids.size + }, [logs]) + + const latestAppetite = logs[0]?.appetite || 'normal' + + const fetchWorkspace = async () => { + setLoading(true) + setError('') + + try { + const [feedLogResponse, batchResponse, feedProductResponse] = await Promise.all([ + axios.get('/feeding_logs?limit=12&field=fed_at&sort=DESC'), + axios.get('/batches/autocomplete?limit=100'), + axios.get('/feed_products/autocomplete?limit=100'), + ]) + + const latestLogs = Array.isArray(feedLogResponse.data?.rows) ? feedLogResponse.data.rows : [] + const batches = Array.isArray(batchResponse.data) ? batchResponse.data : [] + const feeds = Array.isArray(feedProductResponse.data) ? feedProductResponse.data : [] + + setLogs(latestLogs) + setBatchOptions(batches) + setFeedOptions(feeds) + setSelectedLog((current) => current || latestLogs[0] || null) + setForm((current) => ({ + ...current, + batch: current.batch || batches[0]?.id || '', + feed_product: current.feed_product || feeds[0]?.id || '', + })) + } catch (fetchError) { + console.error('Farm ops workspace failed to load', fetchError) + setError('Could not load the farm operations workspace. Please refresh or check your permissions.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchWorkspace() + }, []) + + const handleChange = (field: keyof OpsForm, value: string) => { + setForm((current) => ({ ...current, [field]: value })) + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError('') + setMessage('') + + if (!form.batch) { + setError('Choose a batch before recording feed.') + return + } + + if (!form.fed_at) { + setError('Add the feeding time.') + return + } + + if (!form.quantity_kg || Number(form.quantity_kg) <= 0) { + setError('Feed quantity must be greater than 0 kg.') + return + } + + setSaving(true) + + try { + await axios.post('/feeding_logs', { + data: { + batch: form.batch, + feed_product: form.feed_product || null, + fed_at: new Date(form.fed_at).toISOString(), + quantity_kg: form.quantity_kg, + feeding_method: form.feeding_method, + appetite: form.appetite, + notes: form.notes, + recorded_by: currentUser?.id || null, + }, + }) + + setMessage('Feeding record saved. The latest activity list has been refreshed.') + setForm({ ...initialForm, fed_at: nowForDateTimeLocal(), batch: form.batch, feed_product: form.feed_product }) + await fetchWorkspace() + } catch (saveError) { + console.error('Farm ops feeding log save failed', saveError) + setError('Could not save the feeding record. Please check the values and try again.') + } finally { + setSaving(false) + } + } + + return ( + <> + + {getPageTitle('Farm Ops Command Center')} + + + + + + +
+
+
+
+

+ Multi-tenant aquaculture workflow +

+

+ Record feed, monitor appetite, and keep the farm team aligned. +

+

+ A focused daily operations slice built on top of your generated CRUD entities: batches, feed products, and feeding logs. +

+
+
+
+
{formatQuantity(totalFedToday)}
+
Fed today
+
+
+
{activeBatchCount}
+
Active batches
+
+
+
{latestAppetite}
+
Latest appetite
+
+
+
+
+
+ + {(message || error) && ( +
+ {error || message} +
+ )} + +
+ +
+
+

Quick input

+

Log a feeding

+

Capture the minimum operational data and confirm it immediately.

+
+ + + +
+ + {loading ? ( +
Loading batches and feed products…
+ ) : batchOptions.length === 0 ? ( +
+

No batches available yet.

+

Create a batch first, then return here to record daily feeding activity.

+ +
+ ) : ( +
+ + +
+ + +
+ +
+ + + +
+ +