diff --git a/src/core/components/base/base-drop-down.tsx b/src/core/components/base/base-drop-down.tsx index f62ce1f..51cd528 100644 --- a/src/core/components/base/base-drop-down.tsx +++ b/src/core/components/base/base-drop-down.tsx @@ -2,13 +2,14 @@ import { cn } from "@/core/lib/utils"; import { ChevronDown, Search } from "lucide-react"; import { forwardRef, - type InputHTMLAttributes, useEffect, useRef, useState, + type ChangeEvent, + type InputHTMLAttributes, } from "react"; -type Option = { value: string; label: string }; +type Option = { value: string; name: string }; type BaseDropdownProps = Omit< InputHTMLAttributes, @@ -22,6 +23,7 @@ type BaseDropdownProps = Omit< value?: string; onChange?: (value: string) => void; onInputChange?: (inputValue: string) => void; + fetchOptions?: (inputValue: string) => Promise; // New prop for fetching options }; export const BaseDropdown = forwardRef( @@ -37,15 +39,20 @@ export const BaseDropdown = forwardRef( onChange, disabled, onInputChange, + fetchOptions, // Destructure new prop }, ref ) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [internalOptions, setInternalOptions] = useState(options); // State for options, can be updated by fetchOptions + const [isLoading, setIsLoading] = useState(false); // Loading state for async options const dropdownRef = useRef(null); const inputRef = useRef(null); - const selectedOption = options.find((option) => option.value === value); + const selectedOption = internalOptions.find( + (option) => option.value === value + ); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -66,8 +73,30 @@ export const BaseDropdown = forwardRef( useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 0); + if (fetchOptions) { + setIsLoading(true); + fetchOptions(searchQuery) + .then((fetchedOpts) => { + setInternalOptions(fetchedOpts); + }) + .finally(() => { + setIsLoading(false); + }); + } + } else { + // When closing, reset internal options to initial if not fetching + if (!fetchOptions) { + setInternalOptions(options); + } } - }, [isOpen]); + }, [isOpen, fetchOptions]); + + // Update internal options if the options prop changes and no fetchOptions is provided + useEffect(() => { + if (!fetchOptions) { + setInternalOptions(options); + } + }, [options, fetchOptions]); const handleSelect = (option: Option) => { if (onChange) { @@ -77,8 +106,26 @@ export const BaseDropdown = forwardRef( setSearchQuery(""); }; - const filteredOptions = options.filter((option) => - option.label.toLowerCase().includes(searchQuery.toLowerCase()) + const handleSearchInputChange = (e: ChangeEvent) => { + const newSearchQuery = e.target.value; + setSearchQuery(newSearchQuery); + if (onInputChange) { + onInputChange(newSearchQuery); + } + if (fetchOptions) { + setIsLoading(true); + fetchOptions(newSearchQuery) + .then((fetchedOpts) => { + setInternalOptions(fetchedOpts); + }) + .finally(() => { + setIsLoading(false); + }); + } + }; + + const filteredOptions = internalOptions.filter((option) => + option.value.toLowerCase().includes(searchQuery.toLowerCase()) ); const hasError = !!error; @@ -107,7 +154,7 @@ export const BaseDropdown = forwardRef( )} > - {selectedOption ? selectedOption.label : placeholder} + {selectedOption ? selectedOption.value : placeholder} ( placeholder="جستجو..." className="w-full rounded-md border border-gray-300 px-3 py-2 pl-8 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500" value={searchQuery} - onChange={(e) => { - setSearchQuery(e.target.value); - if (onInputChange) { - onInputChange(e.target.value); - } - }} + onChange={handleSearchInputChange} /> -
    - {filteredOptions.map((option) => ( -
  • handleSelect(option)} - className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" - > - {option.label} -
  • - ))} -
+ {isLoading ? ( +
+ در حال بارگذاری... +
+ ) : ( +
    + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( +
  • handleSelect(option)} + className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" + > + {option.value} +
  • + )) + ) : ( +
  • + موردی یافت نشد. +
  • + )} +
+ )} )} diff --git a/src/core/components/base/form-field.tsx b/src/core/components/base/form-field.tsx new file mode 100644 index 0000000..70f58ac --- /dev/null +++ b/src/core/components/base/form-field.tsx @@ -0,0 +1,379 @@ +import type { FieldDefinition } from "@/core/utils/dynamic-field.utils"; +import { + fieldTypeMap, + FieldTypes, + FormService, + normalizeInput, + regexValidators, +} from "@/core/utils/dynamic-field.utils"; +import { useEffect, useState, type ChangeEvent, type FC } from "react"; +import { BaseDropdown } from "./base-drop-down"; +import { CustomCheckbox } from "./checkbox"; +import { ImageUploader } from "./image-uploader"; +import { CustomInput } from "./input"; +import { PersonSearchInput } from "./person-search-input"; // Import PersonSearchInput +import { CustomRadio } from "./radio"; +import TextAreaField from "./text-area"; + +interface FormFieldProps { + field: FieldDefinition; + value: any; + onChange: (value: any) => void; + allFormValues: Record; // New prop to access all form values for dependent fields +} + +const FormField: FC = ({ + field, + value, + onChange, + allFormValues, +}) => { + const [error, setError] = useState(null); + + useEffect(() => { + validateField(value); + }, [value]); + + const validateField = (currentValue: any) => { + let errorMessage: string | null = null; + + // Required validation + if ( + !field.AllowNull && + (currentValue === null || + currentValue === "" || + (Array.isArray(currentValue) && currentValue.length === 0)) + ) { + errorMessage = "این فیلد الزامی است."; + } + + // Length validation for text fields + if ( + field.Length && + typeof currentValue === "string" && + currentValue.length > field.Length + ) { + errorMessage = `حداکثر طول مجاز ${field.Length} کاراکتر است.`; + } + + // Min/Max validation for numeric fields + if ( + (field.Type === FieldTypes.عدد_صحیح || + field.Type === FieldTypes.عدد_اعشاری || + field.Type === FieldTypes.پول) && + typeof currentValue === "number" + ) { + if (field.MinValue !== undefined && currentValue < field?.MinValue) { + errorMessage = `مقدار باید بزرگتر یا مساوی ${field.MinValue} باشد.`; + } + if (field.MaxValue !== undefined && currentValue > field?.MaxValue) { + errorMessage = `مقدار باید کوچکتر یا مساوی ${field.MaxValue} باشد.`; + } + } + + // Regex validation + switch (field.Type) { + case FieldTypes.رشته_فارسی: + if (currentValue && !regexValidators.persian.test(currentValue)) { + errorMessage = "فقط حروف فارسی مجاز است."; + } + break; + case FieldTypes.رشته_لاتین: + if (currentValue && !regexValidators.latin.test(currentValue)) { + errorMessage = "فقط حروف انگلیسی و اعداد مجاز است."; + } + break; + case FieldTypes.تلفن_همراه: + if (currentValue && !regexValidators.mobile.test(currentValue)) { + errorMessage = "شماره تلفن همراه معتبر نیست."; + } + break; + case FieldTypes.کدملی: + if (currentValue && !regexValidators.nationalId.test(currentValue)) { + errorMessage = "کدملی معتبر نیست."; + } + break; + case FieldTypes.رایانامه: + if (currentValue && !regexValidators.email.test(currentValue)) { + errorMessage = "آدرس رایانامه معتبر نیست."; + } + break; + case FieldTypes.آدرس_پایگاه_اینترنتی: + if (currentValue && !regexValidators.url.test(currentValue)) { + errorMessage = "آدرس پایگاه اینترنتی معتبر نیست."; + } + break; + case FieldTypes.پلاک_ماشین: + case FieldTypes.پلاک_خودرو: + if (currentValue && !regexValidators.carPlate.test(currentValue)) { + errorMessage = "فرمت پلاک خودرو معتبر نیست."; + } + break; + default: + break; + } + + // Custom ParsedValidation + if (field.parsedValidationRules && currentValue) { + for (const validationRule of field.parsedValidationRules) { + const regex = new RegExp(validationRule.regex); + if (!regex.test(currentValue)) { + errorMessage = validationRule.message; + break; + } + } + } + + setError(errorMessage); + }; + + const handleChange = ( + e: ChangeEvent + ) => { + const newValue = normalizeInput(field.Type, e.target.value); + onChange(newValue); + }; + + const handleMultiSelectChange = (option: string, isChecked: boolean) => { + let newValues = Array.isArray(value) ? [...value] : []; + if (isChecked) { + newValues.push(option); + } else { + newValues = newValues.filter((item) => item !== option); + } + onChange(newValues); + }; + + const renderField = () => { + const inputType = fieldTypeMap(field.Type); + const commonProps = { + id: field.ID.toString(), + name: field.Name, + value: value || "", + onChange: handleChange, + required: field.Required, + className: `w-full p-2 border rounded-md text-right ${ + error ? "border-red-500" : "border-gray-300" + }`, + dir: "rtl", + }; + switch (inputType) { + case "text": + return ( + + ); + case "person-search": // New case for person search + return ( + + ); + case "textarea": + return ( + + ); + case "number": + return ( + + ); + case "decimal": + return ( + ) => { + const val = e.target.value; + if (!isNaN(Number(val)) || val === "") { + onChange(val); + } + }} + /> + ); + case "currency": + return ( + ) => { + const val = e.target.value.replace(/[^0-9.]/g, ""); // Remove non-numeric except dot + if (!isNaN(Number(val)) || val === "") { + onChange(val); + } + }} + value={value ? new Intl.NumberFormat("fa-IR").format(value) : ""} + /> + ); + case "date": + return ( + ) => + onChange(e.target.value) + } + /> + ); + case "time": + return ( + ) => + onChange(e.target.value) + } + /> + ); + case "select": + const isDependent = field.Parent_FieldID !== undefined; + const parentFieldValue = + isDependent && field.Parent_FieldID !== undefined + ? allFormValues[field.Parent_FieldID.toString()] + : undefined; + + const fetchDependentOptions = isDependent + ? async (inputValue: string) => { + if (!parentFieldValue || field.Parent_FieldID === undefined) + return []; // Don't fetch if parent value is not set or Parent_FieldID is undefined + const fetched = await FormService.fetchDependentOptions( + field.Parent_FieldID!, // Non-null assertion + parentFieldValue + ); + return fetched.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + } + : undefined; + + return ( + opt.value === value)?.value || + undefined + } + onChange={(optionValue: string) => onChange(optionValue)} + className={`w-full p-2 border rounded-md text-right ${ + error ? "border-red-500" : "border-gray-300" + }`} + fetchOptions={fetchDependentOptions} + disabled={isDependent && !parentFieldValue} // Disable if dependent and parent value is not set + /> + ); + case "radio": + return ( +
+ {field.parsedOptions?.map((option) => ( + onChange(option.value)} + /> + ))} +
+ ); + case "multi-select": + return ( +
+ {field.parsedOptions?.map((option) => ( + + handleMultiSelectChange(option.value, e) + } + /> + ))} +
+ ); + case "image-upload": + return ( + onChange(file)} + previewImage={value} + className={`w-full p-2 border rounded-md text-right ${ + error ? "border-red-500" : "border-gray-300" + }`} + /> + ); + case "file-upload": + return ( + ) => + onChange(e.target.files ? e.target.files[0] : null) + } + value={""} // File inputs are uncontrolled, value should be empty + /> + ); + case "html-editor": + // Placeholder for a more complex HTML editor component + return ( + + ); + case "description": + return ( +

+ {field.Description || field.Name} +

+ ); + default: + return ( + + ); + } + }; + + return ( +
+ {renderField()} + {error &&

{error}

} +
+ ); +}; + +export default FormField; diff --git a/src/core/components/base/person-search-input.tsx b/src/core/components/base/person-search-input.tsx new file mode 100644 index 0000000..93859dc --- /dev/null +++ b/src/core/components/base/person-search-input.tsx @@ -0,0 +1,50 @@ +import { type ChangeEvent, type FC, useState } from "react"; +import { CustomInput } from "./input"; + +interface PersonSearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + dir?: "rtl" | "ltr"; +} + +export const PersonSearchInput: FC = ({ + value, + onChange, + placeholder = "جستجوی شخص...", + className, + dir = "rtl", +}) => { + const [searchTerm, setSearchTerm] = useState(value); + + const handleInputChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setSearchTerm(newValue); + onChange(newValue); // Propagate change immediately + // In a real scenario, you would trigger a search API call here + // and manage search results, perhaps in a dropdown below the input. + }; + + return ( +
+ + {/* Placeholder for search results dropdown */} + {/* {searchTerm && ( +
+
    +
  • نتیجه ۱
  • +
  • نتیجه ۲
  • +
+
+ )} */} +
+ ); +}; diff --git a/src/core/service/api-address.ts b/src/core/service/api-address.ts index 80e963e..c119ab1 100644 --- a/src/core/service/api-address.ts +++ b/src/core/service/api-address.ts @@ -8,6 +8,7 @@ export const API_ADDRESS = { save: "/api/save", delete: "/api/delete", uploadImage: "/workflow/uploadImage", + index: "workflow/index", // LOGIN: "/auth/login", // VERIFY_OTP: "/auth/verify-otp", // REGISTER: "/auth/register", diff --git a/src/core/types/global.type.ts b/src/core/types/global.type.ts index 735e843..ff3b0c1 100644 --- a/src/core/types/global.type.ts +++ b/src/core/types/global.type.ts @@ -4,3 +4,154 @@ export interface TokenInterface { RefreshToken: string; ExpRefreshToken: string; } + +// --------------------------- +// Root Interface +// --------------------------- +export interface WorkflowResponse { + ChildFieldNameOrID: string | null; + Fields: FieldDefinition[]; + ParentStageID: number | null; + Process: ProcessInfo | null; + Stage: StageInfo | null; + StageHistory: StageHistoryInfo | null; + Step: StepInfo | null; + ValueID: number; + Values: Record | null; + Workflow: WorkflowInfo | null; +} + +// --------------------------- +// Field Definition Interface +// --------------------------- +export interface FieldDefinition { + AllowNull: boolean; + CalculationField_Async: boolean; + CalculationField_IsFunctionOutput: boolean; + CalculationField_ReCalculateAfterSave: boolean; + + Calculation_Formula: string; + Calculation_ParsedFormula: string; + Calculation_ParsedFormulaL2: string; + + Child_OpenAgain: boolean; + Child_ProcessID: number; + Child_ShowCalendar: boolean; + + Date_Type: string; + + DefaultValue: string; + + Description_HasSaving: boolean; + Description_IsParser: boolean; + Description_Text: string; + + Document: string; + + FieldStyle_3Col: boolean; + FieldStyle_4Col: boolean; + + File_Extentions: string; + File_MaxSize: string; + + Help: string; + + ID: number; + + IsDashboardField: boolean; + IsNumeric: boolean; + IsUnique: boolean; + + LatinName: string; + Length: number | null; + + LockFirstValue: boolean; + + MaxValue: number | null; + MinValue: number | null; + + Name: string; + NameOrID: string; + + Option_IsChips: boolean; + Option_Options: string; // JSON string + + OrderNumber: number; + + Parent_CanBeSave: boolean; + Parent_DependentFields: string; // JSON string + Parent_FieldID: number; + Parent_Formula: string; + Parent_IsMultiSelection: boolean; + Parent_IsPersonParent: boolean; + Parent_LoadAll: boolean; + Parent_OpenSearchList: boolean; + Parent_ParsedFormula: string; + Parent_ProcessID: number; + Parent_SelectRemainOption: boolean; + Parent_ShowInTree: boolean; + Parent_Type: string; + + ParsedValidation: string; // JSON string + ParsedValidationL2: string; + + ShowCondition: string; + + ShowInChildTable: boolean; + + Status: boolean; + + StepID: number; + StepName: string; + + TabTitle: string; + + Time_HasSecond: boolean; + + Type: number; // → FieldTypes enum عددی تو داری + + Unit: string; + + Validation: string; // JSON string + + ViewPermissionCount: number; +} + +// --------------------------- +// Process Information +// --------------------------- +export interface ProcessInfo { + ID: number; + ProcessName: string; + ProcessLatinName: string; + HasWorkflowsTable: boolean; + ShowInCalendar: boolean; + // می‌تونم اگر خواستی کامل‌ترش کنم +} + +// --------------------------- +// Step Information +// --------------------------- +export interface StepInfo { + XID: string; + TableName: string; + IsFinalStep: boolean; + HasChildField: boolean; + ProcessTitle: string; + // سایر چیزها قابل اضافه شدن هستند +} + +// --------------------------- +// Optional Structures +// --------------------------- +export interface StageInfo { + [key: string]: any; +} + +export interface StageHistoryInfo { + [key: string]: any; +} + +export interface WorkflowInfo { + [key: string]: any; +} diff --git a/src/core/utils/dynamic-field.utils.ts b/src/core/utils/dynamic-field.utils.ts new file mode 100644 index 0000000..0f8a38e --- /dev/null +++ b/src/core/utils/dynamic-field.utils.ts @@ -0,0 +1,239 @@ +export enum FieldTypes { + رشته_عادی = 0, + رشته_فارسی = 1, + رشته_لاتین = 2, + رشته_چندخطی = 3, + عدد_صحیح = 4, + عدد_اعشاری = 5, + تاریخ = 6, + ساعت = 7, + پول = 8, + کدملی = 9, + تلفن_همراه = 10, + پلاک_ماشین = 11, + رایانامه = 12, + آدرس_پایگاه_اینترنتی = 13, + کشویی = 14, + رادیویی = 15, + چندانتخابی = 16, + تصویر = 17, + فایل = 18, + توضیحات = 19, + محاسباتی = 20, + فرآیند_والد = 21, + فرآیند_فرزند = 22, + ویرایشگر_پارسر = 23, + چارت_سازمانی = 24, + پلاک_خودرو = 25, + جستجوی_شخص = 26, +} + +export interface FieldDefinition { + AllowNull: boolean; + CalculationField_Async: boolean; + CalculationField_IsFunctionOutput: boolean; + CalculationField_ReCalculateAfterSave: boolean; + + Calculation_Formula: string; + Calculation_ParsedFormula: string; + Calculation_ParsedFormulaL2: string; + + Child_OpenAgain: boolean; + Child_ProcessID: number; + Child_ShowCalendar: boolean; + + Date_Type: string; + + DefaultValue: string; + + Description_HasSaving: boolean; + Description_IsParser: boolean; + Description_Text: string; + + Document: string; + + FieldStyle_3Col: boolean; + FieldStyle_4Col: boolean; + + File_Extentions: string; + File_MaxSize: string; + + Help: string; + + ID: number; + + IsDashboardField: boolean; + IsNumeric: boolean; + IsUnique: boolean; + + LatinName: string; + Length: number | null; + + LockFirstValue: boolean; + + MaxValue: number | null; + MinValue: number | null; + + Name: string; + NameOrID: string; + + Option_IsChips: boolean; + Option_Options: string; // JSON string + + OrderNumber: number; + + Parent_CanBeSave: boolean; + Parent_DependentFields: string; // JSON string + Parent_FieldID: number; + Parent_Formula: string; + Parent_IsMultiSelection: boolean; + Parent_IsPersonParent: boolean; + Parent_LoadAll: boolean; + Parent_OpenSearchList: boolean; + Parent_ParsedFormula: string; + Parent_ProcessID: number; + Parent_SelectRemainOption: boolean; + Parent_ShowInTree: boolean; + Parent_Type: string; + + ParsedValidation: string; // JSON string + ParsedValidationL2: string; + + ShowCondition: string; + + ShowInChildTable: boolean; + + Status: boolean; + + StepID: number; + StepName: string; + + TabTitle: string; + + Time_HasSecond: boolean; + + Type: number | string; // → FieldTypes enum عددی تو داری + + Unit: string; + + Validation: string; // JSON string + + ViewPermissionCount: number; + // Add typeValue for client-side enum mapping + typeValue?: FieldTypes; + // Add parsed options and validation for client-side use + parsedOptions?: { label: string; value: string }[]; + parsedValidationRules?: { regex: string; message: string }[]; +} + +// Regex Validators +export const regexValidators = { + persian: /^[\u0600-\u06FF\s]+$/, // Persian characters and spaces + latin: /^[a-zA-Z0-9\s]+$/, // English characters, numbers, and spaces + mobile: /^09[0-9]{9}$/, // Iranian mobile number format + nationalId: /^[0-9]{10}$/, // Iranian national ID (Kodemeli) + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // Basic email validation + url: /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/, // Basic URL validation + carPlate: /^(\d{2}[آ-ی]\d{3}|\d{3}[آ-ی]\d{2})$/, // Example: 12ب345 or 123ب45 (simplified) +}; + +// Input Normalization (can be expanded as needed) +export const normalizeInput = (type: FieldTypes, value: any): any => { + if (value === null || value === undefined) return value; + + switch (type) { + case FieldTypes.عدد_صحیح: + case FieldTypes.عدد_اعشاری: + case FieldTypes.پول: + return Number(value); + default: + return value; + } +}; + +// Field type mapping (can be expanded to map to specific components) +export const fieldTypeMap = (type: number): string => { + switch (type) { + case FieldTypes.رشته_عادی: + case FieldTypes.رشته_فارسی: + case FieldTypes.رشته_لاتین: + case FieldTypes.کدملی: + case FieldTypes.تلفن_همراه: + case FieldTypes.رایانامه: + case FieldTypes.آدرس_پایگاه_اینترنتی: + case FieldTypes.پلاک_ماشین: + case FieldTypes.پلاک_خودرو: + case FieldTypes.جستجوی_شخص: // Map new type to text input + return "text"; + case FieldTypes.رشته_چندخطی: + return "textarea"; + case FieldTypes.عدد_صحیح: + return "number"; + case FieldTypes.عدد_اعشاری: + return "decimal"; // Custom type for decimal input + case FieldTypes.تاریخ: + return "date"; + case FieldTypes.ساعت: + return "time"; + case FieldTypes.پول: + return "currency"; // Custom type for currency input + case FieldTypes.کشویی: + return "select"; + case FieldTypes.رادیویی: + return "radio"; + case FieldTypes.چندانتخابی: + return "multi-select"; + case FieldTypes.تصویر: + return "image-upload"; + case FieldTypes.فایل: + return "file-upload"; + case FieldTypes.ویرایشگر_پارسر: + return "html-editor"; + case FieldTypes.توضیحات: + return "description"; + default: + return "text"; + } +}; + +// Placeholder for FormService +export const FormService = { + submitDynamicForm: async ( + processId: number, + stepId: number, + values: Record + ) => { + console.log("Submitting form:", { processId, stepId, values }); + // In a real application, this would make an API call + return new Promise((resolve) => + setTimeout(() => resolve({ success: true }), 1000) + ); + }, + fetchDependentOptions: async ( + parentFieldId: number, + parentFieldValue: any + ): Promise<{ label: string; value: string }[]> => { + console.log("Fetching dependent options for:", { + parentFieldId, + parentFieldValue, + }); + // Simulate an API call to fetch options based on parentFieldValue + return new Promise((resolve) => + setTimeout(() => { + if (parentFieldValue === "Option1") { + resolve([ + { label: "SubOption A", value: "SubOptionA" }, + { label: "SubOption B", value: "SubOptionB" }, + ]); + } else if (parentFieldValue === "Option2") { + resolve([ + { label: "SubOption C", value: "SubOptionC" }, + { label: "SubOption D", value: "SubOptionD" }, + ]); + } else { + resolve([]); + } + }, 500) + ); + }, +}; diff --git a/src/modules/dashboard/pages/campaigns/index.tsx b/src/modules/dashboard/pages/campaigns/index.tsx index 2557486..cc9af77 100644 --- a/src/modules/dashboard/pages/campaigns/index.tsx +++ b/src/modules/dashboard/pages/campaigns/index.tsx @@ -110,12 +110,17 @@ export function CampaignsPage() { const handleJoin = (campaign: Campaign) => { navigate( - `/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.joinToCampaing}?id=${campaign.WorkflowID}` + `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.joinToCampaing}?id=${campaign.WorkflowID}`, + { + replace: true, + } ); }; const showCampaing = (cId: number) => { - navigate(`/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}/${cId}`); + navigate(`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaing}/${cId}`, { + replace: true, + }); }; const renderSkeleton = () => ( @@ -235,6 +240,9 @@ export function CampaignsPage() {

{Number(campaign.signature_count)} عضو

+

+ {Number(campaign.comment_count)} کامنت +

diff --git a/src/modules/dashboard/pages/profile/index.tsx b/src/modules/dashboard/pages/profile/index.tsx index cdfd912..86f60f8 100644 --- a/src/modules/dashboard/pages/profile/index.tsx +++ b/src/modules/dashboard/pages/profile/index.tsx @@ -158,9 +158,7 @@ export function RegisterPage() { }, }); - const handleInputChange = ( - e: React.ChangeEvent - ) => { + const handleInputChange = (e: any) => { const { name, value } = e.target; setFormData((prev) => { @@ -185,6 +183,30 @@ export function RegisterPage() { })); }; + const handleDropDownChange = (e: { name: string; value: string }) => { + const { name, value } = e; + setFormData((prev) => { + if (name === "education_level") { + return { + ...prev, + education_level: value as "متوسطه اول" | "متوسطه دوم" | "", + base: "", + }; + } + + return { + ...prev, + [name]: value, + }; + }); + + setErrors((prev) => ({ + ...prev, + [name]: "", + ...(name === "education_level" && { base: "" }), + })); + }; + const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -272,11 +294,17 @@ export function RegisterPage() { label="مقطع تحصیلی" name="education_level" value={formData.education_level} - onChange={handleInputChange} + onChange={(e) => { + const model = { + name: "education_level", + value: e, + }; + handleDropDownChange(model); + }} error={errors.education_level} options={[ - { value: "متوسطه اول", label: "متوسطه اول" }, - { value: "متوسطه دوم", label: "متوسطه دوم" }, + { value: "متوسطه اول", name: "متوسطه اول" }, + { value: "متوسطه دوم", name: "متوسطه دوم" }, ]} /> @@ -284,20 +312,26 @@ export function RegisterPage() { label="پایه تحصیلی" name="base" value={formData.base} - onChange={handleInputChange} + onChange={(e) => { + const model = { + name: "base", + value: e, + }; + handleDropDownChange(model); + }} error={errors.base} options={ formData.education_level === "متوسطه اول" ? [ - { value: "هفتم", label: "پایه هفتم" }, - { value: "هشتم", label: "پایه هشتم" }, - { value: "نهم", label: "پایه نهم" }, + { value: "هفتم", name: "پایه هفتم" }, + { value: "هشتم", name: "پایه هشتم" }, + { value: "نهم", name: "پایه نهم" }, ] : formData.education_level === "متوسطه دوم" ? [ - { value: "دهم", label: "پایه دهم" }, - { value: "یازدهم", label: "پایه یازدهم" }, - { value: "دوازدهم", label: "پایه دوازدهم" }, + { value: "دهم", name: "پایه دهم" }, + { value: "یازدهم", name: "پایه یازدهم" }, + { value: "دوازدهم", name: "پایه دوازدهم" }, ] : [] } diff --git a/src/modules/dashboard/pages/step-form/dynamic-form.tsx b/src/modules/dashboard/pages/step-form/dynamic-form.tsx new file mode 100644 index 0000000..d551527 --- /dev/null +++ b/src/modules/dashboard/pages/step-form/dynamic-form.tsx @@ -0,0 +1,270 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, + type FC, +} from "react"; + +import { CustomButton } from "@/core/components/base/button"; +import FormField from "@/core/components/base/form-field"; +import { + FieldTypes, + FormService, + regexValidators, + type FieldDefinition, +} from "@/core/utils/dynamic-field.utils"; +import type { DynamicFormProps } from "./step-form.type"; + +const DynamicForm: FC = ({ fields, processId, stepId }) => { + const [formValues, setFormValues] = useState>({}); + const [_, setFormErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const initialValues: Record = {}; + fields.forEach((field) => { + if (field.DefaultValue !== undefined) { + initialValues[field.Name] = field.DefaultValue; + } else if (field.Type === FieldTypes.چندانتخابی) { + initialValues[field.Name] = []; + } else { + initialValues[field.Name] = ""; + } + }); + setFormValues(initialValues); + }, [fields]); + + const validateField = useCallback( + ( + field: FieldDefinition, + value: any, + allValues: Record + ): string | null => { + // Evaluate ShowCondition first. If not visible, no validation needed. + if ( + field.ShowCondition && + !evaluateShowCondition(field.ShowCondition, allValues) + ) { + return null; + } + + // Required validation + if ( + !field.AllowNull && + (value === null || + value === "" || + (Array.isArray(value) && value.length === 0)) + ) { + return "این فیلد الزامی است."; + } + + // Length validation for text fields + if ( + field.Length && + typeof value === "string" && + value.length > field.Length + ) { + return `حداکثر طول مجاز ${field.Length} کاراکتر است.`; + } + + // Min/Max validation for numeric fields + if ( + (field.Type === FieldTypes.عدد_صحیح || + field.Type === FieldTypes.عدد_اعشاری || + field.Type === FieldTypes.پول) && + typeof value === "number" + ) { + if (field && field.MinValue !== undefined && value < field?.MinValue) { + return `مقدار باید بزرگتر یا مساوی ${field.MinValue} باشد.`; + } + if (field && field.MaxValue !== undefined && value > field?.MaxValue) { + return `مقدار باید کوچکتر یا مساوی ${field.MaxValue} باشد.`; + } + } + + // Regex validation (from dynamic-field.utils.ts) + switch (field.Type) { + case FieldTypes.رشته_فارسی: + if (value && !regexValidators.persian.test(value)) { + return "فقط حروف فارسی مجاز است."; + } + break; + case FieldTypes.رشته_لاتین: + if (value && !regexValidators.latin.test(value)) { + return "فقط حروف انگلیسی و اعداد مجاز است."; + } + break; + case FieldTypes.تلفن_همراه: + if (value && !regexValidators.mobile.test(value)) { + return "شماره تلفن همراه معتبر نیست."; + } + break; + case FieldTypes.کدملی: + if (value && !regexValidators.nationalId.test(value)) { + return "کدملی معتبر نیست."; + } + break; + case FieldTypes.رایانامه: + if (value && !regexValidators.email.test(value)) { + return "آدرس رایانامه معتبر نیست."; + } + break; + case FieldTypes.آدرس_پایگاه_اینترنتی: + if (value && !regexValidators.url.test(value)) { + return "آدرس پایگاه اینترنتی معتبر نیست."; + } + break; + case FieldTypes.پلاک_ماشین: + case FieldTypes.پلاک_خودرو: + if (value && !regexValidators.carPlate.test(value)) { + return "فرمت پلاک خودرو معتبر نیست."; + } + break; + default: + break; + } + + // Custom ParsedValidation + if (field.parsedValidationRules && value) { + for (const validationRule of field.parsedValidationRules) { + const regex = new RegExp(validationRule.regex); + if (!regex.test(value)) { + return validationRule.message; + } + } + } + + return null; + }, + [] + ); + + const handleFieldChange = useCallback( + (fieldName: string, newValue: unknown) => { + setFormValues((prevValues) => { + const updatedValues = { + ...prevValues, + [fieldName]: newValue, + }; + + // Re-validate the field immediately + const fieldDef = fields.find((f) => f.Name === fieldName); + if (fieldDef) { + const error = validateField(fieldDef, newValue, updatedValues); + setFormErrors((prevErrors) => ({ + ...prevErrors, + [fieldName]: error, + })); + } + return updatedValues; + }); + }, + [fields, validateField] + ); + + const evaluateShowCondition = useCallback( + (condition: string, currentFormValues: Record): boolean => { + try { + // This is a simplified evaluation. In a real app, you'd use a more robust expression parser. + // Example: "FieldName === 'someValue'" + // For now, we'll just check for simple equality. + const parts = condition.split("===").map((s) => s.trim()); + if (parts.length === 2) { + const fieldName = parts[0]; + const expectedValue = parts[1].replace(/^['"]|['"]$/g, ""); // Remove quotes + return currentFormValues[fieldName] == expectedValue; + } + } catch (e) { + console.error("Error evaluating ShowCondition:", e); + } + return true; // Default to visible if condition cannot be parsed + }, + [] + ); + + const visibleFields = useMemo(() => { + return fields.filter((field) => { + if (field.ShowCondition) { + return evaluateShowCondition(field.ShowCondition, formValues); + } + return true; + }); + }, [fields, evaluateShowCondition, formValues]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + const newErrors: Record = {}; + let hasErrors = false; + + for (const field of visibleFields) { + const value = formValues[field.Name]; + const error = validateField(field, value, formValues); // Pass formValues for conditional validation + newErrors[field.Name] = error; + if (error) { + hasErrors = true; + } + } + + setFormErrors(newErrors); + + if (!hasErrors) { + try { + await FormService.submitDynamicForm(processId, stepId, formValues); + alert("Form submitted successfully!"); + // Optionally reset form or navigate + } catch (err) { + console.error("Form submission failed:", err); + alert("Form submission failed."); + } + } + setIsSubmitting(false); + }; + + return ( +
+
+ {visibleFields.map((field) => { + const colSpanClass = field.FieldStyle_4Col + ? "lg:col-span-3" // 4 columns in a 12-column grid + : field.FieldStyle_3Col + ? "lg:col-span-4" // 3 columns in a 12-column grid + : "lg:col-span-6"; // Default to 2 columns in a 12-column grid + + return ( +
+ + + handleFieldChange(field.Name, newValue) + } + allFormValues={formValues} // Pass all form values for dependent fields + /> +
+ ); + })} +
+
+ + {isSubmitting ? "در حال ارسال..." : "ارسال فرم"} + +
+
+ ); +}; + +export default DynamicForm; diff --git a/src/modules/dashboard/pages/step-form/index.tsx b/src/modules/dashboard/pages/step-form/index.tsx new file mode 100644 index 0000000..269efb0 --- /dev/null +++ b/src/modules/dashboard/pages/step-form/index.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState, type FC } from "react"; + +import type { WorkflowResponse } from "@/core/types/global.type"; +import type { FieldDefinition as LocalFieldDefinition } from "@core/utils/dynamic-field.utils"; +import { FieldTypes } from "@core/utils/dynamic-field.utils"; +import { fetchFieldService } from "@modules/dashboard/service/dynamic-form.service"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import DynamicForm from "./dynamic-form"; + +const StepFormPage: FC = () => { + const [fields, setFields] = useState([]); + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useQuery({ + queryKey: ["dynamic-field", id], + queryFn: fetchFieldService, + }); + + useEffect(() => { + if (data && data.Fields) { + const processedFields: LocalFieldDefinition[] = data.Fields.map((el) => { + const field: LocalFieldDefinition = { + ID: el.ID, + Name: el.Name, + LatinName: el.LatinName, + Type: el.Type, + DefaultValue: el.DefaultValue, + Length: el.Length, + MinValue: el.MinValue, + MaxValue: el.MaxValue, + Option_Options: el.Option_Options, + IsNumeric: el.IsNumeric, + Validation: el.Validation, + ParsedValidation: el.ParsedValidation, + Help: el.Help, + Parent_ProcessID: el.Parent_ProcessID, + Parent_FieldID: el.Parent_FieldID, + FieldStyle_3Col: el.FieldStyle_3Col, + FieldStyle_4Col: el.FieldStyle_4Col, + ShowCondition: el.ShowCondition, + AllowNull: el.AllowNull, + CalculationField_Async: el.CalculationField_Async, + CalculationField_IsFunctionOutput: + el.CalculationField_IsFunctionOutput, + CalculationField_ReCalculateAfterSave: + el.CalculationField_ReCalculateAfterSave, + Calculation_Formula: el.Calculation_Formula, + Calculation_ParsedFormula: el.Calculation_ParsedFormula, + Calculation_ParsedFormulaL2: el.Calculation_ParsedFormulaL2, + Child_OpenAgain: el.Child_OpenAgain, + Child_ProcessID: el.Child_ProcessID, + Child_ShowCalendar: el.Child_ShowCalendar, + Date_Type: el.Date_Type, + Description_HasSaving: el.Description_HasSaving, + Description_IsParser: el.Description_IsParser, + Description_Text: el.Description_Text, + Document: el.Document, + File_Extentions: el.File_Extentions, + File_MaxSize: el.File_MaxSize, + IsDashboardField: el.IsDashboardField, + IsUnique: el.IsUnique, + LockFirstValue: el.LockFirstValue, + NameOrID: el.NameOrID, + Option_IsChips: el.Option_IsChips, + OrderNumber: el.OrderNumber, + Parent_CanBeSave: el.Parent_CanBeSave, + Parent_DependentFields: el.Parent_DependentFields, + Parent_Formula: el.Parent_Formula, + Parent_IsMultiSelection: el.Parent_IsMultiSelection, + Parent_IsPersonParent: el.Parent_IsPersonParent, + Parent_LoadAll: el.Parent_LoadAll, + Parent_OpenSearchList: el.Parent_OpenSearchList, + Parent_ParsedFormula: el.Parent_ParsedFormula, + Parent_SelectRemainOption: el.Parent_SelectRemainOption, + Parent_ShowInTree: el.Parent_ShowInTree, + Parent_Type: el.Parent_Type, + ParsedValidationL2: el.ParsedValidationL2, + ShowInChildTable: el.ShowInChildTable, + Status: el.Status, + StepID: el.StepID, + StepName: el.StepName, + TabTitle: el.TabTitle, + Time_HasSecond: el.Time_HasSecond, + Unit: el.Unit, + ViewPermissionCount: el.ViewPermissionCount, + typeValue: el.Type as FieldTypes, // Map number to enum + }; + + if (el.Option_Options) { + try { + field.parsedOptions = JSON.parse(el.Option_Options); + } catch (e) { + console.error( + `Error parsing Option_Options for field ${el.ID}:`, + e + ); + field.parsedOptions = []; + } + } + + if (el.ParsedValidation) { + try { + field.parsedValidationRules = JSON.parse(el.ParsedValidation); + } catch (e) { + console.error( + `Error parsing ParsedValidation for field ${el.ID}:`, + e + ); + field.parsedValidationRules = []; + } + } + return field; + }); + setFields(processedFields); + } + }, [data]); + + if (isLoading) { + return ( +
+ در حال بارگذاری فرم... +
+ ); + } + + if (error) { + return ( +
+ خطا در بارگذاری فرم: {error.message} +
+ ); + } + + return ( +
+

+ فرم پویا +

+ +
+ ); +}; + +export default StepFormPage; diff --git a/src/modules/dashboard/pages/step-form/step-form.type.ts b/src/modules/dashboard/pages/step-form/step-form.type.ts new file mode 100644 index 0000000..2d6498b --- /dev/null +++ b/src/modules/dashboard/pages/step-form/step-form.type.ts @@ -0,0 +1,7 @@ +import type { FieldDefinition } from "@/core/utils/dynamic-field.utils"; + +export interface DynamicFormProps { + fields: FieldDefinition[]; + processId: number; + stepId: number; +} diff --git a/src/modules/dashboard/routes/route.constant.ts b/src/modules/dashboard/routes/route.constant.ts index 88865a8..d45c9fe 100644 --- a/src/modules/dashboard/routes/route.constant.ts +++ b/src/modules/dashboard/routes/route.constant.ts @@ -5,4 +5,5 @@ export const DASHBOARD_ROUTE = { campaigns: "campaigns", campaing: "campaing", joinToCampaing: "join-to-campaing", + daynamicPage: "dynamic-page", }; diff --git a/src/modules/dashboard/routes/router.tsx b/src/modules/dashboard/routes/router.tsx index ffae5ba..c6bec61 100644 --- a/src/modules/dashboard/routes/router.tsx +++ b/src/modules/dashboard/routes/router.tsx @@ -5,6 +5,7 @@ import CampaignDetailPage from "../pages/campaigns/detail"; import JoinToCampaing from "../pages/join-to-campaing"; import DashboardPage from "../pages/main-page"; import ProfilePage from "../pages/profile"; +import StepFormPage from "../pages/step-form"; import { DASHBOARD_ROUTE } from "./route.constant"; export const dashboardRoutes: AppRoute[] = [ @@ -32,6 +33,10 @@ export const dashboardRoutes: AppRoute[] = [ path: `${DASHBOARD_ROUTE.joinToCampaing}/:id`, element: , }, + { + path: `${DASHBOARD_ROUTE.daynamicPage}/:id`, + element: , + }, ], }, ]; diff --git a/src/modules/dashboard/service/dynamic-form.service.ts b/src/modules/dashboard/service/dynamic-form.service.ts new file mode 100644 index 0000000..0c87a6a --- /dev/null +++ b/src/modules/dashboard/service/dynamic-form.service.ts @@ -0,0 +1,19 @@ +import { API_ADDRESS } from "@/core/service/api-address"; +import api from "@/core/service/axios"; +import type { WorkflowResponse } from "@/core/types/global.type"; +import to from "await-to-js"; +import { toast } from "react-toastify"; + +export const fetchFieldService = async (): Promise => { + const [err, res] = await to(api.post(API_ADDRESS.index, "1224")); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error(res.data.message || "خطا در دریافت فیلدها"); + throw new Error("خطا در دریافت فیلدها"); + } + const data = JSON.parse(res.data.data); + return data; +};