Files
Frontend/src/views/dashboards/farm/fertilizationPlanParser/FertilizationPlanParserPage.tsx
T

1632 lines
61 KiB
TypeScript
Raw Normal View History

2026-04-30 04:00:19 +03:30
"use client";
import { 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";
import CardContent from "@mui/material/CardContent";
import Chip from "@mui/material/Chip";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid2";
import LinearProgress from "@mui/material/LinearProgress";
import Stack from "@mui/material/Stack";
import Step from "@mui/material/Step";
import StepLabel from "@mui/material/StepLabel";
import Stepper from "@mui/material/Stepper";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import type { ThemeColor } from "@core/types";
import HorizontalWithAvatar from "@/components/card-statistics/HorizontalWithAvatar";
import { useFarmHub } from "@/hooks/useFarmHub";
import {
fertilizationPlanParserService,
type FertilizationPlanAnswerValue,
type FertilizationPlanApplication,
type FertilizationPlanData,
type FertilizationPlanParserResult,
} from "@/libs/api/services/fertilizationPlanParserService";
import {
irrigationPlanParserService,
type IrrigationPlanAnswerValue,
type IrrigationPlanData,
type IrrigationPlanParserResult,
} from "@/libs/api/services/irrigationPlanParserService";
import OptionMenu from "@core/components/option-menu";
import CustomAvatar from "@core/components/mui/Avatar";
import CustomTextField from "@core/components/mui/TextField";
type ParserTabKey = "fertilization" | "irrigation";
type ParserAnswerValue =
| FertilizationPlanAnswerValue
| IrrigationPlanAnswerValue;
type ParserQuestion = {
id: string;
field: string;
question: string;
rationale: string;
};
type ParserResponse =
| FertilizationPlanParserResult
| IrrigationPlanParserResult;
type TabState = {
message: string;
response: ParserResponse | null;
answers: Record<string, string>;
activeQuestion: number;
requestError: string | null;
statusNote: string | null;
loading: boolean;
};
type FieldMeta = {
key: string;
label: string;
value: string | null;
};
type ParserConfig = {
key: ParserTabKey;
label: string;
badge: string;
icon: string;
heroTitle: string;
heroDescription: string;
panelTitle: string;
panelDescription: string;
inputLabel: string;
inputPlaceholder: string;
samplePrompts: string[];
fieldLabels: Record<string, string>;
previewOrder: string[];
itemCountTitle: string;
primaryCardTitle: string;
primaryFallbackTitle: string;
finalCardTitle: string;
finalCardDescription: string;
buildPayload: (payload: {
message?: string;
answers?: Record<string, ParserAnswerValue>;
partialPlan?: unknown;
farmUuid?: string;
}) => Record<string, unknown>;
submit: (payload: Record<string, unknown>) => Promise<ParserResponse>;
getFieldValue: (plan: unknown, field: string) => ParserAnswerValue;
formatFieldValue: (field: string, value: ParserAnswerValue) => string;
getPrimaryItem: (plan: unknown) => unknown | null;
getPrimaryMeta: (item: unknown) => FieldMeta[];
getPrimaryHeadline: (item: unknown) => string;
getItemCount: (plan: unknown) => number;
};
2026-05-02 06:23:34 +03:30
type FertilizationPlanParserPageProps = {
initialTab?: ParserTabKey;
enabledTabs?: ParserTabKey[];
};
2026-04-30 04:00:19 +03:30
const createInitialTabState = (): TabState => ({
message: "",
response: null,
answers: {},
activeQuestion: 0,
requestError: null,
statusNote: null,
loading: false,
});
const formatNumber = (value: number) =>
new Intl.NumberFormat("fa-IR").format(value);
const defaultFormatFieldValue = (value: ParserAnswerValue) => {
if (value === null || value === undefined || value === "") {
return "—";
}
if (typeof value === "number") {
return formatNumber(value);
}
if (typeof value === "boolean") {
return value ? "بله" : "خیر";
}
return value;
};
const formatNumberWithUnit = (
value: number | null | undefined,
unit: string,
) => {
if (value === null || value === undefined) {
return null;
}
return `${formatNumber(value)} ${unit}`;
};
const getStatusMeta = (status?: ParserResponse["status"]) => {
if (status === "completed") {
return {
label: "آماده اجرا",
color: "success" as const,
icon: "tabler-rosette-discount-check",
};
}
if (status === "needs_clarification") {
return {
label: "نیازمند تکمیل",
color: "warning" as const,
icon: "tabler-help-hexagon",
};
}
return {
label: "در انتظار تحلیل",
color: "secondary" as const,
icon: "tabler-sparkles",
};
};
const extractErrorMessage = (error: unknown, fallback: string) => {
if (typeof error !== "object" || error === null) {
return fallback;
}
if (
"details" in error &&
error.details &&
typeof error.details === "object" &&
"data" in error.details &&
error.details.data &&
typeof error.details.data === "object"
) {
const data = error.details.data as Record<string, unknown>;
const nonFieldErrors = data.non_field_errors;
if (
Array.isArray(nonFieldErrors) &&
typeof nonFieldErrors[0] === "string"
) {
return nonFieldErrors[0];
}
}
if ("message" in error && typeof error.message === "string") {
return error.message;
}
return fallback;
};
const createJsonSnapshot = (plan: unknown) => {
if (!plan) {
return "{}";
}
return JSON.stringify(plan, null, 2);
};
const getFertilizationFieldValue = (plan: unknown, field: string) => {
const data = plan as FertilizationPlanData | null | undefined;
if (!data) return null;
if (field === "crop_name") return data.crop_name;
if (field === "growth_stage") return data.growth_stage;
if (field === "objective") return data.objective;
const application = data.applications?.[0];
if (!application) return null;
if (field === "fertilizer_name") return application.fertilizer_name;
if (field === "formula") return application.formula;
if (field === "amount") return application.amount;
if (field === "application_method") return application.application_method;
if (field === "timing") return application.timing;
if (field === "interval_days") return application.interval_days;
if (field === "purpose") return application.purpose;
return null;
};
const getIrrigationFieldValue = (plan: unknown, field: string) => {
const data = plan as IrrigationPlanData | null | undefined;
if (!data) return null;
if (field === "crop_name") return data.crop_name;
if (field === "growth_stage") return data.growth_stage;
if (field === "irrigation_method") return data.irrigation_method;
if (field === "water_amount_per_event") return data.water_amount_per_event;
if (field === "duration_minutes") return data.duration_minutes;
if (field === "frequency_text") return data.frequency_text;
if (field === "interval_days") return data.interval_days;
if (field === "preferred_time_of_day") return data.preferred_time_of_day;
if (field === "start_date") return data.start_date;
if (field === "target_area") return data.target_area;
return null;
};
const buildNextAnswersState = (
response: ParserResponse,
previousAnswers: Record<string, string>,
config: ParserConfig,
) => {
return response.questions.reduce<Record<string, string>>((acc, question) => {
const previousValue = previousAnswers[question.field];
if (previousValue) {
acc[question.field] = previousValue;
return acc;
}
const extractedValue = config.getFieldValue(
response.collected_data,
question.field,
);
if (extractedValue !== null && extractedValue !== undefined) {
acc[question.field] = String(extractedValue);
}
return acc;
}, {});
};
const normalizeAnswers = (
questions: ParserQuestion[],
answers: Record<string, string>,
) => {
return questions.reduce<Record<string, ParserAnswerValue>>(
(acc, question) => {
const rawValue = answers[question.field]?.trim();
if (!rawValue) {
return acc;
}
if (
question.field === "interval_days" ||
question.field === "duration_minutes"
) {
const parsed = Number(rawValue);
acc[question.field] = Number.isFinite(parsed) ? parsed : rawValue;
return acc;
}
acc[question.field] = rawValue;
return acc;
},
{},
);
};
const FERTILIZATION_FIELD_LABELS: Record<string, string> = {
crop_name: "محصول",
growth_stage: "مرحله رشد",
objective: "هدف برنامه",
fertilizer_name: "نام کود",
formula: "فرمول کود",
amount: "مقدار مصرف",
application_method: "روش مصرف",
timing: "زمان بندی",
interval_days: "فاصله نوبت ها",
purpose: "هدف هر نوبت",
};
const IRRIGATION_FIELD_LABELS: Record<string, string> = {
crop_name: "محصول",
growth_stage: "مرحله رشد",
irrigation_method: "روش آبیاری",
water_amount_per_event: "مقدار آب هر نوبت",
duration_minutes: "مدت هر نوبت",
frequency_text: "تناوب آبیاری",
interval_days: "فاصله آبیاری",
preferred_time_of_day: "زمان مناسب اجرا",
start_date: "زمان شروع",
target_area: "محدوده اجرا",
};
const PARSER_CONFIGS: Record<ParserTabKey, ParserConfig> = {
fertilization: {
key: "fertilization",
label: "کودهی",
badge: "Fertilization AI Planner",
icon: "tabler-flask-2",
heroTitle: "برنامه کودهی با ورودی متنی و خروجی JSON آماده اجرا",
heroDescription:
"متن آزاد کشاورز را بگیر، اگر داده ناقص بود سوال تکمیلی بپرس و در نهایت یک نسخه ساختاریافته و خوش خوان از برنامه کودهی تحویل بده.",
panelTitle: "اتاق فرمان برنامه کودهی",
panelDescription:
"هر چقدر متن طبیعی تر و نزدیک به زبان خود کشاورز باشد، API بهتر می تواند ساختار نهایی را استخراج کند.",
inputLabel: "متن آزاد برنامه کودهی",
inputPlaceholder:
"مثلا: برای گندم در مرحله پنجه زنی هر 12 روز یک بار کود 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.",
samplePrompts: [
"برای گندم در مرحله پنجه زنی هر 12 روز یک بار کود 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.",
"برای ذرت از کود کامل استفاده می کنم اما فاصله بین نوبت ها را دقیق نمی دانم و می خواهم برنامه ام کامل شود.",
"برای گوجه فرنگی در شروع گلدهی یک برنامه کودهی دقیق با کود NPK و مصرف مرحله ای می خواهم.",
],
fieldLabels: FERTILIZATION_FIELD_LABELS,
previewOrder: [
"crop_name",
"growth_stage",
"objective",
"fertilizer_name",
"formula",
"amount",
"application_method",
"timing",
"interval_days",
"purpose",
],
itemCountTitle: "نوبت های کودی",
primaryCardTitle: "نسخه پیشنهادی برای اولین نوبت کودی",
primaryFallbackTitle: "نام کود هنوز مشخص نیست",
finalCardTitle: "خروجی نهایی کودهی آماده تحویل",
finalCardDescription:
"اگر لازم بود همین JSON را برای ذخیره، اشتراک گذاری یا مرحله بعدی workflow استفاده کن.",
buildPayload: ({ message, answers, partialPlan, farmUuid }) => ({
...(message ? { message } : {}),
...(answers ? { answers } : {}),
...(partialPlan ? { partial_plan: partialPlan } : {}),
...(farmUuid ? { farm_uuid: farmUuid } : {}),
}),
submit: (payload) =>
fertilizationPlanParserService.parseFromText(
payload as Parameters<
typeof fertilizationPlanParserService.parseFromText
>[0],
),
getFieldValue: getFertilizationFieldValue,
formatFieldValue: (field, value) => {
if (field === "interval_days" && typeof value === "number") {
return `${formatNumber(value)} روز`;
}
return defaultFormatFieldValue(value);
},
getPrimaryItem: (plan) =>
(plan as FertilizationPlanData | null | undefined)?.applications?.[0] ??
null,
getPrimaryMeta: (item) => {
const application = item as FertilizationPlanApplication | null;
if (!application) return [];
return [
{
key: "formula",
label: FERTILIZATION_FIELD_LABELS.formula,
value: application.formula,
},
{
key: "amount",
label: FERTILIZATION_FIELD_LABELS.amount,
value: application.amount,
},
{
key: "application_method",
label: FERTILIZATION_FIELD_LABELS.application_method,
value: application.application_method,
},
{
key: "timing",
label: FERTILIZATION_FIELD_LABELS.timing,
value: application.timing,
},
{
key: "interval_days",
label: FERTILIZATION_FIELD_LABELS.interval_days,
value: formatNumberWithUnit(application.interval_days, "روز"),
},
{
key: "purpose",
label: FERTILIZATION_FIELD_LABELS.purpose,
value: application.purpose,
},
];
},
getPrimaryHeadline: (item) =>
(item as FertilizationPlanApplication | null)?.fertilizer_name ||
"نام کود هنوز مشخص نیست",
getItemCount: (plan) =>
(plan as FertilizationPlanData | null | undefined)?.applications
?.length ?? 0,
},
irrigation: {
key: "irrigation",
label: "آبیاری",
badge: "Irrigation AI Planner",
icon: "tabler-droplet-half-2",
heroTitle: "برنامه آبیاری با ورودی متنی و خروجی JSON آماده اجرا",
heroDescription:
"برای آبیاری هم همان flow هوشمند را داشته باش: متن آزاد را بفرست، ابهام ها را کامل کن و یک برنامه ساختاریافته و قابل اجرا بگیر.",
panelTitle: "اتاق فرمان برنامه آبیاری",
panelDescription:
"جزئیات روش آبیاری، زمان بندی و مقدار آب را با زبان طبیعی بنویس تا API آن را به برنامه دقیق تبدیل کند.",
inputLabel: "متن آزاد برنامه آبیاری",
inputPlaceholder:
"مثلا: برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.",
samplePrompts: [
"برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.",
"برای هندوانه هر دو روز یک بار آبیاری می کنم اما زمان شروع برنامه و محدوده اجرا را هنوز مشخص نکرده ام.",
"برای خیار گلخانه ای با تیپ آبیاری می کنم و می خواهم برنامه ام بر اساس زمان و حجم آب کامل شود.",
],
fieldLabels: IRRIGATION_FIELD_LABELS,
previewOrder: [
"crop_name",
"growth_stage",
"irrigation_method",
"water_amount_per_event",
"duration_minutes",
"frequency_text",
"interval_days",
"preferred_time_of_day",
"start_date",
"target_area",
],
itemCountTitle: "بخش های آبیاری",
primaryCardTitle: "چکیده اجرای برنامه آبیاری",
primaryFallbackTitle: "روش آبیاری هنوز مشخص نیست",
finalCardTitle: "خروجی نهایی آبیاری آماده تحویل",
finalCardDescription:
"از همین JSON می توانی برای نمایش، ذخیره یا ادامه workflow آبیاری استفاده کنی.",
buildPayload: ({ message, answers, partialPlan, farmUuid }) => ({
...(message ? { message } : {}),
...(answers ? { answers } : {}),
...(partialPlan ? { partial_plan: partialPlan } : {}),
...(farmUuid ? { farm_uuid: farmUuid } : {}),
}),
submit: (payload) =>
irrigationPlanParserService.parseFromText(
payload as Parameters<
typeof irrigationPlanParserService.parseFromText
>[0],
),
getFieldValue: getIrrigationFieldValue,
formatFieldValue: (field, value) => {
if (field === "duration_minutes" && typeof value === "number") {
return `${formatNumber(value)} دقیقه`;
}
if (field === "interval_days" && typeof value === "number") {
return `${formatNumber(value)} روز`;
}
return defaultFormatFieldValue(value);
},
getPrimaryItem: (plan) => plan ?? null,
getPrimaryMeta: (item) => {
const data = item as IrrigationPlanData | null;
if (!data) return [];
return [
{
key: "irrigation_method",
label: IRRIGATION_FIELD_LABELS.irrigation_method,
value: data.irrigation_method,
},
{
key: "water_amount_per_event",
label: IRRIGATION_FIELD_LABELS.water_amount_per_event,
value: data.water_amount_per_event,
},
{
key: "duration_minutes",
label: IRRIGATION_FIELD_LABELS.duration_minutes,
value: formatNumberWithUnit(data.duration_minutes, "دقیقه"),
},
{
key: "frequency_text",
label: IRRIGATION_FIELD_LABELS.frequency_text,
value: data.frequency_text,
},
{
key: "preferred_time_of_day",
label: IRRIGATION_FIELD_LABELS.preferred_time_of_day,
value: data.preferred_time_of_day,
},
{
key: "target_area",
label: IRRIGATION_FIELD_LABELS.target_area,
value: data.target_area,
},
];
},
getPrimaryHeadline: (item) =>
(item as IrrigationPlanData | null)?.irrigation_method ||
"روش آبیاری هنوز مشخص نیست",
getItemCount: (plan) => {
const data = plan as IrrigationPlanData | null | undefined;
if (!data) return 0;
return 1 + (data.trigger_conditions?.length ?? 0);
},
},
};
2026-05-02 06:23:34 +03:30
const FertilizationPlanParserPage = ({
initialTab = "fertilization",
enabledTabs = ["fertilization", "irrigation"],
}: FertilizationPlanParserPageProps) => {
2026-04-30 04:00:19 +03:30
const theme = useTheme();
const { farmHub } = useFarmHub();
const farmUuid = farmHub?.farm_uuid;
2026-05-02 06:23:34 +03:30
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;
2026-04-30 04:00:19 +03:30
2026-05-02 06:23:34 +03:30
const [activeTab, setActiveTab] = useState<ParserTabKey>(resolvedInitialTab);
2026-04-30 04:00:19 +03:30
const [tabStates, setTabStates] = useState<Record<ParserTabKey, TabState>>({
fertilization: createInitialTabState(),
irrigation: createInitialTabState(),
});
const config = PARSER_CONFIGS[activeTab];
const currentState = tabStates[activeTab];
const statusMeta = getStatusMeta(currentState.response?.status);
const planPreview =
currentState.response?.final_plan ??
currentState.response?.collected_data ??
null;
const primaryItem = config.getPrimaryItem(planPreview);
const currentQuestion =
currentState.response?.questions[currentState.activeQuestion] ?? null;
const completionValue = currentState.response
? Math.max(
20,
Math.round(
((config.previewOrder.length -
currentState.response.missing_fields.length) /
config.previewOrder.length) *
100,
),
)
: 18;
const statCards = useMemo(
() => [
{
stats: currentState.response?.status_fa ?? "شروع نشده",
title: "وضعیت پردازش",
avatarIcon: statusMeta.icon,
avatarColor: statusMeta.color as ThemeColor,
},
{
stats: formatNumber(currentState.response?.missing_fields.length ?? 0),
title: "فیلدهای ناقص",
avatarIcon: "tabler-help-circle",
avatarColor: "warning" as ThemeColor,
},
{
stats: formatNumber(config.getItemCount(planPreview)),
title: config.itemCountTitle,
avatarIcon: config.icon,
avatarColor: "success" as ThemeColor,
},
],
[
config,
currentState.response?.missing_fields.length,
currentState.response?.status_fa,
planPreview,
statusMeta.color,
statusMeta.icon,
],
);
const updateTabState = (
tab: ParserTabKey,
updater: Partial<TabState> | ((previous: TabState) => TabState),
) => {
setTabStates((previous) => ({
...previous,
[tab]:
typeof updater === "function"
? updater(previous[tab])
: { ...previous[tab], ...updater },
}));
};
const handleReset = (tab: ParserTabKey) => {
updateTabState(tab, createInitialTabState());
};
const submitRequest = async (
tab: ParserTabKey,
payload: Record<string, unknown>,
) => {
const tabConfig = PARSER_CONFIGS[tab];
updateTabState(tab, (previous) => ({
...previous,
loading: true,
requestError: null,
statusNote: null,
}));
try {
const nextResponse = await tabConfig.submit(payload);
updateTabState(tab, (previous) => ({
...previous,
response: nextResponse,
answers: buildNextAnswersState(
nextResponse,
previous.answers,
tabConfig,
),
activeQuestion: 0,
statusNote:
nextResponse.status === "completed"
? `برنامه ${tabConfig.label} نهایی آماده شد و می توانی آن را با تیم مزرعه به اشتراک بگذاری.`
: "سیستم چند ابهام پیدا کرده؛ جواب ها را کامل کن تا JSON نهایی ساخته شود.",
}));
} catch (error) {
updateTabState(tab, (previous) => ({
...previous,
requestError: extractErrorMessage(
error,
`در ساخت برنامه ${tabConfig.label} مشکلی پیش آمد. دوباره تلاش کن.`,
),
}));
} finally {
updateTabState(tab, (previous) => ({
...previous,
loading: false,
}));
}
};
const handleGeneratePlan = async () => {
const trimmedMessage = currentState.message.trim();
if (!trimmedMessage) {
updateTabState(activeTab, (previous) => ({
...previous,
requestError: `اول متن برنامه ${config.label} را بنویس تا تحلیل را شروع کنیم.`,
}));
return;
}
await submitRequest(
activeTab,
config.buildPayload({
message: trimmedMessage,
farmUuid,
}),
);
};
const handleSubmitAnswers = async () => {
if (!currentState.response) {
return;
}
const normalizedAnswers = normalizeAnswers(
currentState.response.questions,
currentState.answers,
);
const unansweredQuestion = currentState.response.questions.find(
(question) => {
const value = normalizedAnswers[question.field];
return value === undefined || value === null || value === "";
},
);
if (unansweredQuestion) {
updateTabState(activeTab, (previous) => ({
...previous,
requestError: `پاسخ سوال «${config.fieldLabels[unansweredQuestion.field] ?? unansweredQuestion.question}» هنوز کامل نشده است.`,
}));
return;
}
await submitRequest(
activeTab,
config.buildPayload({
answers: normalizedAnswers,
partialPlan: currentState.response.collected_data,
farmUuid,
}),
);
};
const handleCopyJson = async () => {
if (
!planPreview ||
typeof navigator === "undefined" ||
!navigator.clipboard
) {
updateTabState(activeTab, (previous) => ({
...previous,
statusNote: "کپی خودکار روی این مرورگر در دسترس نیست.",
}));
return;
}
await navigator.clipboard.writeText(createJsonSnapshot(planPreview));
updateTabState(activeTab, (previous) => ({
...previous,
statusNote: `نسخه JSON برنامه ${config.label} در کلیپ بورد کپی شد.`,
}));
};
return (
<Box>
<Grid container spacing={6}>
<Grid size={12}>
<Card sx={{ overflow: "hidden" }}>
<CardContent
sx={{
position: "relative",
overflow: "hidden",
p: { xs: 4, md: 6 },
color: "common.white",
background: `linear-gradient(135deg, ${theme.palette.success.dark} 0%, ${theme.palette.primary.main} 42%, ${theme.palette.info.main} 100%)`,
}}
>
<Box
sx={{
position: "absolute",
insetInlineEnd: -54,
insetBlockStart: -60,
inlineSize: 220,
blockSize: 220,
borderRadius: "40% 60% 70% 30% / 50% 45% 55% 50%",
backgroundColor: alpha(theme.palette.common.white, 0.12),
}}
/>
<Box
sx={{
position: "absolute",
insetInlineStart: "-8%",
insetBlockEnd: "-65%",
inlineSize: "46%",
blockSize: 260,
borderRadius: "50%",
backgroundColor: alpha(theme.palette.common.white, 0.08),
}}
/>
<Grid
container
spacing={5}
sx={{ position: "relative", zIndex: 1, alignItems: "center" }}
>
<Grid size={{ xs: 12, lg: 7 }}>
<Stack spacing={3}>
<Chip
label={config.badge}
size="small"
sx={{
width: "fit-content",
color: "common.white",
border: `1px solid ${alpha(theme.palette.common.white, 0.26)}`,
backgroundColor: alpha(
theme.palette.common.white,
0.12,
),
}}
/>
<Box>
<Typography
variant="h3"
sx={{ color: "common.white", fontWeight: 800, mb: 2 }}
>
{config.heroTitle}
</Typography>
<Typography
sx={{
maxWidth: 760,
color: alpha(theme.palette.common.white, 0.84),
lineHeight: 1.9,
}}
>
{config.heroDescription}
</Typography>
</Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
flexWrap="wrap"
>
<Box
sx={{
px: 2.5,
py: 1.75,
borderRadius: 4,
backdropFilter: "blur(10px)",
backgroundColor: alpha(
theme.palette.common.white,
0.12,
),
border: `1px solid ${alpha(theme.palette.common.white, 0.16)}`,
}}
>
<Typography
sx={{
color: alpha(theme.palette.common.white, 0.72),
mb: 0.5,
}}
>
موتور تحلیل
</Typography>
<Typography
sx={{ color: "common.white", fontWeight: 700 }}
>
Free-text parser + clarification flow
</Typography>
</Box>
<Box
sx={{
px: 2.5,
py: 1.75,
borderRadius: 4,
backdropFilter: "blur(10px)",
backgroundColor: alpha(
theme.palette.common.white,
0.12,
),
border: `1px solid ${alpha(theme.palette.common.white, 0.16)}`,
}}
>
<Typography
sx={{
color: alpha(theme.palette.common.white, 0.72),
mb: 0.5,
}}
>
مزرعه فعال
</Typography>
<Typography
sx={{ color: "common.white", fontWeight: 700 }}
>
{farmHub?.name || farmUuid || "بدون مزرعه فعال"}
</Typography>
</Box>
</Stack>
</Stack>
</Grid>
<Grid size={{ xs: 12, lg: 5 }}>
<Card
sx={{
borderRadius: 5,
color: "text.primary",
backgroundColor: alpha(theme.palette.common.white, 0.96),
boxShadow: `0 24px 60px ${alpha(theme.palette.common.black, 0.16)}`,
}}
>
<CardContent>
<Stack spacing={3}>
<Box>
<Typography
variant="h5"
sx={{ fontWeight: 800, mb: 1.5 }}
>
2026-05-02 06:23:34 +03:30
{singleTabMode
? `جریان هوشمند ${config.label}`
: "دو جریان هوشمند، یک صفحه واحد"}
2026-04-30 04:00:19 +03:30
</Typography>
<Typography color="text.secondary">
2026-05-02 06:23:34 +03:30
{singleTabMode
? `ورودی متنی برنامه ${config.label} را بفرست، ابهام ها را کامل کن و خروجی JSON نهایی بگیر.`
: "بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با همان flow سوال های تکمیلی تا JSON نهایی پیش ببر."}
2026-04-30 04:00:19 +03:30
</Typography>
</Box>
<Stepper
activeStep={
currentState.response?.status === "completed"
? 2
: currentState.response
? 1
: 0
}
alternativeLabel
>
<Step>
<StepLabel>متن آزاد</StepLabel>
</Step>
<Step>
<StepLabel>تکمیل ابهام ها</StepLabel>
</Step>
<Step>
<StepLabel>برنامه نهایی</StepLabel>
</Step>
</Stepper>
</Stack>
</CardContent>
</Card>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, lg: 7 }}>
<Card sx={{ borderRadius: 5 }}>
<CardContent sx={{ p: { xs: 4, md: 5 } }}>
<Stack spacing={4}>
<Box>
2026-05-02 06:23:34 +03:30
{!singleTabMode ? (
<Tabs
value={activeTab}
onChange={(_, value: ParserTabKey) => setActiveTab(value)}
variant="fullWidth"
sx={{
p: 1,
borderRadius: 4,
backgroundColor: alpha(theme.palette.primary.main, 0.05),
minHeight: 64,
"& .MuiTabs-indicator": { display: "none" },
}}
>
{availableTabs.map((tabKey) => {
const tabConfig = PARSER_CONFIGS[tabKey];
return (
<Tab
key={tabConfig.key}
value={tabConfig.key}
icon={<i className={tabConfig.icon} />}
iconPosition="start"
label={tabConfig.label}
sx={{
minHeight: 54,
borderRadius: 3,
fontWeight: 700,
transition: "all 0.2s ease",
"&.Mui-selected": {
color: theme.palette.common.white,
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.info.main} 100%)`,
boxShadow: `0 14px 32px ${alpha(theme.palette.primary.main, 0.24)}`,
},
}}
/>
);
})}
</Tabs>
) : null}
2026-04-30 04:00:19 +03:30
</Box>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
gap={3}
flexWrap="wrap"
>
<Box>
<Typography variant="h4" sx={{ fontWeight: 800, mb: 1 }}>
{config.panelTitle}
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 700 }}>
{config.panelDescription}
</Typography>
</Box>
<OptionMenu
icon="tabler-dots"
options={[
{
text: `پر کردن نمونه ${config.label}`,
icon: config.icon,
menuItemProps: {
onClick: () =>
updateTabState(activeTab, (previous) => ({
...previous,
message: config.samplePrompts[0],
})),
},
},
{
text: "کپی JSON",
icon: "tabler-copy",
menuItemProps: {
onClick: handleCopyJson,
},
},
{
text: "شروع دوباره",
icon: "tabler-rotate-clockwise-2",
menuItemProps: {
onClick: () => handleReset(activeTab),
},
},
]}
/>
</Box>
{!farmUuid && (
<Alert severity="warning">
مزرعه فعالی پیدا نشد. صفحه هنوز کار می کند، اما اگر
`farm_uuid` فعال باشد پاسخ API دقیق تر می شود.
</Alert>
)}
<CustomTextField
fullWidth
multiline
minRows={7}
label={config.inputLabel}
placeholder={config.inputPlaceholder}
value={currentState.message}
onChange={(event) =>
updateTabState(activeTab, (previous) => ({
...previous,
message: event.target.value,
}))
}
/>
<Box>
<Typography
variant="body2"
sx={{ mb: 2, color: "text.secondary" }}
>
نمونه های آماده برای شروع سریع
</Typography>
<Stack
direction="row"
spacing={1.5}
useFlexGap
flexWrap="wrap"
>
{config.samplePrompts.map((prompt) => (
<Chip
key={prompt}
label={prompt}
onClick={() =>
updateTabState(activeTab, (previous) => ({
...previous,
message: prompt,
}))
}
variant="outlined"
sx={{
justifyContent: "flex-start",
px: 1,
py: 2.75,
maxWidth: "100%",
height: "auto",
borderRadius: 3,
backgroundColor: alpha(
theme.palette.success.main,
0.05,
),
"& .MuiChip-label": {
display: "block",
whiteSpace: "normal",
},
}}
/>
))}
</Stack>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="contained"
size="large"
onClick={handleGeneratePlan}
disabled={currentState.loading}
startIcon={<i className="tabler-sparkles" />}
sx={{ px: 5, py: 1.8, borderRadius: 3 }}
>
تحلیل متن و ساخت برنامه
</Button>
<Button
variant="tonal"
size="large"
onClick={() => handleReset(activeTab)}
disabled={currentState.loading}
startIcon={<i className="tabler-refresh" />}
sx={{ px: 4, py: 1.8, borderRadius: 3 }}
>
پاک کردن جریان
</Button>
</Stack>
{currentState.loading && (
<LinearProgress sx={{ borderRadius: 999 }} />
)}
{currentState.requestError && (
<Alert severity="error">{currentState.requestError}</Alert>
)}
{currentState.statusNote && (
<Alert severity="success">{currentState.statusNote}</Alert>
)}
{currentState.response && (
<Card
variant="outlined"
sx={{
borderRadius: 4,
borderColor: alpha(theme.palette.primary.main, 0.16),
background: `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.04)} 0%, ${theme.palette.background.paper} 100%)`,
}}
>
<CardContent>
<Stack spacing={3}>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
gap={2}
flexWrap="wrap"
>
<Box display="flex" alignItems="center" gap={2}>
<CustomAvatar
skin="light-static"
color={statusMeta.color}
size={48}
>
<i className={statusMeta.icon} />
</CustomAvatar>
<Box>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{currentState.response.status_fa}
</Typography>
<Typography color="text.secondary">
{currentState.response.summary}
</Typography>
</Box>
</Box>
<Chip
label={statusMeta.label}
color={statusMeta.color}
variant="tonal"
/>
</Box>
{currentState.response.status ===
"needs_clarification" &&
currentState.response.questions.length > 0 &&
currentQuestion && (
<Stack spacing={3}>
<Divider />
<Box>
<Typography
variant="h5"
sx={{ fontWeight: 800, mb: 1 }}
>
مرحله تکمیل ابهام ها
</Typography>
<Typography color="text.secondary">
سوال ها را یکی یکی جلو ببر؛ بعد از تکمیل، همان
endpoint با `answers` و `partial_plan` دوباره
صدا زده می شود.
</Typography>
</Box>
<Stepper
activeStep={currentState.activeQuestion}
alternativeLabel
>
{currentState.response.questions.map(
(question) => (
<Step key={question.id}>
<StepLabel>
{config.fieldLabels[question.field] ??
question.field}
</StepLabel>
</Step>
),
)}
</Stepper>
<Card
sx={{
borderRadius: 4,
border: `1px solid ${alpha(theme.palette.warning.main, 0.24)}`,
backgroundColor: alpha(
theme.palette.warning.main,
0.06,
),
}}
>
<CardContent>
<Stack spacing={3}>
<Box
display="flex"
alignItems="center"
gap={2}
>
<CustomAvatar
color="warning"
skin="light-static"
size={42}
>
<i className="tabler-message-question" />
</CustomAvatar>
<Box>
<Typography sx={{ fontWeight: 700 }}>
سوال{" "}
{formatNumber(
currentState.activeQuestion + 1,
)}{" "}
از{" "}
{formatNumber(
currentState.response.questions
.length,
)}
</Typography>
<Typography color="text.secondary">
{config.fieldLabels[
currentQuestion.field
] ?? currentQuestion.field}
</Typography>
</Box>
</Box>
<Box>
<Typography
variant="h5"
sx={{ fontWeight: 800, mb: 1.5 }}
>
{currentQuestion.question}
</Typography>
<Typography color="text.secondary">
{currentQuestion.rationale}
</Typography>
</Box>
<CustomTextField
fullWidth
label="پاسخ کاربر"
value={
currentState.answers[
currentQuestion.field
] ?? ""
}
onChange={(event) =>
updateTabState(
activeTab,
(previous) => ({
...previous,
answers: {
...previous.answers,
[currentQuestion.field]:
event.target.value,
},
}),
)
}
placeholder="پاسخ را اینجا بنویس..."
/>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
justifyContent="space-between"
>
<Button
variant="tonal"
disabled={
currentState.activeQuestion === 0
}
onClick={() =>
updateTabState(
activeTab,
(previous) => ({
...previous,
activeQuestion: Math.max(
previous.activeQuestion - 1,
0,
),
}),
)
}
startIcon={
<i className="tabler-arrow-right" />
}
sx={{ borderRadius: 3 }}
>
سوال قبل
</Button>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
>
<Button
variant="outlined"
disabled={
currentState.activeQuestion >=
currentState.response.questions
.length -
1
}
onClick={() =>
updateTabState(
activeTab,
(previous) => ({
...previous,
activeQuestion: Math.min(
previous.activeQuestion + 1,
currentState.response
?.questions.length
? currentState.response
.questions.length - 1
: 0,
),
}),
)
}
endIcon={
<i className="tabler-arrow-left" />
}
sx={{ borderRadius: 3 }}
>
سوال بعد
</Button>
<Button
variant="contained"
onClick={handleSubmitAnswers}
disabled={currentState.loading}
endIcon={
<i className="tabler-send" />
}
sx={{ borderRadius: 3 }}
>
ارسال پاسخ ها
</Button>
</Stack>
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
)}
</Stack>
</CardContent>
</Card>
)}
</Stack>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, lg: 5 }}>
<Stack spacing={4}>
<Grid container spacing={3}>
{statCards.map((card) => (
<Grid key={card.title} size={{ xs: 12, sm: 4, lg: 12 }}>
<HorizontalWithAvatar
stats={card.stats}
title={card.title}
avatarIcon={card.avatarIcon}
avatarColor={card.avatarColor}
avatarSkin="light"
avatarVariant="rounded"
avatarSize={42}
/>
</Grid>
))}
</Grid>
<Card sx={{ borderRadius: 5, overflow: "hidden" }}>
<CardContent sx={{ p: 0 }}>
<Box
sx={{
px: 4,
py: 3,
color: "common.white",
background: `linear-gradient(135deg, ${theme.palette.secondary.dark} 0%, ${theme.palette.primary.main} 100%)`,
}}
>
<Typography variant="h5" sx={{ fontWeight: 800, mb: 1 }}>
پیش نمایش زنده داده ساختاریافته
</Typography>
<Typography
sx={{ color: alpha(theme.palette.common.white, 0.78) }}
>
این بخش نشان می دهد سیستم تا این لحظه چه چیزهایی را فهمیده
است.
</Typography>
</Box>
<Box sx={{ px: 4, py: 3 }}>
<Box sx={{ mb: 3 }}>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={1.5}
>
<Typography sx={{ fontWeight: 700 }}>
درصد تکمیل برنامه
</Typography>
<Typography color="text.secondary">
{formatNumber(completionValue)}٪
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={completionValue}
sx={{ height: 10, borderRadius: 999 }}
/>
</Box>
{planPreview ? (
<Stack spacing={2}>
{config.previewOrder.map((field) => {
const value = config.getFieldValue(planPreview, field);
return (
<Box
key={field}
sx={{
p: 2.5,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.8)}`,
backgroundColor: alpha(
theme.palette.background.default,
0.55,
),
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 0.75 }}
>
{config.fieldLabels[field] ?? field}
</Typography>
<Typography sx={{ fontWeight: 700 }}>
{config.formatFieldValue(field, value)}
</Typography>
</Box>
);
})}
</Stack>
) : (
<Alert severity="info">
هنوز خروجی ای نداریم. متن را ارسال کن تا collected_data و
سپس final_plan اینجا شکل بگیرد.
</Alert>
)}
</Box>
</CardContent>
</Card>
{primaryItem !== null && primaryItem !== undefined && (
<Card sx={{ borderRadius: 5 }}>
<CardContent sx={{ p: { xs: 4, md: 4.5 } }}>
<Stack spacing={3}>
<Box
display="flex"
justifyContent="space-between"
alignItems="flex-start"
gap={2}
>
<Box>
<Typography
variant="h5"
sx={{ fontWeight: 800, mb: 1 }}
>
{config.primaryCardTitle}
</Typography>
<Typography color="text.secondary">
{config.getPrimaryHeadline(primaryItem) ||
config.primaryFallbackTitle}
</Typography>
</Box>
<Chip
label={
currentState.response?.status === "completed"
? "قابل اجرا"
: "در حال تکمیل"
}
color={
currentState.response?.status === "completed"
? "success"
: "warning"
}
variant="tonal"
/>
</Box>
<Grid container spacing={2}>
{config.getPrimaryMeta(primaryItem).map((item) => (
<Grid key={item.key} size={{ xs: 12, sm: 6 }}>
<Box
sx={{
p: 2.5,
borderRadius: 4,
backgroundColor: alpha(
theme.palette.success.main,
0.06,
),
border: `1px solid ${alpha(theme.palette.success.main, 0.15)}`,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 0.75 }}
>
{item.label}
</Typography>
<Typography sx={{ fontWeight: 700 }}>
{item.value || "—"}
</Typography>
</Box>
</Grid>
))}
</Grid>
</Stack>
</CardContent>
</Card>
)}
{currentState.response?.final_plan && (
<Card sx={{ borderRadius: 5 }}>
<CardContent sx={{ p: { xs: 4, md: 4.5 } }}>
<Stack spacing={3}>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
gap={2}
flexWrap="wrap"
>
<Box>
<Typography
variant="h5"
sx={{ fontWeight: 800, mb: 1 }}
>
{config.finalCardTitle}
</Typography>
<Typography color="text.secondary">
{config.finalCardDescription}
</Typography>
</Box>
<Button
variant="outlined"
onClick={handleCopyJson}
startIcon={<i className="tabler-copy" />}
sx={{ borderRadius: 3 }}
>
کپی JSON
</Button>
</Box>
<Box
component="pre"
sx={{
m: 0,
p: 3,
overflow: "auto",
borderRadius: 4,
fontSize: 13,
lineHeight: 1.9,
color: theme.palette.grey[100],
backgroundColor: theme.palette.grey[900],
}}
>
{createJsonSnapshot(currentState.response.final_plan)}
</Box>
</Stack>
</CardContent>
</Card>
)}
</Stack>
</Grid>
</Grid>
</Box>
);
};
export default FertilizationPlanParserPage;