UPDATE
This commit is contained in:
@@ -45,6 +45,7 @@
|
||||
"irrigationRecommendation": "توصیه آبیاری",
|
||||
"fertilizationRecommendation": "توصیه کوددهی",
|
||||
"fertilizationPlanParser": "برنامه آبیاری و کودهی",
|
||||
"irrigationPlanParser": "برنامه آبیاری",
|
||||
"aiAssistant": "دستیار هوشمند",
|
||||
"farmAiAssistant": "دستیار هوشمند مزرعه",
|
||||
"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 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 <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;
|
||||
|
||||
@@ -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 = () => {
|
||||
return <PlantProductionPage />
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default YieldHarvestPage
|
||||
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 label={t("recommendation")}>
|
||||
<MenuItem
|
||||
href="/irrigation-plan"
|
||||
icon={<i className="tabler-droplet-half-2" />}
|
||||
>
|
||||
{t("irrigationPlanParser")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
href="/irrigation-recommendation"
|
||||
icon={<i className="tabler-droplet-half-2" />}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const navigationLabels = {
|
||||
sensor7In1: 'سنسور خاک 7 در 1',
|
||||
dataSection: 'بخش دادهها',
|
||||
recommendation: 'توصیهها',
|
||||
irrigationPlanParser: 'برنامه آبیاری',
|
||||
irrigationRecommendation: 'توصیه آبیاری',
|
||||
fertilizationRecommendation: 'توصیه کوددهی',
|
||||
aiAssistant: 'دستیار هوشمند',
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"sensorSection": "سنسورها",
|
||||
"sensor7In1": "سنسور خاک 7 در 1",
|
||||
"recommendation": "توصیهها",
|
||||
"irrigationPlanParser": "برنامه آبیاری",
|
||||
"irrigationRecommendation": "توصیه آبیاری",
|
||||
"fertilizationRecommendation": "توصیه کوددهی",
|
||||
"aiAssistant": "دستیار هوشمند",
|
||||
|
||||
@@ -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<string, any>
|
||||
}
|
||||
|
||||
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<string, any>
|
||||
}
|
||||
|
||||
@@ -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<string, any>
|
||||
}
|
||||
|
||||
@@ -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<Event> {
|
||||
const response = await apiClient.post<CreateEventResponse>('/api/events', data)
|
||||
const response = await apiClient.post<CreateEventResponse>('/api/events/', data)
|
||||
return response.event
|
||||
},
|
||||
|
||||
@@ -101,7 +108,7 @@ export const eventService = {
|
||||
* Get an event by ID
|
||||
*/
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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<ListEventsResponse>(endpoint)
|
||||
return response.events || []
|
||||
@@ -126,7 +133,7 @@ export const eventService = {
|
||||
*/
|
||||
async updateEvent(data: UpdateEventRequest): Promise<Event> {
|
||||
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
|
||||
},
|
||||
|
||||
@@ -134,10 +141,21 @@ export const eventService = {
|
||||
* Delete an event
|
||||
*/
|
||||
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
|
||||
},
|
||||
|
||||
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)
|
||||
*/
|
||||
@@ -146,4 +164,3 @@ export const eventService = {
|
||||
return response.tasks || []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<AnyAction>;
|
||||
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
|
||||
|
||||
@@ -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<DefaultStateType>(defaultState);
|
||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [tagLoading, setTagLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(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,
|
||||
extendedProps: {
|
||||
calendar:
|
||||
calendarStore.selectedEvent?.extendedProps?.calendar || "Business",
|
||||
description: values.description.length ? values.description : undefined,
|
||||
tags: values.tag.trim().length ? [values.tag.trim()] : [],
|
||||
start: values.startDate.toISOString(),
|
||||
end: values.endDate.toISOString(),
|
||||
extendedProps: {
|
||||
...(calendarStore.selectedEvent?.extendedProps || {}),
|
||||
},
|
||||
};
|
||||
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (
|
||||
calendarStore.selectedEvent === null ||
|
||||
(calendarStore.selectedEvent !== null &&
|
||||
!calendarStore.selectedEvent.title.length)
|
||||
) {
|
||||
dispatch(addEvent(modifiedEvent));
|
||||
await dispatch(createEventAsync(payload)).unwrap();
|
||||
} else {
|
||||
dispatch(
|
||||
updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }),
|
||||
);
|
||||
await dispatch(
|
||||
updateEventAsync({
|
||||
id: calendarStore.selectedEvent.id,
|
||||
data: payload,
|
||||
}),
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
dispatch(filterEvents());
|
||||
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 ? (
|
||||
<Typography color="error" variant="body2">
|
||||
{submitError}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
@@ -385,6 +411,7 @@ const FarmerCalendarEventModal = ({
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleDeleteButtonClick}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
@@ -392,7 +419,7 @@ const FarmerCalendarEventModal = ({
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="contained">
|
||||
<Button type="submit" variant="contained" disabled={submitting}>
|
||||
{isEditMode ? t("actions.update") : t("actions.create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -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 | any>(null);
|
||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
|
||||
const [eventModalOpen, setEventModalOpen] = useState(false);
|
||||
const [eventDetailsOpen, setEventDetailsOpen] = useState(false);
|
||||
const [tagOptions, setTagOptions] = useState<CalendarTagChip[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
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<Record<string, number>>((accumulator, tag) => {
|
||||
accumulator[tag.value] = baseEvents.filter((event) => {
|
||||
const eventTags = Array.isArray(event.extendedProps?.tags)
|
||||
? event.extendedProps.tags
|
||||
: [];
|
||||
|
||||
return eventTags.some(
|
||||
(item) => typeof item === "string" && item.trim() === tag.value,
|
||||
);
|
||||
}).length;
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
Personal: 0,
|
||||
Business: 0,
|
||||
Family: 0,
|
||||
Holiday: 0,
|
||||
ETC: 0,
|
||||
} as Record<CalendarFiltersType, number>,
|
||||
),
|
||||
[allEventCards],
|
||||
}, {}),
|
||||
[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,46 +652,57 @@ const FarmerCalendarPage = () => {
|
||||
</Box>
|
||||
<Chip
|
||||
label={
|
||||
allSelected
|
||||
allTagsSelected
|
||||
? t("sidebar.focusLanes.allVisible")
|
||||
: t("sidebar.focusLanes.activeCount", {
|
||||
count: selectedCount,
|
||||
count: selectedTags.length,
|
||||
})
|
||||
}
|
||||
color={allSelected ? "success" : "default"}
|
||||
variant={allSelected ? "filled" : "outlined"}
|
||||
color={allTagsSelected ? "success" : "default"}
|
||||
variant={allTagsSelected ? "filled" : "outlined"}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" flexWrap="wrap" gap={1.2}>
|
||||
<Chip
|
||||
label={
|
||||
allSelected
|
||||
allTagsSelected
|
||||
? t("sidebar.focusLanes.clearFilters")
|
||||
: t("sidebar.focusLanes.viewAll")
|
||||
}
|
||||
clickable
|
||||
color={allSelected ? "success" : "default"}
|
||||
variant={allSelected ? "filled" : "outlined"}
|
||||
onClick={() => 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 (
|
||||
<Chip
|
||||
key={key}
|
||||
icon={<i className={detail.icon} />}
|
||||
label={t("sidebar.focusLanes.categoryCount", {
|
||||
label: t(detail.labelKey),
|
||||
count: categoryCounts[key],
|
||||
})}
|
||||
key={tag.id}
|
||||
icon={<i className={icon} />}
|
||||
label={`${tag.label} (${tagCounts[tag.value] ?? 0})`}
|
||||
clickable
|
||||
color={detail.accent}
|
||||
color={accent}
|
||||
variant={selected ? "filled" : "outlined"}
|
||||
onClick={() => dispatch(filterCalendarLabel(key))}
|
||||
onClick={() => toggleTagFilter(tag.value)}
|
||||
sx={{
|
||||
"& .MuiChip-icon": {
|
||||
fontSize: "1rem",
|
||||
@@ -642,8 +710,7 @@ const FarmerCalendarPage = () => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -908,26 +975,18 @@ const FarmerCalendarPage = () => {
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{(Object.keys(categoryDetails) as CalendarFiltersType[]).map(
|
||||
(key) => (
|
||||
{tagOptions.map((tag) => (
|
||||
<Chip
|
||||
key={key}
|
||||
key={tag.id}
|
||||
size="small"
|
||||
label={t("hero.categoryCount", {
|
||||
label: t(categoryDetails[key].labelKey),
|
||||
count: categoryCounts[key],
|
||||
})}
|
||||
label={`${tag.label}: ${tagCounts[tag.value] ?? 0}`}
|
||||
sx={{
|
||||
color: "common.white",
|
||||
backgroundColor: alpha(
|
||||
theme.palette.common.white,
|
||||
0.12,
|
||||
),
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.12),
|
||||
border: `1px solid ${alpha(theme.palette.common.white, 0.14)}`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -994,7 +1053,10 @@ const FarmerCalendarPage = () => {
|
||||
<Calendar
|
||||
dispatch={dispatch}
|
||||
calendarApi={calendarApi}
|
||||
calendarStore={calendarStore}
|
||||
calendarStore={{
|
||||
...calendarStore,
|
||||
events: tagFilteredEvents,
|
||||
}}
|
||||
setCalendarApi={setCalendarApi}
|
||||
calendarsColor={calendarsColor}
|
||||
handleLeftSidebarToggle={handleLeftSidebarToggle}
|
||||
|
||||
@@ -107,6 +107,11 @@ type ParserConfig = {
|
||||
getItemCount: (plan: unknown) => number;
|
||||
};
|
||||
|
||||
type FertilizationPlanParserPageProps = {
|
||||
initialTab?: ParserTabKey;
|
||||
enabledTabs?: ParserTabKey[];
|
||||
};
|
||||
|
||||
const createInitialTabState = (): TabState => ({
|
||||
message: "",
|
||||
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 { 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<ParserTabKey>("fertilization");
|
||||
const [activeTab, setActiveTab] = useState<ParserTabKey>(resolvedInitialTab);
|
||||
const [tabStates, setTabStates] = useState<Record<ParserTabKey, TabState>>({
|
||||
fertilization: createInitialTabState(),
|
||||
irrigation: createInitialTabState(),
|
||||
@@ -907,11 +922,14 @@ const FertilizationPlanParserPage = () => {
|
||||
variant="h5"
|
||||
sx={{ fontWeight: 800, mb: 1.5 }}
|
||||
>
|
||||
دو جریان هوشمند، یک صفحه واحد
|
||||
{singleTabMode
|
||||
? `جریان هوشمند ${config.label}`
|
||||
: "دو جریان هوشمند، یک صفحه واحد"}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با
|
||||
همان flow سوال های تکمیلی تا JSON نهایی پیش ببر.
|
||||
{singleTabMode
|
||||
? `ورودی متنی برنامه ${config.label} را بفرست، ابهام ها را کامل کن و خروجی JSON نهایی بگیر.`
|
||||
: "بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با همان flow سوال های تکمیلی تا JSON نهایی پیش ببر."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stepper
|
||||
@@ -948,6 +966,7 @@ const FertilizationPlanParserPage = () => {
|
||||
<CardContent sx={{ p: { xs: 4, md: 5 } }}>
|
||||
<Stack spacing={4}>
|
||||
<Box>
|
||||
{!singleTabMode ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, value: ParserTabKey) => setActiveTab(value)}
|
||||
@@ -960,7 +979,10 @@ const FertilizationPlanParserPage = () => {
|
||||
"& .MuiTabs-indicator": { display: "none" },
|
||||
}}
|
||||
>
|
||||
{Object.values(PARSER_CONFIGS).map((tabConfig) => (
|
||||
{availableTabs.map((tabKey) => {
|
||||
const tabConfig = PARSER_CONFIGS[tabKey];
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tabConfig.key}
|
||||
value={tabConfig.key}
|
||||
@@ -979,8 +1001,10 @@ const FertilizationPlanParserPage = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
) : null}
|
||||
</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;
|
||||
@@ -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<TaskPriority, { color: ThemeColor; icon: string }> =
|
||||
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<FarmerTodoTask[]>([]);
|
||||
const [summary, setSummary] = useState<FarmerTodoSummary>(defaultSummary);
|
||||
const [zones, setZones] = useState<FarmerTodoZoneOption[]>([]);
|
||||
const [segment, setSegment] = useState<TaskSegment>("همه");
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftZone, setDraftZone] = useState("قطعه گندم - شمال مزرعه");
|
||||
const [draftZone, setDraftZone] = useState("");
|
||||
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(
|
||||
() => 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) => {
|
||||
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((task) =>
|
||||
task.id === taskId
|
||||
? { ...task, status: task.status === "done" ? "open" : "done" }
|
||||
: task,
|
||||
),
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
<Typography
|
||||
sx={{ color: "common.white", fontWeight: 700 }}
|
||||
>
|
||||
بازدید 06:30 از ردیف شمالی
|
||||
{nextTask
|
||||
? `${nextTask.time} - ${nextTask.title}`
|
||||
: "هنوز کاری برای امروز ثبت نشده"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -299,7 +347,9 @@ const FarmerTodoPage = () => {
|
||||
<Typography
|
||||
sx={{ color: "common.white", fontWeight: 700 }}
|
||||
>
|
||||
آبیاری، آفت و هماهنگی بار خروجی
|
||||
{nextTask?.tags?.length
|
||||
? nextTask.tags.join("، ")
|
||||
: "آبیاری، آفت و هماهنگی بار خروجی"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</div>
|
||||
@@ -421,6 +471,12 @@ const FarmerTodoPage = () => {
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
{segments.map((item) => (
|
||||
<Chip
|
||||
@@ -434,8 +490,18 @@ const FarmerTodoPage = () => {
|
||||
))}
|
||||
</div>
|
||||
<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) => {
|
||||
const meta = priorityMeta[task.priority];
|
||||
const meta = getPriorityMeta(task.priority);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -455,7 +521,8 @@ const FarmerTodoPage = () => {
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "done"}
|
||||
onChange={() => toggleTask(task.id)}
|
||||
disabled={togglingId === task.id}
|
||||
onChange={() => void toggleTask(task)}
|
||||
sx={{ mt: -0.5 }}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -518,6 +585,9 @@ const FarmerTodoPage = () => {
|
||||
<Typography variant="body2">{task.zone}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{task.scheduledDate}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{task.status === "done"
|
||||
? "انجام شده و ثبت شده"
|
||||
@@ -549,21 +619,28 @@ const FarmerTodoPage = () => {
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
/>
|
||||
<CustomTextField
|
||||
select
|
||||
fullWidth
|
||||
label="محل اجرا"
|
||||
placeholder="مثلا انبار مرکزی"
|
||||
value={draftZone}
|
||||
onChange={(event) => setDraftZone(event.target.value)}
|
||||
>
|
||||
<MenuItem value="قطعه گندم - شمال مزرعه">
|
||||
قطعه گندم - شمال مزرعه
|
||||
</MenuItem>
|
||||
<MenuItem value="گلخانه شماره 2">گلخانه شماره 2</MenuItem>
|
||||
<MenuItem value="انبار مرکزی">انبار مرکزی</MenuItem>
|
||||
<MenuItem value="باغچه آزمایشی غربی">
|
||||
باغچه آزمایشی غربی
|
||||
</MenuItem>
|
||||
</CustomTextField>
|
||||
disabled={!farmUuid || submitting}
|
||||
/>
|
||||
{zones.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zones.slice(0, 6).map((zone) => (
|
||||
<Chip
|
||||
key={zone.id}
|
||||
clickable
|
||||
label={zone.label}
|
||||
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 size={6}>
|
||||
<CustomTextField
|
||||
@@ -571,6 +648,7 @@ const FarmerTodoPage = () => {
|
||||
label="ساعت"
|
||||
value={draftTime}
|
||||
onChange={(event) => setDraftTime(event.target.value)}
|
||||
disabled={!farmUuid || submitting}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
@@ -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}
|
||||
>
|
||||
<MenuItem value="زیاد">زیاد</MenuItem>
|
||||
<MenuItem value="متوسط">متوسط</MenuItem>
|
||||
@@ -593,9 +672,15 @@ const FarmerTodoPage = () => {
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<i className="tabler-plus text-lg" />}
|
||||
onClick={addTask}
|
||||
disabled={
|
||||
!farmUuid ||
|
||||
submitting ||
|
||||
!draftTitle.trim() ||
|
||||
!draftZone.trim()
|
||||
}
|
||||
onClick={() => void addTask()}
|
||||
>
|
||||
ثبت در لیست امروز
|
||||
{submitting ? "در حال ثبت..." : "ثبت در لیست امروز"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user