Files
Frontend/src/views/dashboards/farm/fertilizationPlanParser/FertilizationPlanParserPage.tsx
T
2026-05-02 06:23:34 +03:30

1632 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
};
type FertilizationPlanParserPageProps = {
initialTab?: ParserTabKey;
enabledTabs?: ParserTabKey[];
};
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);
},
},
};
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>(resolvedInitialTab);
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 }}
>
{singleTabMode
? `جریان هوشمند ${config.label}`
: "دو جریان هوشمند، یک صفحه واحد"}
</Typography>
<Typography color="text.secondary">
{singleTabMode
? `ورودی متنی برنامه ${config.label} را بفرست، ابهام ها را کامل کن و خروجی JSON نهایی بگیر.`
: "بین تب آبیاری و تب کودهی جابه جا شو و هر کدام را با همان flow سوال های تکمیلی تا JSON نهایی پیش ببر."}
</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>
{!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}
</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;