This commit is contained in:
2026-05-02 06:23:34 +03:30
parent 0466b7dc75
commit f8d1f84ed6
16 changed files with 1934 additions and 369 deletions
+1
View File
@@ -45,6 +45,7 @@
"irrigationRecommendation": "توصیه آبیاری", "irrigationRecommendation": "توصیه آبیاری",
"fertilizationRecommendation": "توصیه کوددهی", "fertilizationRecommendation": "توصیه کوددهی",
"fertilizationPlanParser": "برنامه آبیاری و کودهی", "fertilizationPlanParser": "برنامه آبیاری و کودهی",
"irrigationPlanParser": "برنامه آبیاری",
"aiAssistant": "دستیار هوشمند", "aiAssistant": "دستیار هوشمند",
"farmAiAssistant": "دستیار هوشمند مزرعه", "farmAiAssistant": "دستیار هوشمند مزرعه",
"pestDetection": "تشخیص آفات گیاهی", "pestDetection": "تشخیص آفات گیاهی",
@@ -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 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 = () => { const FertilizationPlanPage = () => {
return <FertilizationPlanParserPage />; const router = useRouter();
const [plans, setPlans] = useState(mockFertilizationPlans);
const [minOutputTon, setMinOutputTon] = useState("0");
const [selectedPlan, setSelectedPlan] = useState<FertilizationPlanRow | null>(
mockFertilizationPlans[0],
);
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedRelatedPlanId, setSelectedRelatedPlanId] = useState<number | null>(
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 (
<>
<Stack spacing={6}>
<FertilizationPlanParserPage />
<Card>
<CardContent>
<Stack spacing={4}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={3}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography variant="h5">لیست برنامههای کوددهی</Typography>
<Typography color="text.secondary">
نمایش همه برنامهها با داده ماک و امکان فیلتر بر اساس بیشترین تن خروجی محصول
</Typography>
</Box>
<CustomTextField
select
value={minOutputTon}
onChange={(event) => setMinOutputTon(event.target.value)}
label="فیلتر تن خروجی محصول"
sx={{ minWidth: { xs: "100%", md: 260 } }}
SelectProps={{ native: true }}
>
<option value="0">همه برنامهها</option>
<option value="30">بیشتر از ۳۰ تن</option>
<option value="40">بیشتر از ۴۰ تن</option>
<option value="50">بیشتر از ۵۰ تن</option>
</CustomTextField>
</Stack>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>نام برنامه</TableCell>
<TableCell>محصول نهایی</TableCell>
<TableCell>زمان برداشت</TableCell>
<TableCell>تن خروجی</TableCell>
<TableCell>نوع کود</TableCell>
<TableCell>وضعیت</TableCell>
<TableCell align="center">عملیات</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow hover key={plan.id}>
<TableCell>{plan.planName}</TableCell>
<TableCell>{plan.finalProduct}</TableCell>
<TableCell>{plan.harvestTime}</TableCell>
<TableCell>{plan.outputTon} تن</TableCell>
<TableCell>{plan.fertilizerType}</TableCell>
<TableCell>
<Chip
size="small"
label={plan.status === "active" ? "فعال" : "پیش‌نویس"}
color={plan.status === "active" ? "success" : "default"}
variant={plan.status === "active" ? "filled" : "tonal"}
/>
</TableCell>
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="center">
<Tooltip title="فعال‌سازی برنامه">
<span>
<IconButton
color="success"
onClick={() => handleActivate(plan.id)}
disabled={plan.status === "active"}
>
<i className="tabler-player-play text-[20px]" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="مشاهده جزئیات">
<IconButton
color="primary"
onClick={() => handleShowDetails(plan)}
>
<i className="tabler-eye text-[20px]" />
</IconButton>
</Tooltip>
<Tooltip title="حذف برنامه">
<IconButton
color="error"
onClick={() => handleDelete(plan.id)}
>
<i className="tabler-trash text-[20px]" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
{filteredPlans.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<Alert severity="info">
برنامهای با این فیلتر پیدا نشد.
</Alert>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
<Divider />
<Stack spacing={2}>
<Typography variant="h6">جزئیات برنامه انتخابشده</Typography>
{selectedPlan ? (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
useFlexGap
flexWrap="wrap"
>
<Chip label={`محصول نهایی: ${selectedPlan.finalProduct}`} variant="tonal" />
<Chip label={`زمان برداشت: ${selectedPlan.harvestTime}`} variant="tonal" />
<Chip label={`تن خروجی: ${selectedPlan.outputTon} تن`} variant="tonal" />
<Chip label={`نوع کود: ${selectedPlan.fertilizerType}`} variant="tonal" />
<Button
variant="outlined"
color="primary"
startIcon={<i className="tabler-list-details" />}
onClick={() => handleShowDetails(selectedPlan)}
>
مشاهده و انتخاب برنامه آبیاری
</Button>
<Button
variant="contained"
color="success"
startIcon={<i className="tabler-check" />}
onClick={() => handleActivate(selectedPlan.id)}
>
فعال کردن این برنامه
</Button>
</Stack>
) : (
<Alert severity="warning">
برای دیدن جزئیات، یکی از برنامههای کوددهی را انتخاب کنید.
</Alert>
)}
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
{selectedPlan ? (
<RelatedPlanSelector
open={detailsOpen}
onClose={() => 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; export default FertilizationPlanPage;
@@ -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<IrrigationPlanRow | null>(
mockIrrigationPlans[0],
);
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedRelatedPlanId, setSelectedRelatedPlanId] = useState<number | null>(
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 (
<>
<Stack spacing={6}>
<FertilizationPlanParserPage
initialTab="irrigation"
enabledTabs={["irrigation"]}
/>
<Card>
<CardContent>
<Stack spacing={4}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={3}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography variant="h5">لیست برنامههای آبیاری</Typography>
<Typography color="text.secondary">
نمایش همه برنامهها با داده ماک و امکان فیلتر بر اساس بیشترین تن خروجی محصول
</Typography>
</Box>
<CustomTextField
select
value={minOutputTon}
onChange={(event) => setMinOutputTon(event.target.value)}
label="فیلتر تن خروجی محصول"
sx={{ minWidth: { xs: "100%", md: 260 } }}
SelectProps={{ native: true }}
>
<option value="0">همه برنامهها</option>
<option value="30">بیشتر از ۳۰ تن</option>
<option value="40">بیشتر از ۴۰ تن</option>
<option value="50">بیشتر از ۵۰ تن</option>
</CustomTextField>
</Stack>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>نام برنامه</TableCell>
<TableCell>محصول نهایی</TableCell>
<TableCell>زمان برداشت</TableCell>
<TableCell>تن خروجی</TableCell>
<TableCell>روش آبیاری</TableCell>
<TableCell>وضعیت</TableCell>
<TableCell align="center">عملیات</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPlans.map((plan) => (
<TableRow hover key={plan.id}>
<TableCell>{plan.planName}</TableCell>
<TableCell>{plan.finalProduct}</TableCell>
<TableCell>{plan.harvestTime}</TableCell>
<TableCell>{plan.outputTon} تن</TableCell>
<TableCell>{plan.irrigationMethod}</TableCell>
<TableCell>
<Chip
size="small"
label={plan.status === "active" ? "فعال" : "پیش‌نویس"}
color={plan.status === "active" ? "success" : "default"}
variant={plan.status === "active" ? "filled" : "tonal"}
/>
</TableCell>
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="center">
<Tooltip title="فعال‌سازی برنامه">
<span>
<IconButton
color="success"
onClick={() => handleActivate(plan.id)}
disabled={plan.status === "active"}
>
<i className="tabler-player-play text-[20px]" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="مشاهده جزئیات">
<IconButton
color="primary"
onClick={() => handleShowDetails(plan)}
>
<i className="tabler-eye text-[20px]" />
</IconButton>
</Tooltip>
<Tooltip title="حذف برنامه">
<IconButton
color="error"
onClick={() => handleDelete(plan.id)}
>
<i className="tabler-trash text-[20px]" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
{filteredPlans.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<Alert severity="info">
برنامهای با این فیلتر پیدا نشد.
</Alert>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
<Divider />
<Stack spacing={2}>
<Typography variant="h6">جزئیات برنامه انتخابشده</Typography>
{selectedPlan ? (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
useFlexGap
flexWrap="wrap"
>
<Chip label={`محصول نهایی: ${selectedPlan.finalProduct}`} variant="tonal" />
<Chip label={`زمان برداشت: ${selectedPlan.harvestTime}`} variant="tonal" />
<Chip label={`تن خروجی: ${selectedPlan.outputTon} تن`} variant="tonal" />
<Chip label={`روش آبیاری: ${selectedPlan.irrigationMethod}`} variant="tonal" />
<Button
variant="outlined"
color="primary"
startIcon={<i className="tabler-list-details" />}
onClick={() => handleShowDetails(selectedPlan)}
>
مشاهده و انتخاب برنامه کوددهی
</Button>
<Button
variant="contained"
color="success"
startIcon={<i className="tabler-check" />}
onClick={() => handleActivate(selectedPlan.id)}
>
فعال کردن این برنامه
</Button>
</Stack>
) : (
<Alert severity="warning">
برای دیدن جزئیات، یکی از برنامههای آبیاری را انتخاب کنید.
</Alert>
)}
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
{selectedPlan ? (
<RelatedPlanSelector
open={detailsOpen}
onClose={() => 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;
@@ -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 = () => { const YieldHarvestPage = () => {
return <PlantProductionPage /> 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 (
<Stack spacing={6}>
{selectionSummary ? (
<Card variant="outlined">
<CardContent>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography variant="h5">تحلیل عملکرد و برداشت</Typography>
<Typography color="text.secondary">
بر اساس انتخاب برنامهها وارد این صفحه شدهاید.
</Typography>
</Box>
<Button
variant="outlined"
color="secondary"
startIcon={<i className="tabler-arrow-right text-[18px]" />}
onClick={handleBack}
>
بازگشت به صفحه قبل
</Button>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} useFlexGap flexWrap="wrap" sx={{ mt: 3 }}>
<Chip
variant="tonal"
color="primary"
label={`برنامه ${selectionSummary.planType === "irrigation" ? "آبیاری" : "کوددهی"}: ${selectionSummary.planName}`}
/>
<Chip
variant="tonal"
color="success"
label={`برنامه ${selectionSummary.relatedType === "irrigation" ? "آبیاری" : "کوددهی"} انتخاب‌شده: ${selectionSummary.relatedPlanName}`}
/>
</Stack>
</CardContent>
</Card>
) : null}
<PlantProductionPage />
</Stack>
);
};
export default YieldHarvestPage;
@@ -157,6 +157,12 @@ const VerticalMenu = ({ scrollMenu }: Props) => {
</MenuSection> </MenuSection>
<MenuSection label={t("recommendation")}> <MenuSection label={t("recommendation")}>
<MenuItem
href="/irrigation-plan"
icon={<i className="tabler-droplet-half-2" />}
>
{t("irrigationPlanParser")}
</MenuItem>
<MenuItem <MenuItem
href="/irrigation-recommendation" href="/irrigation-recommendation"
icon={<i className="tabler-droplet-half-2" />} icon={<i className="tabler-droplet-half-2" />}
+1
View File
@@ -15,6 +15,7 @@ export const navigationLabels = {
sensor7In1: 'سنسور خاک 7 در 1', sensor7In1: 'سنسور خاک 7 در 1',
dataSection: 'بخش داده‌ها', dataSection: 'بخش داده‌ها',
recommendation: 'توصیه‌ها', recommendation: 'توصیه‌ها',
irrigationPlanParser: 'برنامه آبیاری',
irrigationRecommendation: 'توصیه آبیاری', irrigationRecommendation: 'توصیه آبیاری',
fertilizationRecommendation: 'توصیه کوددهی', fertilizationRecommendation: 'توصیه کوددهی',
aiAssistant: 'دستیار هوشمند', aiAssistant: 'دستیار هوشمند',
+1
View File
@@ -120,6 +120,7 @@
"sensorSection": "سنسورها", "sensorSection": "سنسورها",
"sensor7In1": "سنسور خاک 7 در 1", "sensor7In1": "سنسور خاک 7 در 1",
"recommendation": "توصیه‌ها", "recommendation": "توصیه‌ها",
"irrigationPlanParser": "برنامه آبیاری",
"irrigationRecommendation": "توصیه آبیاری", "irrigationRecommendation": "توصیه آبیاری",
"fertilizationRecommendation": "توصیه کوددهی", "fertilizationRecommendation": "توصیه کوددهی",
"aiAssistant": "دستیار هوشمند", "aiAssistant": "دستیار هوشمند",
+36 -19
View File
@@ -10,20 +10,19 @@ export interface Event {
id: string id: string
title: string title: string
description: string description: string
deadline: number // Unix timestamp deadline?: number // Unix timestamp
tags: string[] tags: string[]
author: Author author?: Author
calendar: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC'
start: string // ISO 8601 start: string // ISO 8601
end: string // ISO 8601 end: string // ISO 8601
allDay: boolean allDay?: boolean
extendedProps?: Record<string, any> extendedProps?: Record<string, any>
} }
export interface ListEventsParams { export interface ListEventsParams {
start?: string // ISO 8601 start?: string // ISO 8601
end?: string // ISO 8601 end?: string // ISO 8601
calendar?: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' farm_uuid?: string
} }
export interface CreateEventRequest { export interface CreateEventRequest {
@@ -31,10 +30,9 @@ export interface CreateEventRequest {
description?: string description?: string
deadline?: number deadline?: number
tags?: string[] tags?: string[]
calendar: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC'
start: string // ISO 8601 start: string // ISO 8601
end: string // ISO 8601 end: string // ISO 8601
allDay?: boolean farm_uuid?: string
extendedProps?: Record<string, any> extendedProps?: Record<string, any>
} }
@@ -52,14 +50,13 @@ export interface ListEventsResponse {
export interface UpdateEventRequest { export interface UpdateEventRequest {
id: string id: string
title?: string title: string
description?: string description?: string
deadline?: number deadline?: number
tags?: string[] tags?: string[]
calendar?: 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC' start: string // ISO 8601
start?: string // ISO 8601 end: string // ISO 8601
end?: string // ISO 8601 farm_uuid?: string
allDay?: boolean
extendedProps?: Record<string, any> extendedProps?: Record<string, any>
} }
@@ -71,6 +68,16 @@ export interface DeleteEventResponse {
success: boolean success: boolean
} }
export interface EventTag {
id: string
label: string
value: string
}
export interface ListEventTagsResponse {
tags: EventTag[]
}
export interface NonRoutineTask { export interface NonRoutineTask {
id: string id: string
title: string title: string
@@ -93,7 +100,7 @@ export const eventService = {
* Create a new event * Create a new event
*/ */
async createEvent(data: CreateEventRequest): Promise<Event> { async createEvent(data: CreateEventRequest): Promise<Event> {
const response = await apiClient.post<CreateEventResponse>('/api/events', data) const response = await apiClient.post<CreateEventResponse>('/api/events/', data)
return response.event return response.event
}, },
@@ -101,7 +108,7 @@ export const eventService = {
* Get an event by ID * Get an event by ID
*/ */
async getEvent(id: string): Promise<Event> { async getEvent(id: string): Promise<Event> {
const response = await apiClient.get<GetEventResponse>(`/api/events/${id}`) const response = await apiClient.get<GetEventResponse>(`/api/events/${id}/`)
return response.event return response.event
}, },
@@ -112,10 +119,10 @@ export const eventService = {
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
if (params?.start) queryParams.append('start', params.start) if (params?.start) queryParams.append('start', params.start)
if (params?.end) queryParams.append('end', params.end) 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 queryString = queryParams.toString()
const endpoint = `/api/events${queryString ? `?${queryString}` : ''}` const endpoint = `/api/events/${queryString ? `?${queryString}` : ''}`
const response = await apiClient.get<ListEventsResponse>(endpoint) const response = await apiClient.get<ListEventsResponse>(endpoint)
return response.events || [] return response.events || []
@@ -126,7 +133,7 @@ export const eventService = {
*/ */
async updateEvent(data: UpdateEventRequest): Promise<Event> { async updateEvent(data: UpdateEventRequest): Promise<Event> {
const { id, ...updateData } = data const { id, ...updateData } = data
const response = await apiClient.put<UpdateEventResponse>(`/api/events/${id}`, updateData) const response = await apiClient.put<UpdateEventResponse>(`/api/events/${id}/`, updateData)
return response.event return response.event
}, },
@@ -134,10 +141,21 @@ export const eventService = {
* Delete an event * Delete an event
*/ */
async deleteEvent(id: string): Promise<boolean> { async deleteEvent(id: string): Promise<boolean> {
const response = await apiClient.delete<DeleteEventResponse>(`/api/events/${id}`) const response = await apiClient.delete<DeleteEventResponse>(`/api/events/${id}/`)
return response.success return response.success
}, },
async listTags(farmUuid?: string): Promise<EventTag[]> {
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<ListEventTagsResponse>(endpoint)
return response.tags || []
},
/** /**
* دریافت تسک‌های غیرروتین (مشترک با Kanban و Todo) * دریافت تسک‌های غیرروتین (مشترک با Kanban و Todo)
*/ */
@@ -146,4 +164,3 @@ export const eventService = {
return response.tasks || [] return response.tasks || []
} }
} }
+190
View File
@@ -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, string | undefined>,
): 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<FarmerTodoTask[]> {
const response = await apiClient.get<ListFarmerTodosResponse>(
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<FarmerTodoTask> {
const response = await apiClient.post<FarmerTodoResponse>(
"/api/farmer-todos/",
data,
);
return normalizeTask(response.task);
},
async updateTask(
taskId: string,
data: UpdateFarmerTodoRequest,
): Promise<FarmerTodoTask> {
const response = await apiClient.put<FarmerTodoResponse>(
`/api/farmer-todos/${taskId}/`,
data,
);
return normalizeTask(response.task);
},
async deleteTask(taskId: string): Promise<boolean> {
const response = await apiClient.delete<{ success: boolean }>(
`/api/farmer-todos/${taskId}/`,
);
return response.success;
},
async listZones(farmUuid?: string): Promise<FarmerTodoZoneOption[]> {
const response = await apiClient.get<ListZonesResponse>(
withQuery("/api/farmer-todos/zones/", { farm_uuid: farmUuid }),
);
return response.zones ?? [];
},
async getSummary(farmUuid?: string): Promise<FarmerTodoSummary> {
return apiClient.get<FarmerTodoSummary>(
withQuery("/api/farmer-todos/summary/", { farm_uuid: farmUuid }),
);
},
};
+30 -56
View File
@@ -8,11 +8,12 @@ import type { CalendarFiltersType, CalendarType } from '@/types/apps/calendarTyp
// API Imports // API Imports
import { eventService } from '@/libs/api' import { eventService } from '@/libs/api'
import type { Event, ListEventsParams } from '@/libs/api'
// Async Thunks // Async Thunks
export const fetchEvents = createAsyncThunk( export const fetchEvents = createAsyncThunk(
'calendar/fetchEvents', 'calendar/fetchEvents',
async (params?: { start?: string; end?: string; calendar?: CalendarFiltersType }) => { async (params?: ListEventsParams) => {
return await eventService.listEvents(params) return await eventService.listEvents(params)
} }
) )
@@ -45,6 +46,22 @@ const initialState: CalendarType & {
error: null 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[]) => { const filterEventsUsingCheckbox = (events: EventInput[], selectedCalendars: CalendarFiltersType[]) => {
return events.filter(event => selectedCalendars.includes(event.extendedProps?.calendar as CalendarFiltersType)) return events.filter(event => selectedCalendars.includes(event.extendedProps?.calendar as CalendarFiltersType))
} }
@@ -54,7 +71,7 @@ export const calendarSlice = createSlice({
initialState: initialState, initialState: initialState,
reducers: { reducers: {
filterEvents: state => { filterEvents: state => {
state.filteredEvents = state.events state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars)
}, },
addEvent: (state, action) => { addEvent: (state, action) => {
@@ -117,24 +134,9 @@ export const calendarSlice = createSlice({
}) })
.addCase(fetchEvents.fulfilled, (state, action) => { .addCase(fetchEvents.fulfilled, (state, action) => {
state.loading = false state.loading = false
// Convert API events to FullCalendar EventInput format const events: EventInput[] = action.payload.map(mapEventToCalendarInput)
const events: EventInput[] = action.payload.map(event => ({ state.filteredEvents = events
id: event.id, state.events = filterEventsUsingCheckbox(events, state.selectedCalendars)
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)
}) })
.addCase(fetchEvents.rejected, (state, action) => { .addCase(fetchEvents.rejected, (state, action) => {
state.loading = false state.loading = false
@@ -142,51 +144,23 @@ export const calendarSlice = createSlice({
}) })
// Create Event // Create Event
.addCase(createEventAsync.fulfilled, (state, action) => { .addCase(createEventAsync.fulfilled, (state, action) => {
const event: EventInput = { const event = mapEventToCalendarInput(action.payload)
id: action.payload.id, state.filteredEvents.push(event)
title: action.payload.title, state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars)
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)
}) })
// Update Event // Update Event
.addCase(updateEventAsync.fulfilled, (state, action) => { .addCase(updateEventAsync.fulfilled, (state, action) => {
const index = state.events.findIndex(event => event.id === action.payload.id) const index = state.events.findIndex(event => event.id === action.payload.id)
if (index !== -1) { if (index !== -1) {
const event: EventInput = { const event = mapEventToCalendarInput(action.payload)
id: action.payload.id, state.filteredEvents[index] = event
title: action.payload.title, state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars)
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)
} }
}) })
// Delete Event // Delete Event
.addCase(deleteEventAsync.fulfilled, (state, action) => { .addCase(deleteEventAsync.fulfilled, (state, action) => {
state.events = state.events.filter(event => event.id !== action.meta.arg) state.filteredEvents = state.filteredEvents.filter(event => event.id !== action.meta.arg)
state.filteredEvents = filterEventsUsingCheckbox(state.events, state.selectedCalendars) state.events = filterEventsUsingCheckbox(state.filteredEvents, state.selectedCalendars)
}) })
} }
}) })
+59 -8
View File
@@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
// Third-party imports // Third-party imports
import type { Dispatch } from "@reduxjs/toolkit";
import "bootstrap-icons/font/bootstrap-icons.css"; import "bootstrap-icons/font/bootstrap-icons.css";
import FullCalendar from "@fullcalendar/react"; import FullCalendar from "@fullcalendar/react";
@@ -20,14 +19,16 @@ import faLocale from "@fullcalendar/core/locales/fa";
import type { import type {
AddEventType, AddEventType,
CalendarColors, CalendarColors,
CalendarFiltersType,
CalendarType, CalendarType,
} from "@/types/apps/calendarTypes"; } from "@/types/apps/calendarTypes";
import type { AppDispatch } from "@/redux-store";
// Slice Imports // Slice Imports
import { import {
filterEvents,
selectedEvent, selectedEvent,
updateEvent, updateEvent,
updateEventAsync,
} from "@/redux-store/slices/calendar"; } from "@/redux-store/slices/calendar";
type CalenderProps = { type CalenderProps = {
@@ -35,10 +36,11 @@ type CalenderProps = {
calendarApi: any; calendarApi: any;
setCalendarApi: (val: any) => void; setCalendarApi: (val: any) => void;
calendarsColor: CalendarColors; calendarsColor: CalendarColors;
dispatch: Dispatch; dispatch: AppDispatch | Dispatch<AnyAction>;
dispatch: AppDispatch;
handleLeftSidebarToggle: () => void; handleLeftSidebarToggle: () => void;
handleAddEventSidebarToggle: () => void; handleAddEventSidebarToggle: () => void;
handleEventDetailsOpen: () => void; handleEventDetailsOpen?: () => void;
}; };
const blankEvent: AddEventType = { const blankEvent: AddEventType = {
@@ -153,7 +155,10 @@ const Calendar = (props: CalenderProps) => {
eventClassNames({ event: calendarEvent }: any) { eventClassNames({ event: calendarEvent }: any) {
// @ts-ignore // @ts-ignore
const colorName = const colorName =
calendarsColor[calendarEvent._def.extendedProps.calendar]; calendarsColor[
(calendarEvent._def.extendedProps.calendar ||
"ETC") as CalendarFiltersType
];
return [ return [
// Background Color // Background Color
@@ -175,7 +180,7 @@ const Calendar = (props: CalenderProps) => {
extendedProps: clickedEvent.extendedProps, extendedProps: clickedEvent.extendedProps,
}), }),
); );
handleEventDetailsOpen(); handleEventDetailsOpen?.();
//* Only grab required field otherwise it goes in infinity loop //* Only grab required field otherwise it goes in infinity loop
//! Always grab all fields rendered by form (even if it get `undefined`) //! Always grab all fields rendered by form (even if it get `undefined`)
@@ -210,7 +215,30 @@ const Calendar = (props: CalenderProps) => {
*/ */
eventDrop({ event: droppedEvent }: any) { eventDrop({ event: droppedEvent }: any) {
dispatch(updateEvent(droppedEvent)); 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) { eventResize({ event: resizedEvent }: any) {
dispatch(updateEvent(resizedEvent)); 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 // @ts-ignore
@@ -23,15 +23,14 @@ import CustomTextField from "@core/components/mui/TextField";
import { eventService } from "@/libs/api"; import { eventService } from "@/libs/api";
import AppReactDatepicker from "@/libs/styles/AppReactDatepicker"; import AppReactDatepicker from "@/libs/styles/AppReactDatepicker";
import { import {
addEvent, createEventAsync,
deleteEvent, deleteEventAsync,
filterEvents, fetchEvents,
selectedEvent, selectedEvent,
updateEvent, updateEventAsync,
} from "@/redux-store/slices/calendar"; } from "@/redux-store/slices/calendar";
import type { import type {
AddEventSidebarType, AddEventSidebarType,
AddEventType,
} from "@/types/apps/calendarTypes"; } from "@/types/apps/calendarTypes";
type FarmerCalendarEventModalProps = Omit< type FarmerCalendarEventModalProps = Omit<
@@ -69,6 +68,16 @@ const defaultState: DefaultStateType = {
startDate: new Date(), 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 = { const modalTransitionDuration = {
appear: 0, appear: 0,
enter: 140, enter: 140,
@@ -108,6 +117,8 @@ const FarmerCalendarEventModal = ({
const [values, setValues] = useState<DefaultStateType>(defaultState); const [values, setValues] = useState<DefaultStateType>(defaultState);
const [tagOptions, setTagOptions] = useState<string[]>([]); const [tagOptions, setTagOptions] = useState<string[]>([]);
const [tagLoading, setTagLoading] = useState(false); const [tagLoading, setTagLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const { const {
control, control,
@@ -131,8 +142,8 @@ const FarmerCalendarEventModal = ({
: "", : "",
allDay: event.allDay ?? true, allDay: event.allDay ?? true,
description: event.extendedProps?.description || "", description: event.extendedProps?.description || "",
endDate: event.end !== null ? event.end : event.start, endDate: event.end !== null ? toDate(event.end) : toDate(event.start),
startDate: event.start !== null ? event.start : new Date(), startDate: event.start !== null ? toDate(event.start) : new Date(),
}); });
} }
}, [calendarStore.selectedEvent, setValue]); }, [calendarStore.selectedEvent, setValue]);
@@ -149,43 +160,62 @@ const FarmerCalendarEventModal = ({
onClose(); onClose();
}; };
const onSubmit = (data: { title: string }) => { const onSubmit = async (data: { title: string }) => {
const modifiedEvent: AddEventType = { const payload = {
display: "block",
title: data.title, title: data.title,
end: values.endDate, description: values.description.length ? values.description : undefined,
allDay: values.allDay, tags: values.tag.trim().length ? [values.tag.trim()] : [],
start: values.startDate, start: values.startDate.toISOString(),
end: values.endDate.toISOString(),
extendedProps: { extendedProps: {
calendar: ...(calendarStore.selectedEvent?.extendedProps || {}),
calendarStore.selectedEvent?.extendedProps?.calendar || "Business",
description: values.description.length ? values.description : undefined,
tags: values.tag.trim().length ? [values.tag.trim()] : [],
}, },
}; };
if ( setSubmitError(null);
calendarStore.selectedEvent === null || setSubmitting(true);
(calendarStore.selectedEvent !== null &&
!calendarStore.selectedEvent.title.length)
) {
dispatch(addEvent(modifiedEvent));
} else {
dispatch(
updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }),
);
}
dispatch(filterEvents()); try {
handleClose(); 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) { if (calendarStore.selectedEvent?.id) {
dispatch(deleteEvent(calendarStore.selectedEvent.id)); setSubmitError(null);
dispatch(filterEvents()); 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(); handleClose();
}; };
@@ -228,19 +258,10 @@ const FarmerCalendarEventModal = ({
setTagLoading(true); setTagLoading(true);
try { try {
const events = await eventService.listEvents(); const tags = await eventService.listTags();
const options = Array.from( const options = tags
new Set( .map((tag) => tag.value?.trim())
events.flatMap((event) => .filter((tag): tag is string => Boolean(tag));
Array.isArray(event.tags)
? event.tags
.filter((tag): tag is string => typeof tag === "string")
.map((tag) => tag.trim())
.filter(Boolean)
: [],
),
),
);
if (active) { if (active) {
setTagOptions(options); setTagOptions(options);
@@ -265,6 +286,11 @@ const FarmerCalendarEventModal = ({
autoComplete="off" autoComplete="off"
className="flex flex-col gap-6" className="flex flex-col gap-6"
> >
{submitError ? (
<Typography color="error" variant="body2">
{submitError}
</Typography>
) : null}
<Controller <Controller
name="title" name="title"
control={control} control={control}
@@ -385,6 +411,7 @@ const FarmerCalendarEventModal = ({
variant="outlined" variant="outlined"
color="error" color="error"
onClick={handleDeleteButtonClick} onClick={handleDeleteButtonClick}
disabled={submitting}
> >
{t("actions.delete")} {t("actions.delete")}
</Button> </Button>
@@ -392,7 +419,7 @@ const FarmerCalendarEventModal = ({
<Button variant="outlined" color="secondary" onClick={handleClose}> <Button variant="outlined" color="secondary" onClick={handleClose}>
{t("actions.cancel")} {t("actions.cancel")}
</Button> </Button>
<Button type="submit" variant="contained"> <Button type="submit" variant="contained" disabled={submitting}>
{isEditMode ? t("actions.update") : t("actions.create")} {isEditMode ? t("actions.update") : t("actions.create")}
</Button> </Button>
</Stack> </Stack>
+148 -86
View File
@@ -23,15 +23,12 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { EventInput } from "@fullcalendar/core"; import type { EventInput } from "@fullcalendar/core";
import { format, isThisWeek, isToday, parseISO } from "date-fns"; 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 AppReactDatepicker from "@/libs/styles/AppReactDatepicker";
import AppFullCalendar from "@/libs/styles/AppFullCalendar"; import AppFullCalendar from "@/libs/styles/AppFullCalendar";
import { useAppDispatch, useAppSelector } from "@/redux-store"; import { useAppDispatch, useAppSelector } from "@/redux-store";
import { import { fetchEvents, selectedEvent } from "@/redux-store/slices/calendar";
fetchEvents,
filterAllCalendarLabels,
filterCalendarLabel,
selectedEvent,
} from "@/redux-store/slices/calendar";
import type { import type {
CalendarColors, CalendarColors,
CalendarFiltersType, CalendarFiltersType,
@@ -51,6 +48,12 @@ type EventCardData = {
description?: string; description?: string;
}; };
type CalendarTagChip = {
id: string;
label: string;
value: string;
};
const calendarsColor: CalendarColors = { const calendarsColor: CalendarColors = {
Personal: "error", Personal: "error",
Business: "primary", Business: "primary",
@@ -343,29 +346,78 @@ const FarmerCalendarPage = () => {
const t = useTranslations("farmerCalendar"); const t = useTranslations("farmerCalendar");
const theme = useTheme(); const theme = useTheme();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const mdUp = useMediaQuery(theme.breakpoints.up("lg")); const mdUp = useMediaQuery(theme.breakpoints.up("lg"));
const [calendarApi, setCalendarApi] = useState<null | any>(null); const [calendarApi, setCalendarApi] = useState<null | any>(null);
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
const [eventModalOpen, setEventModalOpen] = useState(false); const [eventModalOpen, setEventModalOpen] = useState(false);
const [eventDetailsOpen, setEventDetailsOpen] = useState(false); const [eventDetailsOpen, setEventDetailsOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<CalendarTagChip[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const calendarStore = useAppSelector((state) => state.calendarReducer); const calendarStore = useAppSelector((state) => state.calendarReducer);
const { events, selectedCalendars, loading, error } = calendarStore; const { events, loading, error } = calendarStore;
useEffect(() => { useEffect(() => {
dispatch(fetchEvents()); dispatch(fetchEvents(farmUuid ? { farm_uuid: farmUuid } : undefined));
}, [dispatch]); }, [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 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], [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( const todayCount = useMemo(
() => eventCards.filter((event) => isToday(event.start)).length, () => eventCards.filter((event) => isToday(event.start)).length,
@@ -393,27 +445,25 @@ const FarmerCalendarPage = () => {
.slice(0, 5), .slice(0, 5),
[eventCards], [eventCards],
); );
const categoryCounts = useMemo( const tagCounts = useMemo(
() => () =>
allEventCards.reduce( tagOptions.reduce<Record<string, number>>((accumulator, tag) => {
(accumulator, event) => { accumulator[tag.value] = baseEvents.filter((event) => {
accumulator[event.category] += 1; const eventTags = Array.isArray(event.extendedProps?.tags)
? event.extendedProps.tags
: [];
return accumulator; return eventTags.some(
}, (item) => typeof item === "string" && item.trim() === tag.value,
{ );
Personal: 0, }).length;
Business: 0,
Family: 0, return accumulator;
Holiday: 0, }, {}),
ETC: 0, [baseEvents, tagOptions],
} as Record<CalendarFiltersType, number>,
),
[allEventCards],
); );
const selectedCount = selectedCalendars.length; const allTagsSelected = selectedTags.length === 0;
const allSelected = selectedCount === Object.keys(calendarsColor).length;
const handleLeftSidebarToggle = () => const handleLeftSidebarToggle = () =>
setLeftSidebarOpen((previous) => !previous); setLeftSidebarOpen((previous) => !previous);
@@ -433,6 +483,13 @@ const FarmerCalendarPage = () => {
setEventDetailsOpen(false); setEventDetailsOpen(false);
setEventModalOpen(true); setEventModalOpen(true);
}; };
const toggleTagFilter = (value: string) => {
setSelectedTags((current) =>
current.includes(value)
? current.filter((item) => item !== value)
: [...current, value],
);
};
const insightCards = [ const insightCards = [
{ {
@@ -595,55 +652,65 @@ const FarmerCalendarPage = () => {
</Box> </Box>
<Chip <Chip
label={ label={
allSelected allTagsSelected
? t("sidebar.focusLanes.allVisible") ? t("sidebar.focusLanes.allVisible")
: t("sidebar.focusLanes.activeCount", { : t("sidebar.focusLanes.activeCount", {
count: selectedCount, count: selectedTags.length,
}) })
} }
color={allSelected ? "success" : "default"} color={allTagsSelected ? "success" : "default"}
variant={allSelected ? "filled" : "outlined"} variant={allTagsSelected ? "filled" : "outlined"}
size="small" size="small"
/> />
</Box> </Box>
<Stack direction="row" flexWrap="wrap" gap={1.2}> <Stack direction="row" flexWrap="wrap" gap={1.2}>
<Chip <Chip
label={ label={
allSelected allTagsSelected
? t("sidebar.focusLanes.clearFilters") ? t("sidebar.focusLanes.clearFilters")
: t("sidebar.focusLanes.viewAll") : t("sidebar.focusLanes.viewAll")
} }
clickable clickable
color={allSelected ? "success" : "default"} color={allTagsSelected ? "success" : "default"}
variant={allSelected ? "filled" : "outlined"} variant={allTagsSelected ? "filled" : "outlined"}
onClick={() => dispatch(filterAllCalendarLabels(!allSelected))} onClick={() => setSelectedTags([])}
/> />
{(Object.keys(categoryDetails) as CalendarFiltersType[]).map( {tagOptions.map((tag, index) => {
(key) => { const paletteSequence: PaletteAccent[] = [
const detail = categoryDetails[key]; "error",
const selected = selectedCalendars.includes(key); "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 ( return (
<Chip <Chip
key={key} key={tag.id}
icon={<i className={detail.icon} />} icon={<i className={icon} />}
label={t("sidebar.focusLanes.categoryCount", { label={`${tag.label} (${tagCounts[tag.value] ?? 0})`}
label: t(detail.labelKey), clickable
count: categoryCounts[key], color={accent}
})} variant={selected ? "filled" : "outlined"}
clickable onClick={() => toggleTagFilter(tag.value)}
color={detail.accent} sx={{
variant={selected ? "filled" : "outlined"} "& .MuiChip-icon": {
onClick={() => dispatch(filterCalendarLabel(key))} fontSize: "1rem",
sx={{ },
"& .MuiChip-icon": { }}
fontSize: "1rem", />
}, );
}} })}
/>
);
},
)}
</Stack> </Stack>
</Stack> </Stack>
</Card> </Card>
@@ -908,26 +975,18 @@ const FarmerCalendarPage = () => {
}} }}
/> />
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{(Object.keys(categoryDetails) as CalendarFiltersType[]).map( {tagOptions.map((tag) => (
(key) => ( <Chip
<Chip key={tag.id}
key={key} size="small"
size="small" label={`${tag.label}: ${tagCounts[tag.value] ?? 0}`}
label={t("hero.categoryCount", { sx={{
label: t(categoryDetails[key].labelKey), color: "common.white",
count: categoryCounts[key], backgroundColor: alpha(theme.palette.common.white, 0.12),
})} border: `1px solid ${alpha(theme.palette.common.white, 0.14)}`,
sx={{ }}
color: "common.white", />
backgroundColor: alpha( ))}
theme.palette.common.white,
0.12,
),
border: `1px solid ${alpha(theme.palette.common.white, 0.14)}`,
}}
/>
),
)}
</Stack> </Stack>
</Box> </Box>
</Grid> </Grid>
@@ -994,7 +1053,10 @@ const FarmerCalendarPage = () => {
<Calendar <Calendar
dispatch={dispatch} dispatch={dispatch}
calendarApi={calendarApi} calendarApi={calendarApi}
calendarStore={calendarStore} calendarStore={{
...calendarStore,
events: tagFilteredEvents,
}}
setCalendarApi={setCalendarApi} setCalendarApi={setCalendarApi}
calendarsColor={calendarsColor} calendarsColor={calendarsColor}
handleLeftSidebarToggle={handleLeftSidebarToggle} handleLeftSidebarToggle={handleLeftSidebarToggle}
@@ -107,6 +107,11 @@ type ParserConfig = {
getItemCount: (plan: unknown) => number; getItemCount: (plan: unknown) => number;
}; };
type FertilizationPlanParserPageProps = {
initialTab?: ParserTabKey;
enabledTabs?: ParserTabKey[];
};
const createInitialTabState = (): TabState => ({ const createInitialTabState = (): TabState => ({
message: "", message: "",
response: null, response: null,
@@ -551,12 +556,22 @@ const PARSER_CONFIGS: Record<ParserTabKey, ParserConfig> = {
}, },
}; };
const FertilizationPlanParserPage = () => { const FertilizationPlanParserPage = ({
initialTab = "fertilization",
enabledTabs = ["fertilization", "irrigation"],
}: FertilizationPlanParserPageProps) => {
const theme = useTheme(); const theme = useTheme();
const { farmHub } = useFarmHub(); const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid; 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<ParserTabKey>("fertilization"); const [activeTab, setActiveTab] = useState<ParserTabKey>(resolvedInitialTab);
const [tabStates, setTabStates] = useState<Record<ParserTabKey, TabState>>({ const [tabStates, setTabStates] = useState<Record<ParserTabKey, TabState>>({
fertilization: createInitialTabState(), fertilization: createInitialTabState(),
irrigation: createInitialTabState(), irrigation: createInitialTabState(),
@@ -907,11 +922,14 @@ const FertilizationPlanParserPage = () => {
variant="h5" variant="h5"
sx={{ fontWeight: 800, mb: 1.5 }} sx={{ fontWeight: 800, mb: 1.5 }}
> >
دو جریان هوشمند، یک صفحه واحد {singleTabMode
? `جریان هوشمند ${config.label}`
: "دو جریان هوشمند، یک صفحه واحد"}
</Typography> </Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با {singleTabMode
همان flow سوال های تکمیلی تا JSON نهایی پیش ببر. ? `ورودی متنی برنامه ${config.label} را بفرست، ابهام ها را کامل کن و خروجی JSON نهایی بگیر.`
: "بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با همان flow سوال های تکمیلی تا JSON نهایی پیش ببر."}
</Typography> </Typography>
</Box> </Box>
<Stepper <Stepper
@@ -948,39 +966,45 @@ const FertilizationPlanParserPage = () => {
<CardContent sx={{ p: { xs: 4, md: 5 } }}> <CardContent sx={{ p: { xs: 4, md: 5 } }}>
<Stack spacing={4}> <Stack spacing={4}>
<Box> <Box>
<Tabs {!singleTabMode ? (
value={activeTab} <Tabs
onChange={(_, value: ParserTabKey) => setActiveTab(value)} value={activeTab}
variant="fullWidth" onChange={(_, value: ParserTabKey) => setActiveTab(value)}
sx={{ variant="fullWidth"
p: 1, sx={{
borderRadius: 4, p: 1,
backgroundColor: alpha(theme.palette.primary.main, 0.05), borderRadius: 4,
minHeight: 64, backgroundColor: alpha(theme.palette.primary.main, 0.05),
"& .MuiTabs-indicator": { display: "none" }, minHeight: 64,
}} "& .MuiTabs-indicator": { display: "none" },
> }}
{Object.values(PARSER_CONFIGS).map((tabConfig) => ( >
<Tab {availableTabs.map((tabKey) => {
key={tabConfig.key} const tabConfig = PARSER_CONFIGS[tabKey];
value={tabConfig.key}
icon={<i className={tabConfig.icon} />} return (
iconPosition="start" <Tab
label={tabConfig.label} key={tabConfig.key}
sx={{ value={tabConfig.key}
minHeight: 54, icon={<i className={tabConfig.icon} />}
borderRadius: 3, iconPosition="start"
fontWeight: 700, label={tabConfig.label}
transition: "all 0.2s ease", sx={{
"&.Mui-selected": { minHeight: 54,
color: theme.palette.common.white, borderRadius: 3,
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.info.main} 100%)`, fontWeight: 700,
boxShadow: `0 14px 32px ${alpha(theme.palette.primary.main, 0.24)}`, 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)}`,
</Tabs> },
}}
/>
);
})}
</Tabs>
) : null}
</Box> </Box>
<Box <Box
@@ -0,0 +1,310 @@
"use client";
import type { Theme } from "@mui/material/styles";
import { alpha, useTheme } from "@mui/material/styles";
import { useMemo } from "react";
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 Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import Divider from "@mui/material/Divider";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import Radio from "@mui/material/Radio";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
export type RelatedPlanItem = {
id: number;
title: string;
finalProduct: string;
harvestTime: string;
outputTon: number;
methodLabel: string;
status: "active" | "draft";
};
type SummaryItem = {
label: string;
value: string;
};
type RelatedPlanSelectorProps = {
open: boolean;
onClose: () => 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 (
<Stack spacing={4}>
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
<Box>
<Typography variant="h4" sx={{ mb: 1 }}>
{title}
</Typography>
<Typography color="text.secondary">{description}</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<i className="tabler-x text-[22px]" />
</IconButton>
</Stack>
<Card
variant="outlined"
sx={{
borderRadius: 4,
borderColor: alpha(theme.palette.primary.main, 0.22),
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.12)}, ${alpha(theme.palette.success.main, 0.08)})`,
}}
>
<CardContent>
<Stack spacing={2.5}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5} justifyContent="space-between">
<Box>
<Typography variant="h6">{currentPlanName}</Typography>
<Typography color="text.secondary">{currentPlanOutput}</Typography>
</Box>
<Chip color="success" variant="tonal" label={currentPlanStatus} />
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} useFlexGap flexWrap="wrap">
{summaryItems.map((item) => (
<Box
key={item.label}
sx={{
minWidth: 160,
p: 2,
borderRadius: 3,
backgroundColor: alpha(theme.palette.background.paper, 0.7),
border: `1px solid ${alpha(theme.palette.divider, 0.8)}`,
}}
>
<Typography variant="body2" color="text.secondary">
{item.label}
</Typography>
<Typography variant="subtitle1" fontWeight={700}>
{item.value}
</Typography>
</Box>
))}
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
<Stack spacing={1}>
<Typography variant="h6">{relatedTitle}</Typography>
<Typography color="text.secondary">{relatedDescription}</Typography>
</Stack>
<Stack spacing={2}>
{relatedPlans.map((plan) => {
const isSelected = plan.id === selectedRelatedPlanId;
return (
<Card
key={plan.id}
variant="outlined"
onClick={() => 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],
},
}}
>
<CardContent>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Radio checked={isSelected} value={plan.id} />
<Stack spacing={1.5} sx={{ flexGrow: 1 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
justifyContent="space-between"
>
<Box>
<Typography variant="h6">{plan.title}</Typography>
<Typography color="text.secondary">{plan.finalProduct}</Typography>
</Box>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip
size="small"
label={`${plan.outputTon} تن`}
color="primary"
variant="tonal"
/>
<Chip
size="small"
label={plan.status === "active" ? "فعال" : "پیش‌نویس"}
color={plan.status === "active" ? "success" : "default"}
variant={plan.status === "active" ? "filled" : "tonal"}
/>
</Stack>
</Stack>
<Divider />
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} useFlexGap flexWrap="wrap">
<Chip label={`زمان برداشت: ${plan.harvestTime}`} variant="tonal" />
<Chip label={plan.methodLabel} variant="tonal" />
</Stack>
</Stack>
</Stack>
</CardContent>
</Card>
);
})}
</Stack>
<Card
variant="outlined"
sx={{
borderRadius: 4,
borderStyle: "dashed",
backgroundColor: alpha(theme.palette.success.main, 0.05),
}}
>
<CardContent>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} alignItems={{ xs: "stretch", md: "center" }} justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={700}>
{selectedPlan ? `انتخاب شد: ${selectedPlan.title}` : "یک برنامه مرتبط انتخاب کنید"}
</Typography>
<Typography color="text.secondary">
{selectedPlan
? `محصول نهایی ${selectedPlan.finalProduct} با برداشت ${selectedPlan.harvestTime}`
: "برای ادامه، یکی از برنامه‌های لیست بالا را انتخاب کنید."}
</Typography>
</Box>
<Button
variant="contained"
color="success"
size="large"
startIcon={<i className="tabler-check text-[20px]" />}
disabled={!selectedPlan}
onClick={() => {
if (selectedPlan) {
onConfirm(selectedPlan);
}
}}
>
تایید انتخاب
</Button>
</Stack>
</CardContent>
</Card>
</Stack>
);
};
const RelatedPlanSelector = (props: RelatedPlanSelectorProps) => {
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm"));
if (isMobile) {
return (
<Drawer
anchor="bottom"
open={props.open}
onClose={props.onClose}
ModalProps={{ keepMounted: true }}
slotProps={{ backdrop: { sx: backdropBlurSx } }}
PaperProps={{
sx: {
height: "88vh",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
}}
>
<Box sx={{ px: 2.5, pt: 1.5, pb: 0.5, display: "flex", justifyContent: "center" }}>
<Box
sx={{
width: 54,
height: 6,
borderRadius: 999,
backgroundColor: "divider",
}}
/>
</Box>
<Box sx={{ p: 3, overflowY: "auto" }}>
<RelatedPlanSelectorContent {...props} />
</Box>
</Drawer>
);
}
return (
<Dialog
fullWidth
maxWidth="md"
open={props.open}
onClose={props.onClose}
slotProps={{ backdrop: { sx: backdropBlurSx } }}
PaperProps={{ sx: { borderRadius: 6 } }}
>
<DialogContent sx={{ p: 4 }}>
<RelatedPlanSelectorContent {...props} />
</DialogContent>
</Dialog>
);
};
export default RelatedPlanSelector;
+195 -110
View File
@@ -1,7 +1,8 @@
"use client"; "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 Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; 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 CustomAvatar from "@core/components/mui/Avatar";
import CustomTextField from "@core/components/mui/TextField"; import CustomTextField from "@core/components/mui/TextField";
import HorizontalWithAvatar from "@components/card-statistics/HorizontalWithAvatar"; 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 TaskSegment = "همه" | "امروز" | "فوری" | "انجام شده";
type FarmerTask = { const priorityMeta: Record<
id: number; FarmerTodoPriority,
title: string; { color: ThemeColor; icon: 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<TaskPriority, { color: ThemeColor; icon: string }> =
{ {
زیاد: { color: "error", icon: "tabler-alert-triangle" }, زیاد: { color: "error", icon: "tabler-alert-triangle" },
متوسط: { color: "warning", icon: "tabler-sun-high" }, متوسط: { color: "warning", icon: "tabler-sun-high" },
کم: { color: "success", icon: "tabler-leaf" }, کم: { color: "success", icon: "tabler-leaf" },
}; };
const getPriorityMeta = (priority: string) =>
priorityMeta[priority as FarmerTodoPriority] ?? priorityMeta["متوسط"];
const segments: TaskSegment[] = ["همه", "امروز", "فوری", "انجام شده"]; 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 FarmerTodoPage = () => {
const theme = useTheme(); const theme = useTheme();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
const [tasks, setTasks] = useState(initialTasks); const [tasks, setTasks] = useState<FarmerTodoTask[]>([]);
const [summary, setSummary] = useState<FarmerTodoSummary>(defaultSummary);
const [zones, setZones] = useState<FarmerTodoZoneOption[]>([]);
const [segment, setSegment] = useState<TaskSegment>("همه"); const [segment, setSegment] = useState<TaskSegment>("همه");
const [draftTitle, setDraftTitle] = useState(""); const [draftTitle, setDraftTitle] = useState("");
const [draftZone, setDraftZone] = useState("قطعه گندم - شمال مزرعه"); const [draftZone, setDraftZone] = useState("");
const [draftTime, setDraftTime] = useState("07:00"); const [draftTime, setDraftTime] = useState("07:00");
const [draftPriority, setDraftPriority] = useState<TaskPriority>("متوسط"); const [draftPriority, setDraftPriority] =
useState<FarmerTodoPriority>("متوسط");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const completedCount = useMemo( const completedCount = summary.completedCount;
() => tasks.filter((task) => task.status === "done").length, const openCount = useMemo(
[tasks],
);
const openCount = tasks.length - completedCount;
const urgentCount = useMemo(
() => () =>
tasks.filter((task) => task.priority === "زیاد" && task.status === "open") tasks.filter(
.length, (task) => task.scheduledDate === todayDate() && task.status === "open",
).length,
[tasks], [tasks],
); );
const progressValue = const urgentCount = summary.urgentCount;
tasks.length === 0 ? 0 : Math.round((completedCount / tasks.length) * 100); 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(() => { const filteredTasks = useMemo(() => {
switch (segment) { switch (segment) {
@@ -125,7 +146,7 @@ const FarmerTodoPage = () => {
case "انجام شده": case "انجام شده":
return tasks.filter((task) => task.status === "done"); return tasks.filter((task) => task.status === "done");
case "امروز": case "امروز":
return tasks; return tasks.filter((task) => task.scheduledDate === todayDate());
default: default:
return tasks; return tasks;
} }
@@ -152,36 +173,61 @@ const FarmerTodoPage = () => {
}, },
]; ];
const toggleTask = (taskId: number) => { const toggleTask = async (task: FarmerTodoTask) => {
setTasks((currentTasks) => if (!farmUuid) return;
currentTasks.map((task) =>
task.id === taskId const nextStatus: FarmerTodoStatus =
? { ...task, status: task.status === "done" ? "open" : "done" } task.status === "done" ? "open" : "done";
: task,
), 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 = () => { const addTask = async () => {
if (!draftTitle.trim()) return; if (!farmUuid || !draftTitle.trim() || !draftZone.trim()) return;
setTasks((currentTasks) => [ setSubmitting(true);
{ setError(null);
id: Date.now(),
try {
const createdTask = await farmerTodoService.createTask({
farm_uuid: farmUuid,
title: draftTitle.trim(), title: draftTitle.trim(),
zone: draftZone, zone: draftZone.trim(),
scheduledDate: todayDate(),
time: draftTime, time: draftTime,
priority: draftPriority, priority: draftPriority,
note: "بعد از ثبت انجام، اگر موردی غیرعادی بود برای شیفت بعدی یادداشت بگذار.", note: "بعد از ثبت انجام، اگر موردی غیرعادی بود برای شیفت بعدی یادداشت بگذار.",
tags: [draftPriority === "زیاد" ? "فوری" : "روزانه", "ثبت دستی"], tags: [draftPriority === "زیاد" ? "فوری" : "روزانه", "ثبت دستی"],
status: "open", status: "open",
}, });
...currentTasks,
]);
setDraftTitle(""); setTasks((currentTasks) => [createdTask, ...currentTasks]);
setDraftTime("07:00"); await refreshSummary();
setDraftPriority("متوسط"); setDraftTitle("");
setDraftTime("07:00");
setDraftPriority("متوسط");
} catch (apiError: any) {
setError(apiError?.message || "ایجاد تسک جدید انجام نشد.");
} finally {
setSubmitting(false);
}
}; };
return ( return (
@@ -275,7 +321,9 @@ const FarmerTodoPage = () => {
<Typography <Typography
sx={{ color: "common.white", fontWeight: 700 }} sx={{ color: "common.white", fontWeight: 700 }}
> >
بازدید 06:30 از ردیف شمالی {nextTask
? `${nextTask.time} - ${nextTask.title}`
: "هنوز کاری برای امروز ثبت نشده"}
</Typography> </Typography>
</Box> </Box>
<Box <Box
@@ -299,7 +347,9 @@ const FarmerTodoPage = () => {
<Typography <Typography
sx={{ color: "common.white", fontWeight: 700 }} sx={{ color: "common.white", fontWeight: 700 }}
> >
آبیاری، آفت و هماهنگی بار خروجی {nextTask?.tags?.length
? nextTask.tags.join("، ")
: "آبیاری، آفت و هماهنگی بار خروجی"}
</Typography> </Typography>
</Box> </Box>
</div> </div>
@@ -421,6 +471,12 @@ const FarmerTodoPage = () => {
} }
/> />
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{!farmUuid ? (
<Alert severity="warning">
برای مشاهده و ثبت کارها، ابتدا یک مزرعه فعال انتخاب کن.
</Alert>
) : null}
{error ? <Alert severity="error">{error}</Alert> : null}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{segments.map((item) => ( {segments.map((item) => (
<Chip <Chip
@@ -434,8 +490,18 @@ const FarmerTodoPage = () => {
))} ))}
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{loading ? (
<Box className="flex items-center justify-center py-12">
<CircularProgress size={32} />
</Box>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Alert severity="info">
هنوز کاری برای این بخش ثبت نشده است.
</Alert>
) : null}
{filteredTasks.map((task) => { {filteredTasks.map((task) => {
const meta = priorityMeta[task.priority]; const meta = getPriorityMeta(task.priority);
return ( return (
<Box <Box
@@ -455,7 +521,8 @@ const FarmerTodoPage = () => {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
checked={task.status === "done"} checked={task.status === "done"}
onChange={() => toggleTask(task.id)} disabled={togglingId === task.id}
onChange={() => void toggleTask(task)}
sx={{ mt: -0.5 }} sx={{ mt: -0.5 }}
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -518,6 +585,9 @@ const FarmerTodoPage = () => {
<Typography variant="body2">{task.zone}</Typography> <Typography variant="body2">{task.zone}</Typography>
</div> </div>
</div> </div>
<Typography variant="caption" color="text.disabled">
{task.scheduledDate}
</Typography>
<Typography variant="body2"> <Typography variant="body2">
{task.status === "done" {task.status === "done"
? "انجام شده و ثبت شده" ? "انجام شده و ثبت شده"
@@ -549,21 +619,28 @@ const FarmerTodoPage = () => {
onChange={(event) => setDraftTitle(event.target.value)} onChange={(event) => setDraftTitle(event.target.value)}
/> />
<CustomTextField <CustomTextField
select
fullWidth fullWidth
label="محل اجرا" label="محل اجرا"
placeholder="مثلا انبار مرکزی"
value={draftZone} value={draftZone}
onChange={(event) => setDraftZone(event.target.value)} onChange={(event) => setDraftZone(event.target.value)}
> disabled={!farmUuid || submitting}
<MenuItem value="قطعه گندم - شمال مزرعه"> />
قطعه گندم - شمال مزرعه {zones.length > 0 ? (
</MenuItem> <div className="flex flex-wrap gap-2">
<MenuItem value="گلخانه شماره 2">گلخانه شماره 2</MenuItem> {zones.slice(0, 6).map((zone) => (
<MenuItem value="انبار مرکزی">انبار مرکزی</MenuItem> <Chip
<MenuItem value="باغچه آزمایشی غربی"> key={zone.id}
باغچه آزمایشی غربی clickable
</MenuItem> label={zone.label}
</CustomTextField> size="small"
variant={draftZone === zone.value ? "filled" : "tonal"}
color={draftZone === zone.value ? "primary" : "default"}
onClick={() => setDraftZone(zone.value)}
/>
))}
</div>
) : null}
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={6}> <Grid size={6}>
<CustomTextField <CustomTextField
@@ -571,6 +648,7 @@ const FarmerTodoPage = () => {
label="ساعت" label="ساعت"
value={draftTime} value={draftTime}
onChange={(event) => setDraftTime(event.target.value)} onChange={(event) => setDraftTime(event.target.value)}
disabled={!farmUuid || submitting}
/> />
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
@@ -580,8 +658,9 @@ const FarmerTodoPage = () => {
label="اولویت" label="اولویت"
value={draftPriority} value={draftPriority}
onChange={(event) => onChange={(event) =>
setDraftPriority(event.target.value as TaskPriority) setDraftPriority(event.target.value as FarmerTodoPriority)
} }
disabled={!farmUuid || submitting}
> >
<MenuItem value="زیاد">زیاد</MenuItem> <MenuItem value="زیاد">زیاد</MenuItem>
<MenuItem value="متوسط">متوسط</MenuItem> <MenuItem value="متوسط">متوسط</MenuItem>
@@ -593,9 +672,15 @@ const FarmerTodoPage = () => {
variant="contained" variant="contained"
size="large" size="large"
startIcon={<i className="tabler-plus text-lg" />} startIcon={<i className="tabler-plus text-lg" />}
onClick={addTask} disabled={
!farmUuid ||
submitting ||
!draftTitle.trim() ||
!draftZone.trim()
}
onClick={() => void addTask()}
> >
ثبت در لیست امروز {submitting ? "در حال ثبت..." : "ثبت در لیست امروز"}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>