diff --git a/messages/fa.json b/messages/fa.json index e958371..3ae684b 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -45,6 +45,7 @@ "irrigationRecommendation": "توصیه آبیاری", "fertilizationRecommendation": "توصیه کوددهی", "fertilizationPlanParser": "برنامه آبیاری و کودهی", + "irrigationPlanParser": "برنامه آبیاری", "aiAssistant": "دستیار هوشمند", "farmAiAssistant": "دستیار هوشمند مزرعه", "pestDetection": "تشخیص آفات گیاهی", diff --git a/src/app/(dashboard)/(private)/fertilization-plan/page.tsx b/src/app/(dashboard)/(private)/fertilization-plan/page.tsx index 7918141..cbf2374 100644 --- a/src/app/(dashboard)/(private)/fertilization-plan/page.tsx +++ b/src/app/(dashboard)/(private)/fertilization-plan/page.tsx @@ -1,7 +1,361 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + import FertilizationPlanParserPage from "@views/dashboards/farm/fertilizationPlanParser/FertilizationPlanParserPage"; +import RelatedPlanSelector, { + type RelatedPlanItem, +} from "@views/dashboards/farm/planSelector/RelatedPlanSelector"; + +import CustomTextField from "@core/components/mui/TextField"; + +type FertilizationPlanRow = { + id: number; + planName: string; + finalProduct: string; + harvestTime: string; + outputTon: number; + fertilizerType: string; + status: "active" | "draft"; +}; + +const mockFertilizationPlans: FertilizationPlanRow[] = [ + { + id: 1, + planName: "برنامه کوددهی گوجه فرنگی گلخانه‌ای", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۱۲", + outputTon: 52, + fertilizerType: "NPK 20-20-20", + status: "active", + }, + { + id: 2, + planName: "برنامه کوددهی فلفل دلمه‌ای", + finalProduct: "فلفل دلمه‌ای رنگی", + harvestTime: "۱۴۰۴/۰۵/۲۰", + outputTon: 34, + fertilizerType: "نیترات کلسیم", + status: "draft", + }, + { + id: 3, + planName: "برنامه کوددهی خیار گلخانه‌ای", + finalProduct: "خیار ممتاز صادراتی", + harvestTime: "۱۴۰۴/۰۴/۲۸", + outputTon: 41, + fertilizerType: "سولفات پتاسیم", + status: "draft", + }, + { + id: 4, + planName: "برنامه کوددهی هندوانه", + finalProduct: "هندوانه شیرین بازارپسند", + harvestTime: "۱۴۰۴/۰۵/۲۹", + outputTon: 57, + fertilizerType: "اوره + هیومیک اسید", + status: "draft", + }, +]; + +const relatedIrrigationPlans: RelatedPlanItem[] = [ + { + id: 301, + title: "آبیاری قطره‌ای صبحگاهی", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۰۸", + outputTon: 45, + methodLabel: "روش آبیاری: قطره‌ای", + status: "active", + }, + { + id: 302, + title: "آبیاری تنظیم‌شده مرحله گلدهی", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۱۰", + outputTon: 49, + methodLabel: "روش آبیاری: تیپ", + status: "draft", + }, + { + id: 303, + title: "آبیاری تقویتی پیش از برداشت", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۱۴", + outputTon: 53, + methodLabel: "روش آبیاری: بارانی سبک", + status: "draft", + }, +]; const FertilizationPlanPage = () => { - return ; + const router = useRouter(); + const [plans, setPlans] = useState(mockFertilizationPlans); + const [minOutputTon, setMinOutputTon] = useState("0"); + const [selectedPlan, setSelectedPlan] = useState( + mockFertilizationPlans[0], + ); + const [detailsOpen, setDetailsOpen] = useState(false); + const [selectedRelatedPlanId, setSelectedRelatedPlanId] = useState( + relatedIrrigationPlans[0]?.id ?? null, + ); + + const filteredPlans = useMemo(() => { + const minTon = Number(minOutputTon); + + return plans + .filter((plan) => plan.outputTon >= minTon) + .sort((firstPlan, secondPlan) => secondPlan.outputTon - firstPlan.outputTon); + }, [minOutputTon, plans]); + + const handleActivate = (planId: number) => { + setPlans((currentPlans) => + currentPlans.map((plan) => ({ + ...plan, + status: plan.id === planId ? "active" : "draft", + })), + ); + + const activePlan = plans.find((plan) => plan.id === planId); + + if (activePlan) { + setSelectedPlan({ ...activePlan, status: "active" }); + } + }; + + const handleDelete = (planId: number) => { + setPlans((currentPlans) => currentPlans.filter((plan) => plan.id !== planId)); + + if (selectedPlan?.id === planId) { + setSelectedPlan(null); + setDetailsOpen(false); + } + }; + + const handleShowDetails = (plan: FertilizationPlanRow) => { + setSelectedPlan(plan); + setSelectedRelatedPlanId(relatedIrrigationPlans[0]?.id ?? null); + setDetailsOpen(true); + }; + + const handleConfirmRelatedPlan = (relatedPlan: RelatedPlanItem) => { + setDetailsOpen(false); + if (!selectedPlan) return; + + const query = new URLSearchParams({ + from: "fertilization-plan", + planType: "fertilization", + planId: String(selectedPlan.id), + planName: selectedPlan.planName, + relatedType: "irrigation", + relatedPlanId: String(relatedPlan.id), + relatedPlanName: relatedPlan.title, + }); + + router.push(`/yield-harvest?${query.toString()}`); + }; + + return ( + <> + + + + + + + + + لیست برنامه‌های کوددهی + + نمایش همه برنامه‌ها با داده ماک و امکان فیلتر بر اساس بیشترین تن خروجی محصول + + + + setMinOutputTon(event.target.value)} + label="فیلتر تن خروجی محصول" + sx={{ minWidth: { xs: "100%", md: 260 } }} + SelectProps={{ native: true }} + > + + + + + + + + + + + + نام برنامه + محصول نهایی + زمان برداشت + تن خروجی + نوع کود + وضعیت + عملیات + + + + {filteredPlans.map((plan) => ( + + {plan.planName} + {plan.finalProduct} + {plan.harvestTime} + {plan.outputTon} تن + {plan.fertilizerType} + + + + + + + + handleActivate(plan.id)} + disabled={plan.status === "active"} + > + + + + + + + handleShowDetails(plan)} + > + + + + + + handleDelete(plan.id)} + > + + + + + + + ))} + + {filteredPlans.length === 0 ? ( + + + + برنامه‌ای با این فیلتر پیدا نشد. + + + + ) : null} + +
+
+ + + + + جزئیات برنامه انتخاب‌شده + + {selectedPlan ? ( + + + + + + + + + ) : ( + + برای دیدن جزئیات، یکی از برنامه‌های کوددهی را انتخاب کنید. + + )} + +
+
+
+
+ + {selectedPlan ? ( + setDetailsOpen(false)} + title="جزییات برنامه کوددهی" + description="برای تکمیل اجرای این برنامه کوددهی، یکی از برنامه‌های آبیاری مرتبط را انتخاب کنید." + currentPlanName={selectedPlan.planName} + currentPlanStatus={selectedPlan.status === "active" ? "برنامه فعال" : "آماده برای فعال‌سازی"} + currentPlanOutput={`خروجی هدف: ${selectedPlan.outputTon} تن محصول نهایی`} + summaryItems={[ + { label: "محصول نهایی", value: selectedPlan.finalProduct }, + { label: "زمان برداشت", value: selectedPlan.harvestTime }, + { label: "نوع کود", value: selectedPlan.fertilizerType }, + ]} + relatedTitle="برنامه‌های آبیاری پیشنهادی" + relatedDescription="از بین برنامه‌های زیر، مناسب‌ترین برنامه آبیاری را برای این برنامه کوددهی انتخاب کنید." + relatedPlans={relatedIrrigationPlans} + selectedRelatedPlanId={selectedRelatedPlanId} + onSelectRelatedPlan={setSelectedRelatedPlanId} + onConfirm={handleConfirmRelatedPlan} + /> + ) : null} + + ); }; export default FertilizationPlanPage; diff --git a/src/app/(dashboard)/(private)/irrigation-plan/page.tsx b/src/app/(dashboard)/(private)/irrigation-plan/page.tsx new file mode 100644 index 0000000..904ad16 --- /dev/null +++ b/src/app/(dashboard)/(private)/irrigation-plan/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +import FertilizationPlanParserPage from "@views/dashboards/farm/fertilizationPlanParser/FertilizationPlanParserPage"; +import RelatedPlanSelector, { + type RelatedPlanItem, +} from "@views/dashboards/farm/planSelector/RelatedPlanSelector"; + +import CustomTextField from "@core/components/mui/TextField"; + +type IrrigationPlanRow = { + id: number; + planName: string; + finalProduct: string; + harvestTime: string; + outputTon: number; + irrigationMethod: string; + status: "active" | "draft"; +}; + +const mockIrrigationPlans: IrrigationPlanRow[] = [ + { + id: 1, + planName: "برنامه آبیاری گوجه فرنگی گلخانه‌ای", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۱۰", + outputTon: 48, + irrigationMethod: "قطره‌ای", + status: "active", + }, + { + id: 2, + planName: "برنامه آبیاری ذرت علوفه‌ای", + finalProduct: "ذرت علوفه‌ای درجه یک", + harvestTime: "۱۴۰۴/۰۵/۲۲", + outputTon: 36, + irrigationMethod: "بارانی", + status: "draft", + }, + { + id: 3, + planName: "برنامه آبیاری خیار فضای باز", + finalProduct: "خیار صادراتی", + harvestTime: "۱۴۰۴/۰۴/۱۸", + outputTon: 28, + irrigationMethod: "تیپ", + status: "draft", + }, + { + id: 4, + planName: "برنامه آبیاری هندوانه", + finalProduct: "هندوانه شیرین بازارپسند", + harvestTime: "۱۴۰۴/۰۵/۳۰", + outputTon: 55, + irrigationMethod: "قطره‌ای", + status: "draft", + }, +]; + +const relatedFertilizationPlans: RelatedPlanItem[] = [ + { + id: 201, + title: "کوددهی آغاز رشد رویشی", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۵/۲۰", + outputTon: 42, + methodLabel: "نوع کود: NPK 20-20-20", + status: "active", + }, + { + id: 202, + title: "کوددهی تقویت گلدهی", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۰۵", + outputTon: 47, + methodLabel: "نوع کود: کلسیم بور", + status: "draft", + }, + { + id: 203, + title: "کوددهی پتاس بالا برای رنگ‌گیری", + finalProduct: "گوجه فرنگی ممتاز", + harvestTime: "۱۴۰۴/۰۶/۱۲", + outputTon: 51, + methodLabel: "نوع کود: سولفات پتاسیم", + status: "draft", + }, +]; + +const IrrigationPlanPage = () => { + const router = useRouter(); + const [plans, setPlans] = useState(mockIrrigationPlans); + const [minOutputTon, setMinOutputTon] = useState("0"); + const [selectedPlan, setSelectedPlan] = useState( + mockIrrigationPlans[0], + ); + const [detailsOpen, setDetailsOpen] = useState(false); + const [selectedRelatedPlanId, setSelectedRelatedPlanId] = useState( + relatedFertilizationPlans[0]?.id ?? null, + ); + + const filteredPlans = useMemo(() => { + const minTon = Number(minOutputTon); + + return plans + .filter((plan) => plan.outputTon >= minTon) + .sort((firstPlan, secondPlan) => secondPlan.outputTon - firstPlan.outputTon); + }, [minOutputTon, plans]); + + const handleActivate = (planId: number) => { + setPlans((currentPlans) => + currentPlans.map((plan) => ({ + ...plan, + status: plan.id === planId ? "active" : "draft", + })), + ); + + const activePlan = plans.find((plan) => plan.id === planId); + + if (activePlan) { + setSelectedPlan({ ...activePlan, status: "active" }); + } + }; + + const handleDelete = (planId: number) => { + setPlans((currentPlans) => currentPlans.filter((plan) => plan.id !== planId)); + + if (selectedPlan?.id === planId) { + setSelectedPlan(null); + setDetailsOpen(false); + } + }; + + const handleShowDetails = (plan: IrrigationPlanRow) => { + setSelectedPlan(plan); + setSelectedRelatedPlanId(relatedFertilizationPlans[0]?.id ?? null); + setDetailsOpen(true); + }; + + const handleConfirmRelatedPlan = (relatedPlan: RelatedPlanItem) => { + setDetailsOpen(false); + if (!selectedPlan) return; + + const query = new URLSearchParams({ + from: "irrigation-plan", + planType: "irrigation", + planId: String(selectedPlan.id), + planName: selectedPlan.planName, + relatedType: "fertilization", + relatedPlanId: String(relatedPlan.id), + relatedPlanName: relatedPlan.title, + }); + + router.push(`/yield-harvest?${query.toString()}`); + }; + + return ( + <> + + + + + + + + + لیست برنامه‌های آبیاری + + نمایش همه برنامه‌ها با داده ماک و امکان فیلتر بر اساس بیشترین تن خروجی محصول + + + + setMinOutputTon(event.target.value)} + label="فیلتر تن خروجی محصول" + sx={{ minWidth: { xs: "100%", md: 260 } }} + SelectProps={{ native: true }} + > + + + + + + + + + + + + نام برنامه + محصول نهایی + زمان برداشت + تن خروجی + روش آبیاری + وضعیت + عملیات + + + + {filteredPlans.map((plan) => ( + + {plan.planName} + {plan.finalProduct} + {plan.harvestTime} + {plan.outputTon} تن + {plan.irrigationMethod} + + + + + + + + handleActivate(plan.id)} + disabled={plan.status === "active"} + > + + + + + + + handleShowDetails(plan)} + > + + + + + + handleDelete(plan.id)} + > + + + + + + + ))} + + {filteredPlans.length === 0 ? ( + + + + برنامه‌ای با این فیلتر پیدا نشد. + + + + ) : null} + +
+
+ + + + + جزئیات برنامه انتخاب‌شده + + {selectedPlan ? ( + + + + + + + + + ) : ( + + برای دیدن جزئیات، یکی از برنامه‌های آبیاری را انتخاب کنید. + + )} + +
+
+
+
+ + {selectedPlan ? ( + setDetailsOpen(false)} + title="جزییات برنامه آبیاری" + description="برای تکمیل اجرای این برنامه آبیاری، یکی از برنامه‌های کوددهی مرتبط را انتخاب کنید." + currentPlanName={selectedPlan.planName} + currentPlanStatus={selectedPlan.status === "active" ? "برنامه فعال" : "آماده برای فعال‌سازی"} + currentPlanOutput={`خروجی هدف: ${selectedPlan.outputTon} تن محصول نهایی`} + summaryItems={[ + { label: "محصول نهایی", value: selectedPlan.finalProduct }, + { label: "زمان برداشت", value: selectedPlan.harvestTime }, + { label: "روش آبیاری", value: selectedPlan.irrigationMethod }, + ]} + relatedTitle="برنامه‌های کوددهی پیشنهادی" + relatedDescription="از بین برنامه‌های زیر، مناسب‌ترین برنامه کوددهی را برای این برنامه آبیاری انتخاب کنید." + relatedPlans={relatedFertilizationPlans} + selectedRelatedPlanId={selectedRelatedPlanId} + onSelectRelatedPlan={setSelectedRelatedPlanId} + onConfirm={handleConfirmRelatedPlan} + /> + ) : null} + + ); +}; + +export default IrrigationPlanPage; diff --git a/src/app/(dashboard)/(private)/yield-harvest/page.tsx b/src/app/(dashboard)/(private)/yield-harvest/page.tsx index 1461ffe..8d5d7be 100644 --- a/src/app/(dashboard)/(private)/yield-harvest/page.tsx +++ b/src/app/(dashboard)/(private)/yield-harvest/page.tsx @@ -1,7 +1,105 @@ -import PlantProductionPage from '@views/dashboards/farm/PlantProductionPage' +"use client"; + +import { useMemo } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +import PlantProductionPage from "@views/dashboards/farm/PlantProductionPage"; const YieldHarvestPage = () => { - return -} + const router = useRouter(); + const searchParams = useSearchParams(); -export default YieldHarvestPage + const selectionSummary = useMemo(() => { + const from = searchParams.get("from"); + const planType = searchParams.get("planType"); + const planName = searchParams.get("planName"); + const relatedType = searchParams.get("relatedType"); + const relatedPlanName = searchParams.get("relatedPlanName"); + + if (!from || !planType || !planName || !relatedType || !relatedPlanName) { + return null; + } + + return { + from, + planType, + planName, + relatedType, + relatedPlanName, + }; + }, [searchParams]); + + const handleBack = () => { + const from = selectionSummary?.from; + + if (from === "irrigation-plan") { + router.push("/irrigation-plan"); + return; + } + + if (from === "fertilization-plan") { + router.push("/fertilization-plan"); + return; + } + + router.back(); + }; + + return ( + + {selectionSummary ? ( + + + + + تحلیل عملکرد و برداشت + + بر اساس انتخاب برنامه‌ها وارد این صفحه شده‌اید. + + + + + + + + + + + + + ) : null} + + + + ); +}; + +export default YieldHarvestPage; diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index d4157d3..4c70f86 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -157,6 +157,12 @@ const VerticalMenu = ({ scrollMenu }: Props) => { + } + > + {t("irrigationPlanParser")} + } diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts index 0871914..b18cb95 100644 --- a/src/constants/navigation.ts +++ b/src/constants/navigation.ts @@ -15,6 +15,7 @@ export const navigationLabels = { sensor7In1: 'سنسور خاک 7 در 1', dataSection: 'بخش داده‌ها', recommendation: 'توصیه‌ها', + irrigationPlanParser: 'برنامه آبیاری', irrigationRecommendation: 'توصیه آبیاری', fertilizationRecommendation: 'توصیه کوددهی', aiAssistant: 'دستیار هوشمند', diff --git a/src/data/dictionaries/fa.json b/src/data/dictionaries/fa.json index 044bc34..0019485 100644 --- a/src/data/dictionaries/fa.json +++ b/src/data/dictionaries/fa.json @@ -120,6 +120,7 @@ "sensorSection": "سنسورها", "sensor7In1": "سنسور خاک 7 در 1", "recommendation": "توصیه‌ها", + "irrigationPlanParser": "برنامه آبیاری", "irrigationRecommendation": "توصیه آبیاری", "fertilizationRecommendation": "توصیه کوددهی", "aiAssistant": "دستیار هوشمند", diff --git a/src/libs/api/services/eventService.ts b/src/libs/api/services/eventService.ts index 124474e..e86b060 100644 --- a/src/libs/api/services/eventService.ts +++ b/src/libs/api/services/eventService.ts @@ -10,20 +10,19 @@ export interface Event { id: string title: string description: string - deadline: number // Unix timestamp + deadline?: number // Unix timestamp tags: string[] - author: Author - calendar: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' + author?: Author start: string // ISO 8601 end: string // ISO 8601 - allDay: boolean + allDay?: boolean extendedProps?: Record } export interface ListEventsParams { start?: string // ISO 8601 end?: string // ISO 8601 - calendar?: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' + farm_uuid?: string } export interface CreateEventRequest { @@ -31,10 +30,9 @@ export interface CreateEventRequest { description?: string deadline?: number tags?: string[] - calendar: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' start: string // ISO 8601 end: string // ISO 8601 - allDay?: boolean + farm_uuid?: string extendedProps?: Record } @@ -52,14 +50,13 @@ export interface ListEventsResponse { export interface UpdateEventRequest { id: string - title?: string + title: string description?: string deadline?: number tags?: string[] - calendar?: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' - start?: string // ISO 8601 - end?: string // ISO 8601 - allDay?: boolean + start: string // ISO 8601 + end: string // ISO 8601 + farm_uuid?: string extendedProps?: Record } @@ -71,6 +68,16 @@ export interface DeleteEventResponse { success: boolean } +export interface EventTag { + id: string + label: string + value: string +} + +export interface ListEventTagsResponse { + tags: EventTag[] +} + export interface NonRoutineTask { id: string title: string @@ -93,7 +100,7 @@ export const eventService = { * Create a new event */ async createEvent(data: CreateEventRequest): Promise { - const response = await apiClient.post('/api/events', data) + const response = await apiClient.post('/api/events/', data) return response.event }, @@ -101,7 +108,7 @@ export const eventService = { * Get an event by ID */ async getEvent(id: string): Promise { - const response = await apiClient.get(`/api/events/${id}`) + const response = await apiClient.get(`/api/events/${id}/`) return response.event }, @@ -112,10 +119,10 @@ export const eventService = { const queryParams = new URLSearchParams() if (params?.start) queryParams.append('start', params.start) if (params?.end) queryParams.append('end', params.end) - if (params?.calendar) queryParams.append('calendar', params.calendar) + if (params?.farm_uuid) queryParams.append('farm_uuid', params.farm_uuid) const queryString = queryParams.toString() - const endpoint = `/api/events${queryString ? `?${queryString}` : ''}` + const endpoint = `/api/events/${queryString ? `?${queryString}` : ''}` const response = await apiClient.get(endpoint) return response.events || [] @@ -126,7 +133,7 @@ export const eventService = { */ async updateEvent(data: UpdateEventRequest): Promise { const { id, ...updateData } = data - const response = await apiClient.put(`/api/events/${id}`, updateData) + const response = await apiClient.put(`/api/events/${id}/`, updateData) return response.event }, @@ -134,10 +141,21 @@ export const eventService = { * Delete an event */ async deleteEvent(id: string): Promise { - const response = await apiClient.delete(`/api/events/${id}`) + const response = await apiClient.delete(`/api/events/${id}/`) return response.success }, + async listTags(farmUuid?: string): Promise { + const queryParams = new URLSearchParams() + if (farmUuid) queryParams.append('farm_uuid', farmUuid) + + const queryString = queryParams.toString() + const endpoint = `/api/events/tags/${queryString ? `?${queryString}` : ''}` + const response = await apiClient.get(endpoint) + + return response.tags || [] + }, + /** * دریافت تسک‌های غیرروتین (مشترک با Kanban و Todo) */ @@ -146,4 +164,3 @@ export const eventService = { return response.tasks || [] } } - diff --git a/src/libs/api/services/farmerTodoService.ts b/src/libs/api/services/farmerTodoService.ts new file mode 100644 index 0000000..019727b --- /dev/null +++ b/src/libs/api/services/farmerTodoService.ts @@ -0,0 +1,190 @@ +import { apiClient } from "../client"; + +export type FarmerTodoPriority = "زیاد" | "متوسط" | "کم"; +export type FarmerTodoPriorityInput = + | FarmerTodoPriority + | "high" + | "medium" + | "low"; +export type FarmerTodoStatus = "open" | "done"; + +export interface FarmerTodoTask { + id: string; + title: string; + zone: string; + scheduledDate: string; + time: string; + priority: FarmerTodoPriority; + note: string; + tags: string[]; + status: FarmerTodoStatus; +} + +const normalizePriority = ( + priority: string | null | undefined, +): FarmerTodoPriority => { + switch (priority) { + case "high": + case "زیاد": + return "زیاد"; + case "low": + case "کم": + return "کم"; + case "medium": + case "متوسط": + default: + return "متوسط"; + } +}; + +const normalizeStatus = ( + status: string | null | undefined, +): FarmerTodoStatus => { + return status === "done" ? "done" : "open"; +}; + +const normalizeTask = (task: FarmerTodoTask): FarmerTodoTask => ({ + ...task, + priority: normalizePriority(task.priority), + status: normalizeStatus(task.status), + note: task.note ?? "", + tags: Array.isArray(task.tags) ? task.tags : [], +}); + +export interface FarmerTodoSummary { + todayTasksCount: number; + completedCount: number; + urgentCount: number; + progressValue: number; + nextTask: FarmerTodoTask | null; +} + +export interface FarmerTodoZoneOption { + id: string; + label: string; + value: string; +} + +export interface ListFarmerTodosParams { + farm_uuid?: string; + status?: FarmerTodoStatus; + priority?: FarmerTodoPriorityInput; + date?: string; + from?: string; + to?: string; + zone?: string; + search?: string; +} + +export interface CreateFarmerTodoRequest { + farm_uuid?: string; + title: string; + zone: string; + scheduledDate: string; + time: string; + priority: FarmerTodoPriorityInput; + note?: string; + tags?: string[]; + status?: FarmerTodoStatus; +} + +export interface UpdateFarmerTodoRequest { + farm_uuid?: string; + title?: string; + zone?: string; + scheduledDate?: string; + time?: string; + priority?: FarmerTodoPriorityInput; + note?: string; + tags?: string[]; + status?: FarmerTodoStatus; +} + +interface ListFarmerTodosResponse { + tasks: FarmerTodoTask[]; + meta?: { total?: number }; +} + +interface FarmerTodoResponse { + task: FarmerTodoTask; +} + +interface ListZonesResponse { + zones: FarmerTodoZoneOption[]; +} + +const withQuery = ( + endpoint: string, + params?: Record, +): string => { + const query = new URLSearchParams(); + + Object.entries(params ?? {}).forEach(([key, value]) => { + if (value) { + query.append(key, value); + } + }); + + const queryString = query.toString(); + return `${endpoint}${queryString ? `?${queryString}` : ""}`; +}; + +export const farmerTodoService = { + async listTasks(params?: ListFarmerTodosParams): Promise { + const response = await apiClient.get( + withQuery("/api/farmer-todos/", { + farm_uuid: params?.farm_uuid, + status: params?.status, + priority: params?.priority, + date: params?.date, + from: params?.from, + to: params?.to, + zone: params?.zone, + search: params?.search, + }), + ); + + return (response.tasks ?? []).map(normalizeTask); + }, + + async createTask(data: CreateFarmerTodoRequest): Promise { + const response = await apiClient.post( + "/api/farmer-todos/", + data, + ); + return normalizeTask(response.task); + }, + + async updateTask( + taskId: string, + data: UpdateFarmerTodoRequest, + ): Promise { + const response = await apiClient.put( + `/api/farmer-todos/${taskId}/`, + data, + ); + return normalizeTask(response.task); + }, + + async deleteTask(taskId: string): Promise { + const response = await apiClient.delete<{ success: boolean }>( + `/api/farmer-todos/${taskId}/`, + ); + + return response.success; + }, + + async listZones(farmUuid?: string): Promise { + const response = await apiClient.get( + withQuery("/api/farmer-todos/zones/", { farm_uuid: farmUuid }), + ); + + return response.zones ?? []; + }, + + async getSummary(farmUuid?: string): Promise { + return apiClient.get( + withQuery("/api/farmer-todos/summary/", { farm_uuid: farmUuid }), + ); + }, +}; diff --git a/src/redux-store/slices/calendar.ts b/src/redux-store/slices/calendar.ts index a3f6177..cbacf50 100644 --- a/src/redux-store/slices/calendar.ts +++ b/src/redux-store/slices/calendar.ts @@ -8,11 +8,12 @@ import type { CalendarFiltersType, CalendarType } from '@/types/apps/calendarTyp // API Imports import { eventService } from '@/libs/api' +import type { Event, ListEventsParams } from '@/libs/api' // Async Thunks export const fetchEvents = createAsyncThunk( 'calendar/fetchEvents', - async (params?: { start?: string; end?: string; calendar?: CalendarFiltersType }) => { + async (params?: ListEventsParams) => { return await eventService.listEvents(params) } ) @@ -45,6 +46,22 @@ const initialState: CalendarType & { error: null } +const mapEventToCalendarInput = (event: Event): EventInput => ({ + id: event.id, + title: event.title, + start: event.start, + end: event.end, + allDay: event.allDay ?? false, + extendedProps: { + ...(event.extendedProps || {}), + calendar: event.extendedProps?.calendar || 'Business', + description: event.description, + deadline: event.deadline, + tags: event.tags || [], + author: event.author + } +}) + const filterEventsUsingCheckbox = (events: EventInput[], selectedCalendars: CalendarFiltersType[]) => { return events.filter(event => selectedCalendars.includes(event.extendedProps?.calendar as CalendarFiltersType)) } @@ -54,7 +71,7 @@ export const calendarSlice = createSlice({ initialState: initialState, reducers: { filterEvents: state => { - state.filteredEvents = state.events + state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars) }, addEvent: (state, action) => { @@ -117,24 +134,9 @@ export const calendarSlice = createSlice({ }) .addCase(fetchEvents.fulfilled, (state, action) => { state.loading = false - // Convert API events to FullCalendar EventInput format - const events: EventInput[] = action.payload.map(event => ({ - id: event.id, - title: event.title, - start: event.start, - end: event.end, - allDay: event.allDay, - extendedProps: { - ...event.extendedProps, - calendar: event.calendar, - description: event.description, - deadline: event.deadline, - tags: event.tags, - author: event.author - } - })) - state.events = events - state.filteredEvents = filterEventsUsingCheckbox(events, state.selectedCalendars) + const events: EventInput[] = action.payload.map(mapEventToCalendarInput) + state.filteredEvents = events + state.events = filterEventsUsingCheckbox(events, state.selectedCalendars) }) .addCase(fetchEvents.rejected, (state, action) => { state.loading = false @@ -142,51 +144,23 @@ export const calendarSlice = createSlice({ }) // Create Event .addCase(createEventAsync.fulfilled, (state, action) => { - const event: EventInput = { - id: action.payload.id, - title: action.payload.title, - start: action.payload.start, - end: action.payload.end, - allDay: action.payload.allDay, - extendedProps: { - ...action.payload.extendedProps, - calendar: action.payload.calendar, - description: action.payload.description, - deadline: action.payload.deadline, - tags: action.payload.tags, - author: action.payload.author - } - } - state.events.push(event) - state.filteredEvents = filterEventsUsingCheckbox(state.events, state.selectedCalendars) + const event = mapEventToCalendarInput(action.payload) + state.filteredEvents.push(event) + state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars) }) // Update Event .addCase(updateEventAsync.fulfilled, (state, action) => { const index = state.events.findIndex(event => event.id === action.payload.id) if (index !== -1) { - const event: EventInput = { - id: action.payload.id, - title: action.payload.title, - start: action.payload.start, - end: action.payload.end, - allDay: action.payload.allDay, - extendedProps: { - ...action.payload.extendedProps, - calendar: action.payload.calendar, - description: action.payload.description, - deadline: action.payload.deadline, - tags: action.payload.tags, - author: action.payload.author - } - } - state.events[index] = event - state.filteredEvents = filterEventsUsingCheckbox(state.events, state.selectedCalendars) + const event = mapEventToCalendarInput(action.payload) + state.filteredEvents[index] = event + state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars) } }) // Delete Event .addCase(deleteEventAsync.fulfilled, (state, action) => { - state.events = state.events.filter(event => event.id !== action.meta.arg) - state.filteredEvents = filterEventsUsingCheckbox(state.events, state.selectedCalendars) + state.filteredEvents = state.filteredEvents.filter(event => event.id !== action.meta.arg) + state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars) }) } }) diff --git a/src/views/apps/calendar/Calendar.tsx b/src/views/apps/calendar/Calendar.tsx index 6db1411..0c4aa4d 100644 --- a/src/views/apps/calendar/Calendar.tsx +++ b/src/views/apps/calendar/Calendar.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef } from "react"; import { useTheme } from "@mui/material/styles"; // Third-party imports -import type { Dispatch } from "@reduxjs/toolkit"; import "bootstrap-icons/font/bootstrap-icons.css"; import FullCalendar from "@fullcalendar/react"; @@ -20,14 +19,16 @@ import faLocale from "@fullcalendar/core/locales/fa"; import type { AddEventType, CalendarColors, + CalendarFiltersType, CalendarType, } from "@/types/apps/calendarTypes"; +import type { AppDispatch } from "@/redux-store"; // Slice Imports import { - filterEvents, selectedEvent, updateEvent, + updateEventAsync, } from "@/redux-store/slices/calendar"; type CalenderProps = { @@ -35,10 +36,11 @@ type CalenderProps = { calendarApi: any; setCalendarApi: (val: any) => void; calendarsColor: CalendarColors; - dispatch: Dispatch; + dispatch: AppDispatch | Dispatch; + dispatch: AppDispatch; handleLeftSidebarToggle: () => void; handleAddEventSidebarToggle: () => void; - handleEventDetailsOpen: () => void; + handleEventDetailsOpen?: () => void; }; const blankEvent: AddEventType = { @@ -153,7 +155,10 @@ const Calendar = (props: CalenderProps) => { eventClassNames({ event: calendarEvent }: any) { // @ts-ignore const colorName = - calendarsColor[calendarEvent._def.extendedProps.calendar]; + calendarsColor[ + (calendarEvent._def.extendedProps.calendar || + "ETC") as CalendarFiltersType + ]; return [ // Background Color @@ -175,7 +180,7 @@ const Calendar = (props: CalenderProps) => { extendedProps: clickedEvent.extendedProps, }), ); - handleEventDetailsOpen(); + handleEventDetailsOpen?.(); //* Only grab required field otherwise it goes in infinity loop //! Always grab all fields rendered by form (even if it get `undefined`) @@ -210,7 +215,30 @@ const Calendar = (props: CalenderProps) => { */ eventDrop({ event: droppedEvent }: any) { dispatch(updateEvent(droppedEvent)); - dispatch(filterEvents()); + + const currentEvent = calendarStore.events.find( + (calendarEvent) => calendarEvent.id === droppedEvent.id, + ); + + void dispatch( + updateEventAsync({ + id: droppedEvent.id, + data: { + title: droppedEvent.title, + description: currentEvent?.extendedProps?.description, + deadline: currentEvent?.extendedProps?.deadline, + tags: currentEvent?.extendedProps?.tags || [], + start: droppedEvent.start?.toISOString(), + end: + droppedEvent.end?.toISOString() || + droppedEvent.start?.toISOString(), + extendedProps: { + ...(currentEvent?.extendedProps || {}), + ...(droppedEvent.extendedProps || {}), + }, + }, + }), + ); }, /* @@ -219,7 +247,30 @@ const Calendar = (props: CalenderProps) => { */ eventResize({ event: resizedEvent }: any) { dispatch(updateEvent(resizedEvent)); - dispatch(filterEvents()); + + const currentEvent = calendarStore.events.find( + (calendarEvent) => calendarEvent.id === resizedEvent.id, + ); + + void dispatch( + updateEventAsync({ + id: resizedEvent.id, + data: { + title: resizedEvent.title, + description: currentEvent?.extendedProps?.description, + deadline: currentEvent?.extendedProps?.deadline, + tags: currentEvent?.extendedProps?.tags || [], + start: resizedEvent.start?.toISOString(), + end: + resizedEvent.end?.toISOString() || + resizedEvent.start?.toISOString(), + extendedProps: { + ...(currentEvent?.extendedProps || {}), + ...(resizedEvent.extendedProps || {}), + }, + }, + }), + ); }, // @ts-ignore diff --git a/src/views/dashboards/farm/FarmerCalendarEventModal.tsx b/src/views/dashboards/farm/FarmerCalendarEventModal.tsx index ce0b87d..2214401 100644 --- a/src/views/dashboards/farm/FarmerCalendarEventModal.tsx +++ b/src/views/dashboards/farm/FarmerCalendarEventModal.tsx @@ -23,15 +23,14 @@ import CustomTextField from "@core/components/mui/TextField"; import { eventService } from "@/libs/api"; import AppReactDatepicker from "@/libs/styles/AppReactDatepicker"; import { - addEvent, - deleteEvent, - filterEvents, + createEventAsync, + deleteEventAsync, + fetchEvents, selectedEvent, - updateEvent, + updateEventAsync, } from "@/redux-store/slices/calendar"; import type { AddEventSidebarType, - AddEventType, } from "@/types/apps/calendarTypes"; type FarmerCalendarEventModalProps = Omit< @@ -69,6 +68,16 @@ const defaultState: DefaultStateType = { startDate: new Date(), }; +const toDate = (value: unknown) => { + if (value instanceof Date) return value; + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date() : parsed; + } + + return new Date(); +}; + const modalTransitionDuration = { appear: 0, enter: 140, @@ -108,6 +117,8 @@ const FarmerCalendarEventModal = ({ const [values, setValues] = useState(defaultState); const [tagOptions, setTagOptions] = useState([]); const [tagLoading, setTagLoading] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); const { control, @@ -131,8 +142,8 @@ const FarmerCalendarEventModal = ({ : "", allDay: event.allDay ?? true, description: event.extendedProps?.description || "", - endDate: event.end !== null ? event.end : event.start, - startDate: event.start !== null ? event.start : new Date(), + endDate: event.end !== null ? toDate(event.end) : toDate(event.start), + startDate: event.start !== null ? toDate(event.start) : new Date(), }); } }, [calendarStore.selectedEvent, setValue]); @@ -149,43 +160,62 @@ const FarmerCalendarEventModal = ({ onClose(); }; - const onSubmit = (data: { title: string }) => { - const modifiedEvent: AddEventType = { - display: "block", + const onSubmit = async (data: { title: string }) => { + const payload = { title: data.title, - end: values.endDate, - allDay: values.allDay, - start: values.startDate, + description: values.description.length ? values.description : undefined, + tags: values.tag.trim().length ? [values.tag.trim()] : [], + start: values.startDate.toISOString(), + end: values.endDate.toISOString(), extendedProps: { - calendar: - calendarStore.selectedEvent?.extendedProps?.calendar || "Business", - description: values.description.length ? values.description : undefined, - tags: values.tag.trim().length ? [values.tag.trim()] : [], + ...(calendarStore.selectedEvent?.extendedProps || {}), }, }; - if ( - calendarStore.selectedEvent === null || - (calendarStore.selectedEvent !== null && - !calendarStore.selectedEvent.title.length) - ) { - dispatch(addEvent(modifiedEvent)); - } else { - dispatch( - updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }), - ); - } + setSubmitError(null); + setSubmitting(true); - dispatch(filterEvents()); - handleClose(); + try { + if ( + calendarStore.selectedEvent === null || + (calendarStore.selectedEvent !== null && + !calendarStore.selectedEvent.title.length) + ) { + await dispatch(createEventAsync(payload)).unwrap(); + } else { + await dispatch( + updateEventAsync({ + id: calendarStore.selectedEvent.id, + data: payload, + }), + ).unwrap(); + } + + await dispatch(fetchEvents()).unwrap(); + handleClose(); + } catch (error: any) { + setSubmitError(error?.message || t("validation.submitFailed")); + } finally { + setSubmitting(false); + } }; - const handleDeleteButtonClick = () => { + const handleDeleteButtonClick = async () => { if (calendarStore.selectedEvent?.id) { - dispatch(deleteEvent(calendarStore.selectedEvent.id)); - dispatch(filterEvents()); + setSubmitError(null); + setSubmitting(true); + + try { + await dispatch(deleteEventAsync(calendarStore.selectedEvent.id)).unwrap(); + await dispatch(fetchEvents()).unwrap(); + } catch (error: any) { + setSubmitError(error?.message || t("validation.submitFailed")); + setSubmitting(false); + return; + } } + setSubmitting(false); handleClose(); }; @@ -228,19 +258,10 @@ const FarmerCalendarEventModal = ({ setTagLoading(true); try { - const events = await eventService.listEvents(); - const options = Array.from( - new Set( - events.flatMap((event) => - Array.isArray(event.tags) - ? event.tags - .filter((tag): tag is string => typeof tag === "string") - .map((tag) => tag.trim()) - .filter(Boolean) - : [], - ), - ), - ); + const tags = await eventService.listTags(); + const options = tags + .map((tag) => tag.value?.trim()) + .filter((tag): tag is string => Boolean(tag)); if (active) { setTagOptions(options); @@ -265,6 +286,11 @@ const FarmerCalendarEventModal = ({ autoComplete="off" className="flex flex-col gap-6" > + {submitError ? ( + + {submitError} + + ) : null} {t("actions.delete")} @@ -392,7 +419,7 @@ const FarmerCalendarEventModal = ({ - diff --git a/src/views/dashboards/farm/FarmerCalendarPage.tsx b/src/views/dashboards/farm/FarmerCalendarPage.tsx index 0ba4816..5fd1e7a 100644 --- a/src/views/dashboards/farm/FarmerCalendarPage.tsx +++ b/src/views/dashboards/farm/FarmerCalendarPage.tsx @@ -23,15 +23,12 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { EventInput } from "@fullcalendar/core"; import { format, isThisWeek, isToday, parseISO } from "date-fns"; +import { eventService } from "@/libs/api"; +import { useFarmHub } from "@/hooks/useFarmHub"; import AppReactDatepicker from "@/libs/styles/AppReactDatepicker"; import AppFullCalendar from "@/libs/styles/AppFullCalendar"; import { useAppDispatch, useAppSelector } from "@/redux-store"; -import { - fetchEvents, - filterAllCalendarLabels, - filterCalendarLabel, - selectedEvent, -} from "@/redux-store/slices/calendar"; +import { fetchEvents, selectedEvent } from "@/redux-store/slices/calendar"; import type { CalendarColors, CalendarFiltersType, @@ -51,6 +48,12 @@ type EventCardData = { description?: string; }; +type CalendarTagChip = { + id: string; + label: string; + value: string; +}; + const calendarsColor: CalendarColors = { Personal: "error", Business: "primary", @@ -343,29 +346,78 @@ const FarmerCalendarPage = () => { const t = useTranslations("farmerCalendar"); const theme = useTheme(); const dispatch = useAppDispatch(); + const { farmHub } = useFarmHub(); + const farmUuid = farmHub?.farm_uuid; const mdUp = useMediaQuery(theme.breakpoints.up("lg")); const [calendarApi, setCalendarApi] = useState(null); const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); const [eventModalOpen, setEventModalOpen] = useState(false); const [eventDetailsOpen, setEventDetailsOpen] = useState(false); + const [tagOptions, setTagOptions] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); const calendarStore = useAppSelector((state) => state.calendarReducer); - const { events, selectedCalendars, loading, error } = calendarStore; + const { events, loading, error } = calendarStore; useEffect(() => { - dispatch(fetchEvents()); - }, [dispatch]); + dispatch(fetchEvents(farmUuid ? { farm_uuid: farmUuid } : undefined)); + }, [dispatch, farmUuid]); + + useEffect(() => { + let active = true; + + const fetchTags = async () => { + try { + const tags = await eventService.listTags(farmUuid); + + if (!active) return; + + setTagOptions( + tags + .map((tag) => ({ + id: tag.id, + label: tag.label?.trim() || tag.value?.trim() || "", + value: tag.value?.trim() || tag.label?.trim() || "", + })) + .filter((tag) => tag.value.length > 0), + ); + } catch { + if (active) { + setTagOptions([]); + } + } + }; + + void fetchTags(); + + return () => { + active = false; + }; + }, [farmUuid]); const eventCards = useMemo(() => createEventCards(events), [events]); - const allEventCards = useMemo( + const baseEvents = useMemo( () => - createEventCards( - calendarStore.filteredEvents.length - ? calendarStore.filteredEvents - : events, - ), + calendarStore.filteredEvents.length ? calendarStore.filteredEvents : events, [calendarStore.filteredEvents, events], ); + const tagFilteredEvents = useMemo(() => { + if (selectedTags.length === 0) return baseEvents; + + return baseEvents.filter((event) => { + const tags = Array.isArray(event.extendedProps?.tags) + ? event.extendedProps.tags + : []; + + return tags.some( + (tag) => typeof tag === "string" && selectedTags.includes(tag.trim()), + ); + }); + }, [baseEvents, selectedTags]); + const allEventCards = useMemo( + () => createEventCards(tagFilteredEvents), + [tagFilteredEvents], + ); const todayCount = useMemo( () => eventCards.filter((event) => isToday(event.start)).length, @@ -393,27 +445,25 @@ const FarmerCalendarPage = () => { .slice(0, 5), [eventCards], ); - const categoryCounts = useMemo( + const tagCounts = useMemo( () => - allEventCards.reduce( - (accumulator, event) => { - accumulator[event.category] += 1; + tagOptions.reduce>((accumulator, tag) => { + accumulator[tag.value] = baseEvents.filter((event) => { + const eventTags = Array.isArray(event.extendedProps?.tags) + ? event.extendedProps.tags + : []; - return accumulator; - }, - { - Personal: 0, - Business: 0, - Family: 0, - Holiday: 0, - ETC: 0, - } as Record, - ), - [allEventCards], + return eventTags.some( + (item) => typeof item === "string" && item.trim() === tag.value, + ); + }).length; + + return accumulator; + }, {}), + [baseEvents, tagOptions], ); - const selectedCount = selectedCalendars.length; - const allSelected = selectedCount === Object.keys(calendarsColor).length; + const allTagsSelected = selectedTags.length === 0; const handleLeftSidebarToggle = () => setLeftSidebarOpen((previous) => !previous); @@ -433,6 +483,13 @@ const FarmerCalendarPage = () => { setEventDetailsOpen(false); setEventModalOpen(true); }; + const toggleTagFilter = (value: string) => { + setSelectedTags((current) => + current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value], + ); + }; const insightCards = [ { @@ -595,55 +652,65 @@ const FarmerCalendarPage = () => { dispatch(filterAllCalendarLabels(!allSelected))} + color={allTagsSelected ? "success" : "default"} + variant={allTagsSelected ? "filled" : "outlined"} + onClick={() => setSelectedTags([])} /> - {(Object.keys(categoryDetails) as CalendarFiltersType[]).map( - (key) => { - const detail = categoryDetails[key]; - const selected = selectedCalendars.includes(key); + {tagOptions.map((tag, index) => { + const paletteSequence: PaletteAccent[] = [ + "error", + "primary", + "warning", + "success", + "info", + ]; + const iconSequence = [ + "tabler-sun-high", + "tabler-tractor", + "tabler-users-group", + "tabler-leaf", + "tabler-sparkles", + ]; + const selected = selectedTags.includes(tag.value); + const accent = paletteSequence[index % paletteSequence.length]; + const icon = iconSequence[index % iconSequence.length]; - return ( - } - label={t("sidebar.focusLanes.categoryCount", { - label: t(detail.labelKey), - count: categoryCounts[key], - })} - clickable - color={detail.accent} - variant={selected ? "filled" : "outlined"} - onClick={() => dispatch(filterCalendarLabel(key))} - sx={{ - "& .MuiChip-icon": { - fontSize: "1rem", - }, - }} - /> - ); - }, - )} + return ( + } + label={`${tag.label} (${tagCounts[tag.value] ?? 0})`} + clickable + color={accent} + variant={selected ? "filled" : "outlined"} + onClick={() => toggleTagFilter(tag.value)} + sx={{ + "& .MuiChip-icon": { + fontSize: "1rem", + }, + }} + /> + ); + })} @@ -908,26 +975,18 @@ const FarmerCalendarPage = () => { }} /> - {(Object.keys(categoryDetails) as CalendarFiltersType[]).map( - (key) => ( - - ), - )} + {tagOptions.map((tag) => ( + + ))} @@ -994,7 +1053,10 @@ const FarmerCalendarPage = () => { number; }; +type FertilizationPlanParserPageProps = { + initialTab?: ParserTabKey; + enabledTabs?: ParserTabKey[]; +}; + const createInitialTabState = (): TabState => ({ message: "", response: null, @@ -551,12 +556,22 @@ const PARSER_CONFIGS: Record = { }, }; -const FertilizationPlanParserPage = () => { +const FertilizationPlanParserPage = ({ + initialTab = "fertilization", + enabledTabs = ["fertilization", "irrigation"], +}: FertilizationPlanParserPageProps) => { const theme = useTheme(); const { farmHub } = useFarmHub(); const farmUuid = farmHub?.farm_uuid; + const availableTabs = enabledTabs.filter( + (tab): tab is ParserTabKey => tab in PARSER_CONFIGS, + ); + const resolvedInitialTab = availableTabs.includes(initialTab) + ? initialTab + : availableTabs[0] ?? "fertilization"; + const singleTabMode = availableTabs.length === 1; - const [activeTab, setActiveTab] = useState("fertilization"); + const [activeTab, setActiveTab] = useState(resolvedInitialTab); const [tabStates, setTabStates] = useState>({ fertilization: createInitialTabState(), irrigation: createInitialTabState(), @@ -907,11 +922,14 @@ const FertilizationPlanParserPage = () => { variant="h5" sx={{ fontWeight: 800, mb: 1.5 }} > - دو جریان هوشمند، یک صفحه واحد + {singleTabMode + ? `جریان هوشمند ${config.label}` + : "دو جریان هوشمند، یک صفحه واحد"} - بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با - همان flow سوال های تکمیلی تا JSON نهایی پیش ببر. + {singleTabMode + ? `ورودی متنی برنامه ${config.label} را بفرست، ابهام ها را کامل کن و خروجی JSON نهایی بگیر.` + : "بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با همان flow سوال های تکمیلی تا JSON نهایی پیش ببر."} { - setActiveTab(value)} - variant="fullWidth" - sx={{ - p: 1, - borderRadius: 4, - backgroundColor: alpha(theme.palette.primary.main, 0.05), - minHeight: 64, - "& .MuiTabs-indicator": { display: "none" }, - }} - > - {Object.values(PARSER_CONFIGS).map((tabConfig) => ( - } - iconPosition="start" - label={tabConfig.label} - sx={{ - minHeight: 54, - borderRadius: 3, - fontWeight: 700, - transition: "all 0.2s ease", - "&.Mui-selected": { - color: theme.palette.common.white, - background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.info.main} 100%)`, - boxShadow: `0 14px 32px ${alpha(theme.palette.primary.main, 0.24)}`, - }, - }} - /> - ))} - + {!singleTabMode ? ( + setActiveTab(value)} + variant="fullWidth" + sx={{ + p: 1, + borderRadius: 4, + backgroundColor: alpha(theme.palette.primary.main, 0.05), + minHeight: 64, + "& .MuiTabs-indicator": { display: "none" }, + }} + > + {availableTabs.map((tabKey) => { + const tabConfig = PARSER_CONFIGS[tabKey]; + + return ( + } + iconPosition="start" + label={tabConfig.label} + sx={{ + minHeight: 54, + borderRadius: 3, + fontWeight: 700, + transition: "all 0.2s ease", + "&.Mui-selected": { + color: theme.palette.common.white, + background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.info.main} 100%)`, + boxShadow: `0 14px 32px ${alpha(theme.palette.primary.main, 0.24)}`, + }, + }} + /> + ); + })} + + ) : null} void; + title: string; + description: string; + currentPlanName: string; + currentPlanStatus: string; + currentPlanOutput: string; + summaryItems: SummaryItem[]; + relatedTitle: string; + relatedDescription: string; + relatedPlans: RelatedPlanItem[]; + selectedRelatedPlanId: number | null; + onSelectRelatedPlan: (planId: number) => void; + onConfirm: (selectedPlan: RelatedPlanItem) => void; +}; + +const backdropBlurSx = { backdropFilter: "blur(6px)", backgroundColor: "rgba(15, 23, 42, 0.32)" }; + +const RelatedPlanSelectorContent = ({ + title, + description, + currentPlanName, + currentPlanStatus, + currentPlanOutput, + summaryItems, + relatedTitle, + relatedDescription, + relatedPlans, + selectedRelatedPlanId, + onSelectRelatedPlan, + onConfirm, + onClose, +}: RelatedPlanSelectorProps) => { + const theme = useTheme(); + + const selectedPlan = useMemo( + () => relatedPlans.find((plan) => plan.id === selectedRelatedPlanId) ?? null, + [relatedPlans, selectedRelatedPlanId], + ); + + return ( + + + + + + {title} + + {description} + + + + + + + + + + + + + {currentPlanName} + {currentPlanOutput} + + + + + + {summaryItems.map((item) => ( + + + {item.label} + + + {item.value} + + + ))} + + + + + + + + {relatedTitle} + {relatedDescription} + + + + {relatedPlans.map((plan) => { + const isSelected = plan.id === selectedRelatedPlanId; + + return ( + onSelectRelatedPlan(plan.id)} + sx={{ + cursor: "pointer", + borderRadius: 4, + transition: "all 0.2s ease", + borderColor: isSelected + ? theme.palette.primary.main + : alpha(theme.palette.divider, 0.9), + boxShadow: isSelected + ? `0 0 0 3px ${alpha(theme.palette.primary.main, 0.14)}` + : "none", + backgroundColor: isSelected + ? alpha(theme.palette.primary.main, 0.05) + : theme.palette.background.paper, + "&:hover": { + transform: "translateY(-2px)", + boxShadow: theme.shadows[3], + }, + }} + > + + + + + + + + {plan.title} + {plan.finalProduct} + + + + + + + + + + + + + + + + + + + ); + })} + + + + + + + + {selectedPlan ? `انتخاب شد: ${selectedPlan.title}` : "یک برنامه مرتبط انتخاب کنید"} + + + {selectedPlan + ? `محصول نهایی ${selectedPlan.finalProduct} با برداشت ${selectedPlan.harvestTime}` + : "برای ادامه، یکی از برنامه‌های لیست بالا را انتخاب کنید."} + + + + + + + + + ); +}; + +const RelatedPlanSelector = (props: RelatedPlanSelectorProps) => { + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")); + + if (isMobile) { + return ( + + + + + + + + + ); + } + + return ( + + + + + + ); +}; + +export default RelatedPlanSelector; diff --git a/src/views/dashboards/farm/todos/FarmerTodoPage.tsx b/src/views/dashboards/farm/todos/FarmerTodoPage.tsx index cc4b4cc..efd34c9 100644 --- a/src/views/dashboards/farm/todos/FarmerTodoPage.tsx +++ b/src/views/dashboards/farm/todos/FarmerTodoPage.tsx @@ -1,7 +1,8 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -24,97 +25,117 @@ import OptionMenu from "@core/components/option-menu"; import CustomAvatar from "@core/components/mui/Avatar"; import CustomTextField from "@core/components/mui/TextField"; import HorizontalWithAvatar from "@components/card-statistics/HorizontalWithAvatar"; +import { useFarmHub } from "@/hooks/useFarmHub"; +import { farmerTodoService } from "@/libs/api/services/farmerTodoService"; +import type { + FarmerTodoPriority, + FarmerTodoStatus, + FarmerTodoSummary, + FarmerTodoTask, + FarmerTodoZoneOption, +} from "@/libs/api/services/farmerTodoService"; -type TaskPriority = "زیاد" | "متوسط" | "کم"; -type TaskStatus = "open" | "done"; type TaskSegment = "همه" | "امروز" | "فوری" | "انجام شده"; -type FarmerTask = { - id: number; - title: string; - zone: string; - time: string; - priority: TaskPriority; - note: string; - tags: string[]; - status: TaskStatus; -}; - -const initialTasks: FarmerTask[] = [ - { - id: 1, - title: "بررسی رطوبت ردیف شمالی و تنظیم آبیاری قطره ای", - zone: "قطعه گندم - شمال مزرعه", - time: "06:30", - priority: "زیاد", - note: "اگر رطوبت کمتر از 28٪ بود، دور دوم آبیاری را 20 دقیقه جلو بینداز.", - tags: ["آبیاری", "صبح زود"], - status: "open", - }, - { - id: 2, - title: "نمونه برداری خاک برای بخش سبزیجات", - zone: "گلخانه شماره 2", - time: "09:15", - priority: "متوسط", - note: "سه نقطه از بستر برداشت شود و برای آزمایش فسفر ثبت گردد.", - tags: ["خاک", "آزمایش"], - status: "open", - }, - { - id: 3, - title: "هماهنگی با راننده برای ارسال بار جو", - zone: "انبار مرکزی", - time: "12:00", - priority: "کم", - note: "وزن نهایی بارنامه قبل از خروج با باسکول تطبیق داده شود.", - tags: ["لجستیک", "فروش"], - status: "done", - }, - { - id: 4, - title: "بازدید سریع برای نشانه های آفت روی برگ های تازه", - zone: "باغچه آزمایشی غربی", - time: "17:40", - priority: "زیاد", - note: "فقط لکه های جدید را علامت بزن و برای تیم سمپاشی عکس بگیر.", - tags: ["آفت", "بازدید عصر"], - status: "open", - }, -]; - -const priorityMeta: Record = +const priorityMeta: Record< + FarmerTodoPriority, + { color: ThemeColor; icon: string } +> = { زیاد: { color: "error", icon: "tabler-alert-triangle" }, متوسط: { color: "warning", icon: "tabler-sun-high" }, کم: { color: "success", icon: "tabler-leaf" }, }; +const getPriorityMeta = (priority: string) => + priorityMeta[priority as FarmerTodoPriority] ?? priorityMeta["متوسط"]; + const segments: TaskSegment[] = ["همه", "امروز", "فوری", "انجام شده"]; +const todayDate = () => new Date().toISOString().slice(0, 10); + +const defaultSummary: FarmerTodoSummary = { + todayTasksCount: 0, + completedCount: 0, + urgentCount: 0, + progressValue: 0, + nextTask: null, +}; + const FarmerTodoPage = () => { const theme = useTheme(); + const { farmHub } = useFarmHub(); + const farmUuid = farmHub?.farm_uuid; - const [tasks, setTasks] = useState(initialTasks); + const [tasks, setTasks] = useState([]); + const [summary, setSummary] = useState(defaultSummary); + const [zones, setZones] = useState([]); const [segment, setSegment] = useState("همه"); const [draftTitle, setDraftTitle] = useState(""); - const [draftZone, setDraftZone] = useState("قطعه گندم - شمال مزرعه"); + const [draftZone, setDraftZone] = useState(""); const [draftTime, setDraftTime] = useState("07:00"); - const [draftPriority, setDraftPriority] = useState("متوسط"); + const [draftPriority, setDraftPriority] = + useState("متوسط"); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [togglingId, setTogglingId] = useState(null); + const [error, setError] = useState(null); - const completedCount = useMemo( - () => tasks.filter((task) => task.status === "done").length, - [tasks], - ); - const openCount = tasks.length - completedCount; - const urgentCount = useMemo( + const completedCount = summary.completedCount; + const openCount = useMemo( () => - tasks.filter((task) => task.priority === "زیاد" && task.status === "open") - .length, + tasks.filter( + (task) => task.scheduledDate === todayDate() && task.status === "open", + ).length, [tasks], ); - const progressValue = - tasks.length === 0 ? 0 : Math.round((completedCount / tasks.length) * 100); + const urgentCount = summary.urgentCount; + const progressValue = summary.progressValue; + const nextTask = summary.nextTask; + + const refreshSummary = async () => { + if (!farmUuid) return; + + try { + const latestSummary = await farmerTodoService.getSummary(farmUuid); + setSummary(latestSummary); + } catch {} + }; + + const loadData = async () => { + if (!farmUuid) { + setTasks([]); + setZones([]); + setSummary(defaultSummary); + setDraftZone(""); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const [taskList, summaryData, zoneList] = await Promise.all([ + farmerTodoService.listTasks({ farm_uuid: farmUuid }), + farmerTodoService.getSummary(farmUuid), + farmerTodoService.listZones(farmUuid), + ]); + + setTasks(taskList); + setSummary(summaryData); + setZones(zoneList); + setDraftZone((current) => current || zoneList[0]?.value || ""); + } catch (apiError: any) { + setError(apiError?.message || "دریافت اطلاعات تسک‌ها انجام نشد."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadData(); + }, [farmUuid]); const filteredTasks = useMemo(() => { switch (segment) { @@ -125,7 +146,7 @@ const FarmerTodoPage = () => { case "انجام شده": return tasks.filter((task) => task.status === "done"); case "امروز": - return tasks; + return tasks.filter((task) => task.scheduledDate === todayDate()); default: return tasks; } @@ -152,36 +173,61 @@ const FarmerTodoPage = () => { }, ]; - const toggleTask = (taskId: number) => { - setTasks((currentTasks) => - currentTasks.map((task) => - task.id === taskId - ? { ...task, status: task.status === "done" ? "open" : "done" } - : task, - ), - ); + const toggleTask = async (task: FarmerTodoTask) => { + if (!farmUuid) return; + + const nextStatus: FarmerTodoStatus = + task.status === "done" ? "open" : "done"; + + setTogglingId(task.id); + setError(null); + + try { + const updatedTask = await farmerTodoService.updateTask(task.id, { + farm_uuid: farmUuid, + status: nextStatus, + }); + + setTasks((currentTasks) => + currentTasks.map((item) => (item.id === task.id ? updatedTask : item)), + ); + await refreshSummary(); + } catch (apiError: any) { + setError(apiError?.message || "تغییر وضعیت تسک انجام نشد."); + } finally { + setTogglingId(null); + } }; - const addTask = () => { - if (!draftTitle.trim()) return; + const addTask = async () => { + if (!farmUuid || !draftTitle.trim() || !draftZone.trim()) return; - setTasks((currentTasks) => [ - { - id: Date.now(), + setSubmitting(true); + setError(null); + + try { + const createdTask = await farmerTodoService.createTask({ + farm_uuid: farmUuid, title: draftTitle.trim(), - zone: draftZone, + zone: draftZone.trim(), + scheduledDate: todayDate(), time: draftTime, priority: draftPriority, note: "بعد از ثبت انجام، اگر موردی غیرعادی بود برای شیفت بعدی یادداشت بگذار.", tags: [draftPriority === "زیاد" ? "فوری" : "روزانه", "ثبت دستی"], status: "open", - }, - ...currentTasks, - ]); + }); - setDraftTitle(""); - setDraftTime("07:00"); - setDraftPriority("متوسط"); + setTasks((currentTasks) => [createdTask, ...currentTasks]); + await refreshSummary(); + setDraftTitle(""); + setDraftTime("07:00"); + setDraftPriority("متوسط"); + } catch (apiError: any) { + setError(apiError?.message || "ایجاد تسک جدید انجام نشد."); + } finally { + setSubmitting(false); + } }; return ( @@ -275,7 +321,9 @@ const FarmerTodoPage = () => { - بازدید 06:30 از ردیف شمالی + {nextTask + ? `${nextTask.time} - ${nextTask.title}` + : "هنوز کاری برای امروز ثبت نشده"} { - آبیاری، آفت و هماهنگی بار خروجی + {nextTask?.tags?.length + ? nextTask.tags.join("، ") + : "آبیاری، آفت و هماهنگی بار خروجی"} @@ -421,6 +471,12 @@ const FarmerTodoPage = () => { } /> + {!farmUuid ? ( + + برای مشاهده و ثبت کارها، ابتدا یک مزرعه فعال انتخاب کن. + + ) : null} + {error ? {error} : null}
{segments.map((item) => ( { ))}
+ {loading ? ( + + + + ) : null} + {!loading && filteredTasks.length === 0 ? ( + + هنوز کاری برای این بخش ثبت نشده است. + + ) : null} {filteredTasks.map((task) => { - const meta = priorityMeta[task.priority]; + const meta = getPriorityMeta(task.priority); return ( {
toggleTask(task.id)} + disabled={togglingId === task.id} + onChange={() => void toggleTask(task)} sx={{ mt: -0.5 }} />
@@ -518,6 +585,9 @@ const FarmerTodoPage = () => { {task.zone}
+ + {task.scheduledDate} + {task.status === "done" ? "انجام شده و ثبت شده" @@ -549,21 +619,28 @@ const FarmerTodoPage = () => { onChange={(event) => setDraftTitle(event.target.value)} /> setDraftZone(event.target.value)} - > - - قطعه گندم - شمال مزرعه - - گلخانه شماره 2 - انبار مرکزی - - باغچه آزمایشی غربی - - + disabled={!farmUuid || submitting} + /> + {zones.length > 0 ? ( +
+ {zones.slice(0, 6).map((zone) => ( + setDraftZone(zone.value)} + /> + ))} +
+ ) : null} { label="ساعت" value={draftTime} onChange={(event) => setDraftTime(event.target.value)} + disabled={!farmUuid || submitting} /> @@ -580,8 +658,9 @@ const FarmerTodoPage = () => { label="اولویت" value={draftPriority} onChange={(event) => - setDraftPriority(event.target.value as TaskPriority) + setDraftPriority(event.target.value as FarmerTodoPriority) } + disabled={!farmUuid || submitting} > زیاد متوسط @@ -593,9 +672,15 @@ const FarmerTodoPage = () => { variant="contained" size="large" startIcon={} - onClick={addTask} + disabled={ + !farmUuid || + submitting || + !draftTitle.trim() || + !draftZone.trim() + } + onClick={() => void addTask()} > - ثبت در لیست امروز + {submitting ? "در حال ثبت..." : "ثبت در لیست امروز"}