This commit is contained in:
2026-05-01 23:40:48 +03:30
parent cd8771bd8a
commit 0466b7dc75
15 changed files with 3486 additions and 1594 deletions
+381
View File
@@ -0,0 +1,381 @@
# Farmer Calendar Backend Requirements
این فایل قرارداد کامل داده‌ای برای صفحه `farmer-calendar` را توضیح می‌دهد؛ یعنی هر چیزی که فرانت برای نمایش، ساخت، ویرایش و حذف رویدادهای تقویم نیاز دارد باید از بک‌اند دریافت یا به بک‌اند ارسال کند.
## Scope
این مستند بر اساس این فایل‌ها تهیه شده است:
- `src/views/dashboards/farm/FarmerCalendarPage.tsx`
- `src/views/dashboards/farm/FarmerCalendarEventModal.tsx`
- `src/views/dashboards/farm/FarmerCalendarEventDetails.tsx`
- `src/views/apps/calendar/Calendar.tsx`
- `src/redux-store/slices/calendar.ts`
- `src/libs/api/services/eventService.ts`
## Frontend Features Covered
بک‌اند باید از این قابلیت‌های صفحه پشتیبانی کند:
- نمایش لیست رویدادهای تقویم
- فیلتر رویدادها بر اساس بازه زمانی و نوع تقویم
- نمایش جزییات رویداد
- ساخت رویداد جدید
- ویرایش رویداد
- حذف رویداد
- دریافت جداگانه‌ی لیست `tag`ها برای فرم ساخت/ویرایش
- پشتیبانی از drag/drop و resize رویدادها در تقویم
## Domain Model
هر رویداد تقویم باید حداقل ساختار زیر را داشته باشد:
```ts
type CalendarEvent = {
id: string;
title: string;
description: string;
deadline?: number | null;
tags: string[];
start: string;
end: string;
extendedProps?: Record<string, unknown>;
};
```
## Required Event Fields For Frontend
فیلدهای زیر برای عملکرد درست فرانت لازم هستند:
| Field | Type | Required | Used For |
|---|---|---:|---|
| `id` | `string` | yes | شناسایی رویداد برای edit/delete/update |
| `title` | `string` | yes | عنوان رویداد در تقویم، کارت‌ها، مودال جزییات |
| `description` | `string` | no but recommended | نمایش توضیح در کارت‌ها و جزییات |
| `start` | `ISO 8601 string` | yes | نمایش زمان شروع، تقویم، محاسبه امروز/این هفته |
| `end` | `ISO 8601 string` | yes | نمایش بازه زمانی، resize/drop |
| `tags` | `string[]` | yes | نمایش tag در جزییات، انتخاب مقدار در فرم |
| `deadline` | `number` | no | فعلا در state نگه‌داری می‌شود، بهتر است برگردد |
| `extendedProps` | `object` | no | برای توسعه آینده و سازگاری با FullCalendar |
## Endpoints
### 1) List Events
برای لود اولیه‌ی صفحه و رفرش رویدادها.
`GET /api/events`
#### Query Params
| Param | Type | Required | Description |
|---|---|---:|---|
| `start` | `string` | no | شروع بازه به فرمت ISO 8601 |
| `end` | `string` | no | پایان بازه به فرمت ISO 8601 |
#### Success Response
```json
{
"events": [
{
"id": "evt_101",
"title": "آبیاری بلوک شمالی",
"description": "کنترل فشار و مدت زمان آبیاری",
"deadline": 1734942600,
"tags": ["آبیاری"],
"start": "2025-02-24T06:30:00Z",
"end": "2025-02-24T08:00:00Z",
"extendedProps": {}
}
]
}
```
#### Frontend Notes
- اگر `events` خالی باشد باید `[]` برگردد، نه `null`
- تاریخ‌ها باید قابل parse شدن با `new Date(...)` و `parseISO(...)` باشند
- `start` و `end` برای همه رویدادها لازم‌اند
### 2) Get Event Details
برای سناریوهای آینده یا lazy loading جزییات.
`GET /api/events/:id`
#### Success Response
```json
{
"event": {
"id": "evt_101",
"title": "آبیاری بلوک شمالی",
"description": "کنترل فشار و مدت زمان آبیاری",
"deadline": 1734942600,
"tags": ["آبیاری"],
"start": "2025-02-24T06:30:00Z",
"end": "2025-02-24T08:00:00Z",
"extendedProps": {}
}
}
```
### 3) Create Event
برای ساخت تسک/رویداد روزانه‌ی جدید.
`POST /api/events`
#### Request Body
```json
{
"title": "بازدید آفت در گلخانه",
"description": "بررسی وضعیت برگ‌ها و ثبت گزارش",
"deadline": 1734971400,
"tags": ["آفت"],
"start": "2025-02-24T14:00:00Z",
"end": "2025-02-24T15:00:00Z",
"extendedProps": {}
}
```
#### Required Create Fields
- `title`
- `start`
- `end`
#### Success Response
```json
{
"event": {
"id": "evt_102",
"title": "بازدید آفت در گلخانه",
"description": "بررسی وضعیت برگ‌ها و ثبت گزارش",
"deadline": 1734971400,
"tags": ["آفت"],
"start": "2025-02-24T14:00:00Z",
"end": "2025-02-24T15:00:00Z",
"extendedProps": {}
}
}
```
### 4) Update Event
برای ویرایش دستی، drag/drop و resize.
`PUT /api/events/:id`
#### Request Body
```json
{
"title": "بازدید آفت در گلخانه",
"description": "اولویت بالا",
"deadline": 1734971400,
"tags": ["آفت", "فوری"],
"start": "2025-02-24T15:00:00Z",
"end": "2025-02-24T16:00:00Z",
"extendedProps": {}
}
```
#### Success Response
```json
{
"event": {
"id": "evt_102",
"title": "بازدید آفت در گلخانه",
"description": "اولویت بالا",
"deadline": 1734971400,
"tags": ["آفت", "فوری"],
"start": "2025-02-24T15:00:00Z",
"end": "2025-02-24T16:00:00Z",
"extendedProps": {}
}
}
```
#### Important Update Notes
- این endpoint باید برای هر دو حالت `form edit` و `calendar drag/resize` جواب بدهد
- تغییر `start` و `end` باید سریع و idempotent باشد
### 5) Delete Event
`DELETE /api/events/:id`
#### Success Response
```json
{
"success": true
}
```
## Separate Tags API
طبق نیاز این صفحه، لیست `tag`ها نباید از `events` استخراج شود و باید از یک API جداگانه دریافت شود.
### 6) List Event Tags
`GET /api/events/tags`
#### Purpose
- پر کردن `select` مربوط به tag در فرم ساخت/ویرایش
- جلوگیری از وابستگی UI به استخراج `tags` از event list
- بهینه‌تر شدن لود مودال افزودن رویداد
#### Success Response
```json
{
"tags": [
{
"id": "tag_irrigation",
"label": "آبیاری",
"value": "آبیاری"
},
{
"id": "tag_pest",
"label": "آفت",
"value": "آفت"
},
{
"id": "tag_harvest",
"label": "برداشت",
"value": "برداشت"
}
]
}
```
#### Minimum Accepted Alternative
اگر نخواهید آبجکت کامل برگردانید، این ساختار هم برای فرانت کافی است:
```json
{
"tags": ["آبیاری", "آفت", "برداشت"]
}
```
#### Recommendation
فرمت آبجکتی بهتر است، چون بعدا این قابلیت‌ها را ساده‌تر می‌کند:
- مرتب‌سازی
- disabled state
- رنگ یا آیکن برای هر tag
- localization
## Error Handling
برای همه endpointها بهتر است خطاها ساختار ثابت داشته باشند:
```json
{
"code": "EVENT_VALIDATION_ERROR",
"message": "Invalid event payload",
"details": {
"start": "start is required"
}
}
```
فرانت فعلی حداقل به یک `message` قابل‌نمایش نیاز دارد.
## Validation Rules Recommended
- `title` نباید خالی باشد
- `start` باید تاریخ معتبر باشد
- `end` باید تاریخ معتبر باشد
- `end` نباید قبل از `start` باشد
- `tags` اگر موجود است باید آرایه‌ای از string باشد
## Date/Time Requirements
- فرمت ترجیحی: `ISO 8601 UTC`, مثال: `2025-02-24T06:30:00Z`
- timezone باید در پاسخ‌ها صریح و قابل پیش‌بینی باشد
- بک‌اند نباید تاریخ مبهم بدون timezone برگرداند
## What Frontend Actually Renders Today
این فیلدها همین الان در UI استفاده می‌شوند:
- `title`
- `description`
- `start`
- `end`
- `tags[0]`
این فیلدها فعلا بیشتر برای سازگاری یا توسعه بعدی نگه‌داری می‌شوند:
- `deadline`
- `extendedProps`
## Performance Recommendations
- `GET /api/events` باید pagination یا date-range filtering را پشتیبانی کند، حتی اگر فعلا فرانت از آن به‌صورت کامل استفاده نکند
- `GET /api/events/tags` باید سبک و cacheable باشد
- پاسخ `GET /api/events/tags` بهتر است کوچک و بدون payload اضافی باشد
## Final Backend Checklist
- `GET /api/events`
- `GET /api/events/:id`
- `POST /api/events`
- `PUT /api/events/:id`
- `DELETE /api/events/:id`
- `GET /api/events/tags`
- پشتیبانی از `tags` به صورت آرایه
- بازگرداندن تاریخ‌ها با فرمت ISO 8601
## Suggested Stable Response Shapes
اگر بخواهید قرارداد بک‌اند کاملا پایدار و توسعه‌پذیر باشد، این دو response shape پیشنهاد می‌شوند:
### Events List
```json
{
"events": [
{
"id": "evt_101",
"title": "آبیاری بلوک شمالی",
"description": "کنترل فشار و مدت زمان آبیاری",
"deadline": 1734942600,
"tags": ["آبیاری"],
"start": "2025-02-24T06:30:00Z",
"end": "2025-02-24T08:00:00Z",
"extendedProps": {}
}
],
"meta": {
"total": 1
}
}
```
### Tags List
```json
{
"tags": [
{
"id": "tag_irrigation",
"label": "آبیاری",
"value": "آبیاری"
}
],
"meta": {
"total": 1
}
}
```
+451
View File
@@ -0,0 +1,451 @@
# Farmer Todos Backend Requirements
این فایل قرارداد کامل داده‌ای برای صفحه `farmer-todos` را توضیح می‌دهد؛ یعنی همه اطلاعاتی که فرانت باید از بک‌اند دریافت کند یا به بک‌اند ارسال کند تا صفحه تودولیست مزرعه به‌صورت کامل و قابل اتصال کار کند.
## Scope
این مستند بر اساس این فایل‌ها تهیه شده است:
- `src/views/dashboards/farm/todos/FarmerTodoPage.tsx`
- `src/libs/api/services/todoService.ts`
- `src/libs/api/services/taskService.ts`
## Frontend Features Covered
بک‌اند باید از این قابلیت‌های صفحه پشتیبانی کند:
- نمایش لیست تسک‌های مزرعه
- ثبت سریع تسک جدید با `عنوان + محل اجرا + روز + ساعت + اولویت`
- نمایش تسک‌ها بر اساس `همه / امروز / فوری / انجام شده`
- تغییر وضعیت تسک بین `باز` و `انجام شده`
- نمایش `note` و `tags`
- نمایش زمان‌بندی هر تسک با `scheduledDate` و `time`
- دریافت لیست محل‌های اجرا برای select فرم
- دریافت لیست tagها برای توسعه بعدی یا prefill
## Page Data Model
مدلی که این صفحه واقعا با آن کار می‌کند به این شکل است:
```ts
type TaskPriority = "زیاد" | "متوسط" | "کم";
type TaskStatus = "open" | "done";
type FarmerTodoTask = {
id: number | string;
title: string;
zone: string;
scheduledDate: string;
time: string;
priority: TaskPriority;
note: string;
tags: string[];
status: TaskStatus;
};
```
## Required Fields For Frontend
| Field | Type | Required | Used For |
|---|---|---:|---|
| `id` | `number \| string` | yes | toggle/update/render key |
| `title` | `string` | yes | عنوان کارت تسک |
| `zone` | `string` | yes | نمایش محل اجرا و select فرم |
| `scheduledDate` | `string` | yes | فیلتر امروز، مرتب‌سازی، نمایش روز |
| `time` | `string` | yes | نمایش ساعت و مرتب‌سازی |
| `priority` | `"زیاد" \| "متوسط" \| "کم"` | yes | رنگ، آیکن، فیلتر فوری |
| `note` | `string` | no but recommended | متن توضیح داخل کارت |
| `tags` | `string[]` | yes | نمایش برچسب‌ها |
| `status` | `"open" \| "done"` | yes | نمایش/فیلتر و checkbox |
## Date And Time Requirements
- `scheduledDate` باید برای فرانت قابل parse باشد
- فرمت پیشنهادی برای `scheduledDate`: `YYYY-MM-DD`
- `time` بهتر است فرمت `HH:mm` داشته باشد
- چون datepicker صفحه شمسی است، فرانت می‌تواند تاریخ را شمسی انتخاب کند اما بهتر است به بک‌اند معادل میلادی استاندارد بفرستد
- پیشنهاد نهایی: بک‌اند و فرانت روی ذخیره `scheduledDate` به شکل میلادی `YYYY-MM-DD` توافق کنند
## Recommended Task API
### 1) List Farmer Todo Tasks
`GET /api/farmer-todos`
#### Query Params
| Param | Type | Required | Description |
|---|---|---:|---|
| `status` | `open \| done` | no | فیلتر بر اساس وضعیت |
| `priority` | `high \| medium \| low` یا مقدار بومی | no | فیلتر اولویت |
| `date` | `YYYY-MM-DD` | no | فیلتر یک روز مشخص |
| `from` | `YYYY-MM-DD` | no | شروع بازه |
| `to` | `YYYY-MM-DD` | no | پایان بازه |
| `zone` | `string` | no | فیلتر بر اساس محل اجرا |
| `search` | `string` | no | جستجو در عنوان/یادداشت |
#### Success Response
```json
{
"tasks": [
{
"id": 101,
"title": "بررسی رطوبت ردیف شمالی",
"zone": "قطعه گندم - شمال مزرعه",
"scheduledDate": "2025-02-24",
"time": "06:30",
"priority": "زیاد",
"note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.",
"tags": ["آبیاری", "صبح زود"],
"status": "open"
}
],
"meta": {
"total": 1
}
}
```
#### Frontend Notes
- اگر `tasks` خالی باشد باید `[]` برگردد
- اگر بک‌اند enum داخلی دارد، بهتر است همینجا به مقادیر قابل‌استفاده فرانت map شود
- اگر بخواهید با `todoService` فعلی سازگار بمانید، یک adapter در frontend هم می‌تواند این mapping را انجام دهد
### 2) Create New Farmer Todo Task
`POST /api/farmer-todos`
#### Request Body
```json
{
"title": "بازدید پمپ جنوب",
"zone": "قطعه گندم - شمال مزرعه",
"scheduledDate": "2025-02-24",
"time": "07:00",
"priority": "متوسط",
"note": "بعد از ثبت انجام، اگر موردی غیرعادی بود برای شیفت بعدی یادداشت بگذار.",
"tags": ["روزانه", "ثبت دستی"],
"status": "open"
}
```
#### Minimum Required Fields
- `title`
- `zone`
- `scheduledDate`
- `time`
- `priority`
#### Success Response
```json
{
"task": {
"id": 102,
"title": "بازدید پمپ جنوب",
"zone": "قطعه گندم - شمال مزرعه",
"scheduledDate": "2025-02-24",
"time": "07:00",
"priority": "متوسط",
"note": "بعد از ثبت انجام، اگر موردی غیرعادی بود برای شیفت بعدی یادداشت بگذار.",
"tags": ["روزانه", "ثبت دستی"],
"status": "open"
}
}
```
### 3) Update Task
`PUT /api/farmer-todos/:id`
#### Use Cases
- تغییر وضعیت `open/done`
- ویرایش عنوان
- ویرایش زمان و روز
- ویرایش اولویت
- ویرایش محل اجرا
- ویرایش note و tags
#### Request Body Example
```json
{
"status": "done"
}
```
یا:
```json
{
"title": "نمونه برداری خاک",
"zone": "گلخانه شماره 2",
"scheduledDate": "2025-02-24",
"time": "09:15",
"priority": "زیاد",
"note": "سه نقطه برداشت شود.",
"tags": ["خاک", "آزمایش"],
"status": "open"
}
```
#### Success Response
```json
{
"task": {
"id": 102,
"title": "نمونه برداری خاک",
"zone": "گلخانه شماره 2",
"scheduledDate": "2025-02-24",
"time": "09:15",
"priority": "زیاد",
"note": "سه نقطه برداشت شود.",
"tags": ["خاک", "آزمایش"],
"status": "open"
}
}
```
### 4) Delete Task
اگرچه UI فعلی دکمه حذف ندارد، برای کامل شدن flow بهتر است endpoint حذف موجود باشد.
`DELETE /api/farmer-todos/:id`
#### Success Response
```json
{
"success": true
}
```
## Separate Zones API
در UI فعلی `zone`ها hardcoded هستند. برای اتصال واقعی بهتر است از بک‌اند دریافت شوند.
### 5) List Task Zones
`GET /api/farmer-todos/zones`
#### Success Response
```json
{
"zones": [
{
"id": "zone_north_wheat",
"label": "قطعه گندم - شمال مزرعه",
"value": "قطعه گندم - شمال مزرعه"
},
{
"id": "greenhouse_2",
"label": "گلخانه شماره 2",
"value": "گلخانه شماره 2"
},
{
"id": "central_warehouse",
"label": "انبار مرکزی",
"value": "انبار مرکزی"
}
]
}
```
#### Minimum Accepted Alternative
```json
{
"zones": [
"قطعه گندم - شمال مزرعه",
"گلخانه شماره 2",
"انبار مرکزی"
]
}
```
## Separate Tags API
این صفحه `tags` را نمایش می‌دهد و برای create/edit هم می‌تواند به لیست آماده نیاز داشته باشد، بنابراین بهتر است API جداگانه داشته باشد.
### 6) List Todo Tags
`GET /api/farmer-todos/tags`
#### Success Response
```json
{
"tags": [
{
"id": "tag_irrigation",
"label": "آبیاری",
"value": "آبیاری"
},
{
"id": "tag_daily",
"label": "روزانه",
"value": "روزانه"
}
]
}
```
## Optional Summary API
الان فرانت آمارهای زیر را از خود لیست taskها محاسبه می‌کند:
- تعداد کارهای امروز
- تعداد انجام شده
- تعداد فوری
- درصد پیشرفت
- اولین تسک بعدی
اگر بخواهید لود صفحه سبک‌تر شود، این API اختیاری مفید است:
### 7) Farmer Todos Summary
`GET /api/farmer-todos/summary`
#### Success Response
```json
{
"todayTasksCount": 3,
"completedCount": 1,
"urgentCount": 2,
"progressValue": 25,
"nextTask": {
"id": 101,
"title": "بررسی رطوبت ردیف شمالی",
"zone": "قطعه گندم - شمال مزرعه",
"scheduledDate": "2025-02-24",
"time": "06:30",
"priority": "زیاد",
"note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.",
"tags": ["آبیاری", "صبح زود"],
"status": "open"
}
}
```
این endpoint ضروری نیست، چون صفحه فعلی می‌تواند summary را از `GET /api/farmer-todos` بسازد.
## Validation Rules Recommended
- `title` نباید خالی باشد
- `zone` نباید خالی باشد
- `scheduledDate` باید تاریخ معتبر باشد
- `time` باید فرمت معتبر `HH:mm` داشته باشد
- `priority` باید یکی از `زیاد / متوسط / کم` یا enum معادل آن باشد
- `status` باید یکی از `open / done` باشد
- `tags` اگر ارسال می‌شود باید آرایه‌ای از string باشد
## Error Handling
ساختار پیشنهادی خطا:
```json
{
"code": "TASK_VALIDATION_ERROR",
"message": "Invalid farmer todo payload",
"details": {
"scheduledDate": "scheduledDate is required"
}
}
```
فرانت حداقل به یک `message` قابل‌نمایش نیاز دارد.
## Mapping Notes If Reusing Existing Todo Service
اگر بخواهید از `todoService` فعلی استفاده کنید، این mapping لازم می‌شود:
| UI Field | Current `todoService` Field |
|---|---|
| `title` | `title` |
| `note` | `description` |
| `scheduledDate` | `startDate` یا `dueDate` |
| `time` | بهتر است field جداگانه داشته باشد؛ در `todoService` فعلی مستقیم وجود ندارد |
| `priority` | نیاز به mapping بین `high/medium/low` و `زیاد/متوسط/کم` |
| `status` | نیاز به mapping بین `pending/completed` و `open/done` |
| `tags` | `tags` |
| `zone` | field جداگانه لازم دارد؛ در `todoService` فعلی مستقیم وجود ندارد |
### Important Note
برای `farmer-todos` بهتر است endpoint/domain جداگانه داشته باشید، چون این صفحه به شکل صریح به `zone`, `scheduledDate`, `time` و اولویت محلی نیاز دارد و `todoService` عمومی فعلی دقیقاً این مدل را پوشش نمی‌دهد.
## Final Backend Checklist
- `GET /api/farmer-todos`
- `POST /api/farmer-todos`
- `PUT /api/farmer-todos/:id`
- `DELETE /api/farmer-todos/:id`
- `GET /api/farmer-todos/zones`
- `GET /api/farmer-todos/tags`
- پشتیبانی از `scheduledDate`
- پشتیبانی از `time`
- پشتیبانی از `zone`
- پشتیبانی از `priority`
- پشتیبانی از `status`
- پشتیبانی از `tags`
## Suggested Stable Response Shapes
### Tasks List
```json
{
"tasks": [
{
"id": 101,
"title": "بررسی رطوبت ردیف شمالی",
"zone": "قطعه گندم - شمال مزرعه",
"scheduledDate": "2025-02-24",
"time": "06:30",
"priority": "زیاد",
"note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.",
"tags": ["آبیاری", "صبح زود"],
"status": "open"
}
],
"meta": {
"total": 1
}
}
```
### Zones List
```json
{
"zones": [
{
"id": "zone_north_wheat",
"label": "قطعه گندم - شمال مزرعه",
"value": "قطعه گندم - شمال مزرعه"
}
]
}
```
### Tags List
```json
{
"tags": [
{
"id": "tag_daily",
"label": "روزانه",
"value": "روزانه"
}
]
}
```
+126
View File
@@ -873,5 +873,131 @@
"empty": "هنوز مکالمه‌ای ندارید",
"chatLabel": "چت"
}
},
"farmerCalendar": {
"categories": {
"personal": "کارهای مزرعه",
"business": "عملیات",
"family": "تیم",
"holiday": "برداشت",
"etc": "پشتیبانی"
},
"fallbacks": {
"untitledTask": "وظیفه بدون عنوان"
},
"insights": {
"today": {
"label": "امروز",
"hint": "کارهای روی نقشه مزرعه"
},
"thisWeek": {
"label": "این هفته",
"hint": "بازه های برنامه ریزی شده"
},
"allDay": {
"label": "تمام روز",
"hint": "کارهای طولانی مدت"
}
},
"sidebar": {
"planningPulse": "نبض برنامه ریزی",
"farmWeekAtGlance": "نمای سریع هفته مزرعه",
"navigator": {
"title": "مسیریاب",
"description": "به هر تاریخ بروید و روی بازه زمانی قابل مشاهده تمرکز کنید."
},
"focusLanes": {
"title": "مسیرهای تمرکز",
"description": "رویدادها را بر اساس جریان کاری فیلتر کنید.",
"allVisible": "همه قابل مشاهده",
"activeCount": "{count} مورد فعال",
"clearFilters": "پاک کردن فیلترها",
"viewAll": "نمایش همه",
"categoryCount": "{label} ({count})"
},
"upcomingActions": {
"title": "اقدام های پیش رو",
"description": "مهم ترین بازه های بعدی از تقویم شما.",
"empty": "هنوز رویداد آینده ای ندارید. با افزودن اقدام بعدی مزرعه شروع کنید."
}
},
"hero": {
"badge": "مرکز برنامه ریزی کشاورز",
"title": "کارهای روزانه مزرعه را به یک ریتم تصویری روشن تبدیل کنید.",
"description": "عملیات، بازه های برداشت و فعالیت های تیم را در یک نما با تقویمی آرام تر و هدفمندتر مدیریت کنید.",
"addFarmEvent": "افزودن رویداد مزرعه",
"jumpToToday": "رفتن به امروز",
"openPlannerTools": "باز کردن ابزارهای برنامه ریزی",
"nextCheckpoint": "ایستگاه بعدی",
"noScheduledAction": "اقدام برنامه ریزی نشده",
"noScheduledActionDescription": "برای شروع ثبت آبیاری، پایش، برداشت یا برنامه های تیمی از دکمه افزودن استفاده کنید.",
"nextCheckpointAllDay": "{date} - تمام روز",
"nextCheckpointTimed": "{date} ساعت {time}",
"categoryCount": "{label}: {count}"
},
"calendar": {
"title": "تقویم فرمان مزرعه",
"description": "رویدادها را مستقیما در بوم زمان بندی جابه جا، تغییر اندازه یا ایجاد کنید.",
"visibleCount": "{count} مورد قابل مشاهده",
"todayCount": "{count} مورد امروز",
"thisWeekCount": "{count} مورد این هفته",
"allDaySuffix": " - تمام روز",
"timeSuffix": " - {time}"
},
"drawer": {
"title": "ابزارهای برنامه ریزی",
"description": "فیلترها، جابه جایی بین تاریخ ها و اقدام های پیش رو."
},
"modal": {
"titleCreate": "افزودن رویداد جدید",
"titleEdit": "ویرایش رویداد",
"description": "جزئیات رویداد مزرعه را ثبت کنید.",
"close": "بستن",
"fields": {
"title": "عنوان",
"tag": "برچسب",
"tagPlaceholder": "انتخاب برچسب",
"startDate": "تاریخ شروع",
"endDate": "تاریخ پایان",
"allDay": "تمام روز",
"description": "توضیحات"
},
"actions": {
"cancel": "انصراف",
"create": "افزودن رویداد",
"update": "بروزرسانی",
"delete": "حذف"
},
"validation": {
"titleRequired": "عنوان رویداد الزامی است"
}
},
"details": {
"title": "جزئیات رویداد",
"subtitle": "اطلاعات کامل رویداد انتخاب شده را ببینید.",
"close": "بستن",
"untitled": "رویداد بدون عنوان",
"allDay": "تمام روز",
"emptyTag": "برچسبی ثبت نشده است.",
"emptyDescription": "برای این رویداد توضیحی ثبت نشده است.",
"timeRange": "{start} تا {end}",
"fields": {
"date": "تاریخ",
"time": "زمان",
"tag": "برچسب",
"description": "توضیحات"
},
"actions": {
"close": "بستن",
"edit": "ویرایش رویداد"
},
"categories": {
"personal": "کارهای مزرعه",
"business": "عملیات",
"family": "تیم",
"holiday": "برداشت",
"etc": "پشتیبانی"
}
}
}
}
+4 -10
View File
@@ -71,7 +71,7 @@
"react-apexcharts": "1.4.1",
"react-chartjs-2": "^5.3.1",
"react-colorful": "5.6.1",
"react-date-object": "1.1.9",
"react-date-object": "^2.1.9",
"react-datepicker": "7.3.0",
"react-dom": "18.3.1",
"react-dropzone": "14.3.5",
@@ -9044,9 +9044,9 @@
}
},
"node_modules/react-date-object": {
"version": "1.1.9",
"resolved": "https://mirror-npm.runflare.com/react-date-object/-/react-date-object-1.1.9.tgz",
"integrity": "sha512-NEbp/JSqwpF3nm4bXcez9XGwmlpOjSPJSYoRyPC1XOxzRHHaijp6xjMbYpyKB3O4yrluxsbwBHbO1WgeFKip3Q==",
"version": "2.1.9",
"resolved": "https://mirror-npm.runflare.com/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-datepicker": {
@@ -9197,12 +9197,6 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-multi-date-picker/node_modules/react-date-object": {
"version": "2.1.9",
"resolved": "https://mirror-npm.runflare.com/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-onclickoutside": {
"version": "6.13.1",
"license": "MIT",
+1 -1
View File
@@ -76,7 +76,7 @@
"react-apexcharts": "1.4.1",
"react-chartjs-2": "^5.3.1",
"react-colorful": "5.6.1",
"react-date-object": "1.1.9",
"react-date-object": "^2.1.9",
"react-datepicker": "7.3.0",
"react-dom": "18.3.1",
"react-dropzone": "14.3.5",
+10 -37
View File
@@ -9,7 +9,7 @@ import { useTheme } from '@mui/material/styles'
import type { BoxProps } from '@mui/material/Box'
// Third-party Imports
import { Calendar } from 'react-multi-date-picker'
import DatePicker from 'react-multi-date-picker'
import DateObject from 'react-date-object'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
@@ -20,11 +20,12 @@ import 'react-multi-date-picker/styles/colors/teal.css'
interface AppJalaliDatepickerProps {
value?: Date | null
onChange?: (date: Date) => void
placeholder?: string
boxProps?: BoxProps
}
const AppJalaliDatepicker = (props: AppJalaliDatepickerProps) => {
const { value: externalValue, onChange, boxProps } = props
const { value: externalValue, onChange, placeholder, boxProps } = props
const theme = useTheme()
const [internalValue, setInternalValue] = useState<DateObject | undefined>(() => {
@@ -51,46 +52,18 @@ const AppJalaliDatepicker = (props: AppJalaliDatepickerProps) => {
return (
<Box
{...boxProps}
sx={{
...(typeof boxProps?.sx === 'object' ? boxProps.sx : {}),
'& .rmdp-container': {
width: '100%'
},
'& .rmdp-wrapper': {
boxShadow: 'none !important',
border: 'none !important',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
fontFamily: theme.typography.fontFamily
},
'& .rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden)': {
color: 'var(--mui-palette-text-primary)'
},
'& .rmdp-day.rmdp-selected span:not(.highlight)': {
backgroundColor: 'var(--mui-palette-primary-main) !important',
color: 'var(--mui-palette-common-white) !important'
},
'& .rmdp-day.rmdp-today span': {
backgroundColor: 'var(--mui-palette-primary-lightOpacity)',
color: 'var(--mui-palette-primary-main)'
},
'& .rmdp-week-day': {
color: 'var(--mui-palette-text-primary)'
},
'& .rmdp-header-values': {
color: 'var(--mui-palette-text-primary)'
},
'& .rmdp-arrow': {
borderColor: 'var(--mui-palette-text-secondary)'
}
}}
>
<Calendar
<DatePicker
value={displayValue}
onChange={handleChange as any}
calendar={persian}
locale={persian_fa}
className="teal"
format='YYYY/MM/DD'
calendarPosition='bottom-right'
inputClass='rmdp-input'
placeholder={placeholder || 'انتخاب تاریخ'}
className='teal'
/>
</Box>
)
+242 -504
View File
@@ -1,526 +1,264 @@
'use client'
"use client";
// React Imports
import type { ComponentProps } from 'react'
import type { ComponentProps, ReactElement } from "react";
import { cloneElement, isValidElement } from "react";
// MUI imports
import Box from '@mui/material/Box'
import { styled } from '@mui/material/styles'
import type { BoxProps } from '@mui/material/Box'
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { BoxProps } from "@mui/material/Box";
// Third-party Imports
import ReactDatePickerComponent from 'react-datepicker'
import DatePicker, { Calendar } from "react-multi-date-picker";
import type { DateObject } from "react-multi-date-picker";
import type { Value } from "react-multi-date-picker";
import TimePicker from "react-multi-date-picker/plugins/time_picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
// Styles
import 'react-datepicker/dist/react-datepicker.css'
import "react-multi-date-picker/styles/colors/teal.css";
type Props = ComponentProps<typeof ReactDatePickerComponent> & {
boxProps?: BoxProps
}
type Props = Omit<
ComponentProps<typeof DatePicker>,
"value" | "onChange" | "render" | "calendar" | "locale"
> & {
boxProps?: BoxProps;
selected?: Date | null;
startDate?: Date | null;
endDate?: Date | null;
selectsRange?: boolean;
showTimeSelect?: boolean;
customInput?: ReactElement;
placeholderText?: string;
inline?: boolean;
dateFormat?: string;
minDate?: Date | string | number | null;
maxDate?: Date | string | number | null;
shouldCloseOnSelect?: boolean;
onChange?: (
value: Date | [Date | null, Date | null] | null,
event?: unknown,
) => void;
onSelect?: (value: Date | null) => void;
};
// Styled Components
const StyledReactDatePicker = styled(Box)<BoxProps>(({ theme }) => {
return {
'& .react-datepicker-popper': {
zIndex: 20,
paddingTop: `${theme.spacing(0.5)} !important`
},
'& .react-datepicker-wrapper': {
width: '100%'
},
'& .react-datepicker__triangle': {
display: 'none'
},
'& .react-datepicker': {
color: 'var(--mui-palette-text-primary)',
borderRadius: 'var(--mui-shape-borderRadius)',
fontFamily: theme.typography.fontFamily,
backgroundColor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-customShadows-md)',
border: 'none',
'& .react-datepicker__header': {
padding: 0,
border: 'none',
fontWeight: 'normal',
backgroundColor: 'var(--mui-palette-background-paper)',
'& .react-datepicker__current-month, &.react-datepicker-year-header': {
textAlign: 'left'
},
'&:not(.react-datepicker-year-header)': {
'& + .react-datepicker__month, & + .react-datepicker__year': {
margin: theme.spacing(2),
marginTop: theme.spacing(4.5)
}
},
'&.react-datepicker-year-header': {
'& + .react-datepicker__month, & + .react-datepicker__year': {
margin: theme.spacing(2),
marginTop: theme.spacing(0)
}
}
},
'& > .react-datepicker__navigation': {
top: 13,
borderRadius: '50%',
backgroundColor: 'var(--mui-palette-action-selected)',
'&.react-datepicker__navigation--previous': {
width: 30,
height: 30,
border: 'none',
top: 12,
left: 'auto',
right: '57px',
...(theme.direction === 'ltr'
? {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(47 43 61 / 0.7)' d='M17.5 10L12.5 15L17.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")",
...theme.applyStyles('dark', {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(225 222 245 / 0.7)' d='M17.5 10L12.5 15L17.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")"
})
}
: {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(47 43 61 / 0.7)' d='M12.5 10L17.5 15L12.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")",
...theme.applyStyles('dark', {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(225 222 245 / 0.7)' d='M12.5 10L17.5 15L12.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")"
})
}),
'& .react-datepicker__navigation-icon': {
display: 'none'
},
'&:has(+ .react-datepicker__navigation--next--with-time)':
theme.direction === 'ltr' ? { right: 177 } : { left: 177 }
},
'&.react-datepicker__navigation--next': {
width: 30,
height: 30,
border: 'none',
top: 12,
right: 15,
left: 'auto',
...(theme.direction === 'ltr'
? {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(47 43 61 / 0.7)' d='M12.5 10L17.5 15L12.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")",
...theme.applyStyles('dark', {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(225 222 245 / 0.7)' d='M12.5 10L17.5 15L12.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")"
})
}
: {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(47 43 61 / 0.7)' d='M17.5 10L12.5 15L17.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")",
...theme.applyStyles('dark', {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(225 222 245 / 0.7)' d='M17.5 10L12.5 15L17.5 20' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E\")"
})
}),
'& .react-datepicker__navigation-icon': {
display: 'none'
}
},
'&.react-datepicker__navigation--next--with-time': theme.direction === 'ltr' ? { right: 135 } : { left: 135 },
'&:focus, &:active': {
outline: 0
}
},
'& .react-datepicker__current-month, & .react-datepicker-year-header': {
...theme.typography.subtitle1,
lineHeight: 2,
paddingBlockStart: theme.spacing(3),
paddingBlockEnd: theme.spacing(4.5),
paddingInline: theme.spacing(4),
color: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__day-name': {
margin: 0,
width: '2.25rem',
...theme.typography.subtitle2,
color: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__day-names': {
marginBottom: 0
},
'& .react-datepicker__day': {
margin: 0,
width: '2.25rem',
borderRadius: '50%',
lineHeight: '2.25rem',
color: 'var(--mui-palette-text-primary)',
fontSize: theme.typography.body1.fontSize,
'&.react-datepicker__day--selected.react-datepicker__day--in-selecting-range.react-datepicker__day--selecting-range-start, &.react-datepicker__day--selected.react-datepicker__day--range-start.react-datepicker__day--in-range, &.react-datepicker__day--range-start':
{
borderRadius: '18px 0px 0px 18px;',
color: 'var(--mui-palette-common-white) !important',
backgroundColor: 'var(--mui-palette-primary-main) !important'
},
'&.react-datepicker__day--range-end.react-datepicker__day--in-range': {
borderRadius: '0px 18px 18px 0px',
color: 'var(--mui-palette-common-white) !important',
backgroundColor: 'var(--mui-palette-primary-main) !important'
},
'&:focus, &:active': {
outline: 0
},
'&.react-datepicker__day--outside-month, &.react-datepicker__day--disabled:not(.react-datepicker__day--selected)':
{
color: 'var(--mui-palette-text-disabled)',
'&:hover': {
backgroundColor: 'transparent'
}
},
'&.react-datepicker__day--highlighted, &.react-datepicker__day--highlighted:hover': {
color: 'var(--mui-palette-success-main)',
backgroundColor: 'var(--mui-palette-success-lightOpacity)',
'&.react-datepicker__day--selected': {
backgroundColor: 'var(--mui-palette-primary-main) !important'
}
}
},
'&:has(.react-datepicker__day--in-range)': {
'& > .react-datepicker__navigation': {
'&.react-datepicker__navigation--previous': {
...(theme.direction === 'ltr' ? { left: 15 } : { right: 15 })
}
},
'& .react-datepicker__header': {
'& .react-datepicker__current-month': {
textAlign: 'center'
}
}
},
'& .react-datepicker__day--in-range, & .react-datepicker__day--in-selecting-range': {
borderRadius: 0,
color: 'var(--mui-palette-primary-main) !important',
backgroundColor: 'var(--mui-palette-primary-lightOpacity) !important'
},
'& .react-datepicker__day--today': {
fontWeight: 'normal',
'&:not(.react-datepicker__day--selected):not(:empty)': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'var(--mui-palette-primary-lightOpacity)',
'&:hover': {
backgroundColor: 'var(--mui-palette-primary-mainOpacity)'
},
'&.react-datepicker__day--keyboard-selected': {
backgroundColor: 'var(--mui-palette-primary-lightOpacity)',
'&:hover': {
backgroundColor: 'var(--mui-palette-primary-lightOpacity)'
}
}
}
},
'& .react-datepicker__month-text--today': {
fontWeight: 'normal',
'&:not(.react-datepicker__month-text--selected)': {
lineHeight: '2.125rem',
color: 'var(--mui-palette-primary-main)',
border: '1px solid var(--mui-palette-primary-main)',
'&:hover': {
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.04)'
}
}
},
'& .react-datepicker__year-text--today': {
fontWeight: 'normal',
'&:not(.react-datepicker__year-text--selected)': {
lineHeight: '2.125rem',
color: 'var(--mui-palette-primary-main)',
border: '1px solid var(--mui-palette-primary-main)',
'&:hover': {
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.04)'
},
'&.react-datepicker__year-text--keyboard-selected': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.06)',
'&:hover': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.06)'
}
}
}
},
'& .react-datepicker__day--keyboard-selected': {
'&:not(.react-datepicker__day--in-range)': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.16)',
'&:hover': {
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.16)'
}
}
},
'& .react-datepicker__month-text--keyboard-selected': {
'&:not(.react-datepicker__month--in-range)': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.16)',
'&:hover': {
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.16)'
}
}
},
'& .react-datepicker__year-text--keyboard-selected, & .react-datepicker__quarter-text--keyboard-selected': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'rgb(var(--mui-palette-primary-mainChannel) / 0.16)'
},
'& .react-datepicker__day--selected, & .react-datepicker__month-text--selected, & .react-datepicker__year-text--selected, & .react-datepicker__quarter-text--selected':
{
color: 'var(--mui-palette-common-white) !important',
backgroundColor: 'var(--mui-palette-primary-main) !important',
boxShadow: 'var(--mui-customShadows-primary-sm)',
'&:hover': {
backgroundColor: 'var(--mui-palette-primary-dark) !important'
}
},
'& .react-datepicker__header__dropdown': {
'& .react-datepicker__month-dropdown-container:not(:last-child)': {
marginRight: theme.spacing(8)
},
'& .react-datepicker__month-dropdown-container, & .react-datepicker__year-dropdown-container': {
marginBottom: theme.spacing(4)
},
'& .react-datepicker__month-read-view--selected-month, & .react-datepicker__year-read-view--selected-year': {
fontSize: '0.875rem',
marginRight: theme.spacing(1),
color: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__month-read-view:hover .react-datepicker__month-read-view--down-arrow, & .react-datepicker__year-read-view:hover .react-datepicker__year-read-view--down-arrow':
{
borderColor: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__month-read-view--down-arrow, & .react-datepicker__year-read-view--down-arrow': {
top: 4,
borderColor: 'var(--mui-palette-text-secondary)'
},
'& .react-datepicker__month-dropdown, & .react-datepicker__year-dropdown': {
padding: theme.spacing(2),
border: 'none',
borderRadius: 'var(--mui-shape-borderRadius)',
backgroundColor: 'var(--mui-palette-background-paper)',
boxShadow: 'var(--mui-customShadows-lg)',
'[data-skin="bordered"] &': {
boxShadow: 'none',
border: `1px solid var(--mui-palette-divider)`
}
},
'& .react-datepicker__month-option, & .react-datepicker__year-option': {
...theme.typography.body1,
padding: theme.spacing(1.5, 4),
borderRadius: 'var(--mui-shape-borderRadius)',
marginBlockEnd: theme.spacing(0.5),
'&:hover': {
backgroundColor: 'var(--mui-palette-action-hover)'
}
},
'& .react-datepicker__month-option.react-datepicker__month-option--selected_month': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'var(--mui-palette-primary-lightOpacity)',
'&:hover': {
backgroundColor: 'var(--mui-palette-primary-lightOpacity)'
},
'& .react-datepicker__month-option--selected': {
display: 'none'
}
},
'& .react-datepicker__year-option.react-datepicker__year-option--selected_year': {
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'var(--mui-palette-primary-lightOpacity)',
'&:hover': {
backgroundColor: 'var(--mui-palette-primary-lightOpacity)'
},
'& .react-datepicker__year-option--selected': {
display: 'none'
}
},
'& .react-datepicker__year-option': {
// TODO: Remove some of the following styles for arrow in Year dropdown when react-datepicker give arrows in Year dropdown
'& .react-datepicker__navigation--years-upcoming': {
width: 9,
height: 9,
borderStyle: 'solid',
borderWidth: '3px 3px 0 0',
transform: 'rotate(-45deg)',
borderTopColor: 'var(--mui-palette-text-secondary)',
borderRightColor: 'var(--mui-palette-text-secondary)',
margin: `${theme.spacing(2.75)} auto ${theme.spacing(0)}`
},
'&:hover .react-datepicker__navigation--years-upcoming': {
borderTopColor: 'var(--mui-palette-text-primary)',
borderRightColor: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__navigation--years-previous': {
width: 9,
height: 9,
borderStyle: 'solid',
borderWidth: '0 0 3px 3px',
transform: 'rotate(-45deg)',
borderLeftColor: 'var(--mui-palette-text-secondary)',
borderBottomColor: 'var(--mui-palette-text-secondary)',
margin: `${theme.spacing(0)} auto ${theme.spacing(2.75)}`
},
'&:hover .react-datepicker__navigation--years-previous': {
borderLeftColor: 'var(--mui-palette-text-primary)',
borderBottomColor: 'var(--mui-palette-text-primary)'
}
}
},
'& .react-datepicker__week-number': {
margin: 0,
fontWeight: 500,
width: '2.25rem',
lineHeight: '2.25rem',
fontSize: theme.typography.body2.fontSize,
color: 'var(--mui-palette-text-primary)'
},
'& .react-datepicker__month-text, & .react-datepicker__year-text, & .react-datepicker__quarter-text': {
margin: 0,
alignItems: 'center',
fontSize: theme.typography.body1.fontSize,
lineHeight: '2rem',
display: 'inline-flex',
justifyContent: 'center',
borderRadius: 'var(--mui-shape-borderRadius)',
'&:focus, &:active': {
outline: 0
}
},
'& .react-datepicker__year-wrapper': {
maxWidth: 205,
justifyContent: 'center'
},
'& .react-datepicker__input-time-container': {
display: 'flex',
alignItems: 'center',
...(theme.direction === 'rtl' ? { flexDirection: 'row-reverse' } : {})
},
'& .react-datepicker__today-button': {
borderTop: 0,
borderRadius: '1rem',
margin: theme.spacing(0, 4, 4),
color: 'var(--mui-palette-common-white)',
backgroundColor: 'var(--mui-palette-primary-main)'
},
const convertFormat = (format?: string) => {
if (!format) return "YYYY/MM/DD";
// Time Picker
'&:not(.react-datepicker--time-only)': {
'& .react-datepicker__time-container': {
borderLeftColor: 'var(--mui-palette-divider)',
[theme.breakpoints.down('sm')]: {
width: '5.5rem'
},
[theme.breakpoints.up('sm')]: {
width: '7.4375rem'
}
}
},
'&.react-datepicker--time-only': {
width: '7.4375rem',
'& .react-datepicker__time-container': {
width: '7.4375rem'
}
},
'& .react-datepicker__time-container': {
padding: theme.spacing(0.75, 0),
'& .react-datepicker-time__header': {
...theme.typography.subtitle2,
marginBottom: theme.spacing(3.5),
marginTop: theme.spacing(3.5),
color: 'var(--mui-palette-text-primary)'
},
return format
.replace(/yyyy/g, "YYYY")
.replace(/yy/g, "YY")
.replace(/dd/g, "DD")
.replace(/d/g, "D")
.replace(/HH/g, "HH")
.replace(/hh/g, "HH")
.replace(/mm/g, "mm");
};
'& .react-datepicker__time': {
background: 'var(--mui-palette-background-paper)',
'& .react-datepicker__time-box .react-datepicker__time-list-item--disabled': {
pointerEvents: 'none',
color: 'var(--mui-palette-text-disabled)',
'&.react-datepicker__time-list-item--selected': {
fontWeight: 'normal',
backgroundColor: 'var(--mui-palette-action-disabledBackground)'
}
}
},
const toDate = (value: Value | null | undefined): Date | null => {
if (!value) return null;
if (value instanceof Date) return value;
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
'& .react-datepicker__time-list-item': {
height: 'auto !important',
padding: `${theme.spacing(1.75, 0)} !important`,
marginLeft: theme.spacing(4.25),
marginRight: theme.spacing(2.2),
...theme.typography.body1,
color: 'var(--mui-palette-text-primary)',
borderRadius: 'var(--mui-shape-borderRadius)',
'&:focus, &:active': {
outline: 0
},
'&:hover': {
backgroundColor: 'var(--mui-palette-action-hover) !important'
},
'&.react-datepicker__time-list-item--selected:not(.react-datepicker__time-list-item--disabled)': {
fontWeight: 'normal',
color: 'var(--mui-palette-common-white) !important',
backgroundColor: 'var(--mui-palette-primary-main) !important',
boxShadow: 'var(--mui-customShadows-xs)'
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
},
if (Array.isArray(value)) return null;
if (typeof (value as DateObject).toDate === "function")
return (value as DateObject).toDate();
'& .react-datepicker__time-box': {
width: '100%'
},
'& .react-datepicker__time-list': {
'&::-webkit-scrollbar': {
width: 8
},
return null;
};
/* Track */
'&::-webkit-scrollbar-track': {
background: 'var(--mui-palette-background-paper)'
const baseSx: BoxProps["sx"] = {
"& .rmdp-container": {
width: "100%",
},
/* Handle */
'&::-webkit-scrollbar-thumb': {
borderRadius: 10,
background: '#aaa'
"& .rmdp-wrapper, & .rmdp-calendar": {
width: "100%",
border: "1px solid var(--mui-palette-divider)",
borderRadius: "var(--mui-shape-borderRadius)",
backgroundColor: "var(--mui-palette-background-paper)",
boxShadow: "var(--mui-customShadows-md)",
color: "var(--mui-palette-text-primary)",
},
/* Handle on hover */
'&::-webkit-scrollbar-thumb:hover': {
background: '#999'
}
}
"& .rmdp-shadow": {
boxShadow: "var(--mui-customShadows-md)",
},
'& .react-datepicker__day:hover, & .react-datepicker__month-text:hover, & .react-datepicker__quarter-text:hover, & .react-datepicker__year-text:hover':
{
backgroundColor: 'var(--mui-palette-action-hover)'
"& .rmdp-top-class": {
width: "100%",
},
'[data-skin="bordered"] &': {
boxShadow: 'none',
border: `1px solid var(--mui-palette-divider)`
}
"& .rmdp-header-values": {
color: "var(--mui-palette-text-primary)",
fontWeight: 700,
},
'& .react-datepicker__close-icon': {
top: 10,
paddingRight: theme.spacing(4),
...(theme.direction === 'rtl' ? { right: 0, left: 'auto' } : {}),
'&:after': {
width: 'unset',
height: 'unset',
fontSize: '1.5rem',
color: 'var(--mui-palette-text-primary)',
backgroundColor: 'transparent !important'
}
}
}
}) as typeof Box
"& .rmdp-arrow-container:hover": {
backgroundColor: "var(--mui-palette-primary-lightOpacity)",
},
"& .rmdp-arrow": {
borderColor: "var(--mui-palette-text-secondary)",
},
"& .rmdp-week-day": {
color: "var(--mui-palette-text-secondary)",
fontWeight: 700,
},
"& .rmdp-day, & .rmdp-time-picker div input": {
color: "var(--mui-palette-text-primary)",
},
"& .rmdp-day.rmdp-today span": {
backgroundColor: "var(--mui-palette-primary-lightOpacity)",
color: "var(--mui-palette-primary-main)",
},
"& .rmdp-day.rmdp-selected span:not(.highlight)": {
backgroundColor: "var(--mui-palette-primary-main)",
color: "var(--mui-palette-common-white)",
},
"& .rmdp-range, & .rmdp-range.start span, & .rmdp-range.end span": {
backgroundColor: "var(--mui-palette-primary-main)",
color: "var(--mui-palette-common-white)",
},
"& .rmdp-day.rmdp-disabled, & .rmdp-day.rmdp-deactive": {
color: "var(--mui-palette-text-disabled)",
},
"& .rmdp-input": {
width: "100%",
minHeight: 42,
padding: "10px 12px",
borderRadius: "var(--mui-shape-borderRadius)",
border: "1px solid var(--mui-palette-divider)",
backgroundColor: "var(--mui-palette-background-paper)",
color: "var(--mui-palette-text-primary)",
font: "inherit",
},
"& .rmdp-input:focus": {
borderColor: "var(--mui-palette-primary-main)",
boxShadow: "0 0 0 2px var(--mui-palette-primary-lightOpacity)",
outline: "none",
},
"& .rmdp-time-picker": {
padding: "8px 12px",
},
"& .rmdp-time-picker div input": {
border: "1px solid var(--mui-palette-divider)",
borderRadius: 8,
backgroundColor: "var(--mui-palette-background-paper)",
},
};
const AppReactDatepicker = (props: Props) => {
// Props
const { boxProps, ...rest } = props
const {
boxProps,
selected,
startDate,
endDate,
selectsRange,
showTimeSelect,
customInput,
placeholderText,
onChange,
onSelect,
inline,
dateFormat,
minDate,
maxDate,
shouldCloseOnSelect,
...rest
} = props;
return (
<StyledReactDatePicker {...boxProps}>
<ReactDatePickerComponent popperPlacement='bottom-start' {...rest} />
</StyledReactDatePicker>
)
const theme = useTheme();
const format = convertFormat(dateFormat);
const plugins = showTimeSelect
? [<TimePicker key="time-picker" position="bottom" hideSeconds />]
: [];
const rangeValue =
startDate && endDate ? [startDate, endDate] : startDate ? [startDate] : [];
const value = selectsRange ? rangeValue : (selected ?? null);
const handleChange = (nextValue: Value) => {
if (selectsRange) {
const dates = Array.isArray(nextValue)
? nextValue.map((item) => toDate(item as Value))
: [];
const normalized: [Date | null, Date | null] = [
dates[0] ?? null,
dates[1] ?? null,
];
onChange?.(normalized);
return;
}
export default AppReactDatepicker
const normalized = toDate(nextValue);
onChange?.(normalized);
onSelect?.(normalized);
};
const renderInput =
customInput && isValidElement(customInput)
? (valueText: string, openCalendar: () => void) => {
const existingProps = customInput.props as Record<string, any>;
return (
<Box
onClick={openCalendar}
onFocus={openCalendar}
sx={{ width: "100%" }}
>
{cloneElement(customInput, {
...existingProps,
value: existingProps.value ?? valueText,
onClick: openCalendar,
onFocus: openCalendar,
readOnly: true,
placeholder: existingProps.placeholder ?? placeholderText,
slotProps: {
...(typeof existingProps.slotProps === "object" &&
existingProps.slotProps
? existingProps.slotProps
: {}),
input: {
...(existingProps.slotProps?.input || {}),
readOnly: true,
},
},
})}
</Box>
);
}
: undefined;
const sharedProps = {
value,
onChange: handleChange,
calendar: persian,
locale: persian_fa,
format,
minDate,
maxDate,
plugins,
weekStartDayIndex: 6,
calendarPosition:
theme.direction === "rtl" ? "bottom-right" : "bottom-left",
shadow: true,
className: "teal",
...(shouldCloseOnSelect === false ? { closeOnScroll: false } : {}),
...rest,
};
return (
<Box {...boxProps} sx={[baseSx, boxProps?.sx]}>
{inline ? (
<Calendar {...sharedProps} />
) : (
<DatePicker
{...sharedProps}
render={renderInput}
inputClass="rmdp-input"
placeholder={placeholderText}
/>
)}
</Box>
);
};
export default AppReactDatepicker;
+197 -189
View File
@@ -1,74 +1,87 @@
// React Imports
import { useState, useEffect, forwardRef, useCallback } from 'react'
import { useState, useEffect, forwardRef, useCallback } from "react";
// MUI Imports
import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer'
import Switch from '@mui/material/Switch'
import Button from '@mui/material/Button'
import MenuItem from '@mui/material/MenuItem'
import IconButton from '@mui/material/IconButton'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import FormControl from '@mui/material/FormControl'
import FormControlLabel from '@mui/material/FormControlLabel'
import type { SelectChangeEvent } from '@mui/material/Select'
import type { Theme } from '@mui/material/styles'
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import Switch from "@mui/material/Switch";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import type { Theme } from "@mui/material/styles";
// Third-party Imports
import { useForm, Controller } from 'react-hook-form'
import PerfectScrollbar from 'react-perfect-scrollbar'
import { useForm, Controller } from "react-hook-form";
import PerfectScrollbar from "react-perfect-scrollbar";
// Type Imports
import type { AddEventSidebarType, AddEventType } from '@/types/apps/calendarTypes'
import type {
AddEventSidebarType,
AddEventType,
} from "@/types/apps/calendarTypes";
// Component Imports
import CustomTextField from '@core/components/mui/TextField'
import CustomTextField from "@core/components/mui/TextField";
// Styled Component Imports
import AppReactDatepicker from '@/libs/styles/AppReactDatepicker'
import AppReactDatepicker from "@/libs/styles/AppReactDatepicker";
// Slice Imports
import { createEventAsync, updateEventAsync, deleteEventAsync, selectedEvent, filterEvents } from '@/redux-store/slices/calendar'
import {
addEvent,
deleteEvent,
updateEvent,
selectedEvent,
filterEvents,
} from "@/redux-store/slices/calendar";
interface PickerProps {
label?: string
error?: boolean
registername?: string
label?: string;
error?: boolean;
registername?: string;
}
interface DefaultStateType {
url: string
title: string
allDay: boolean
calendar: string
description: string
endDate: Date
startDate: Date
guests: string[] | undefined
url: string;
title: string;
allDay: boolean;
calendar: string;
description: string;
endDate: Date;
startDate: Date;
guests: string[] | undefined;
}
// Vars
const capitalize = (string: string) => string && string[0].toUpperCase() + string.slice(1)
const capitalize = (string: string) =>
string && string[0].toUpperCase() + string.slice(1);
// Vars
const defaultState: DefaultStateType = {
url: '',
title: '',
url: "",
title: "",
guests: [],
allDay: true,
description: '',
description: "",
endDate: new Date(),
calendar: 'Business',
startDate: new Date()
}
calendar: "Business",
startDate: new Date(),
};
const AddEventSidebar = (props: AddEventSidebarType) => {
// Props
const { calendarStore, dispatch, addEventSidebarOpen, handleAddEventSidebarToggle } = props
const {
calendarStore,
dispatch,
addEventSidebarOpen,
handleAddEventSidebarToggle,
} = props;
// States
const [values, setValues] = useState<DefaultStateType>(defaultState)
const [values, setValues] = useState<DefaultStateType>(defaultState);
// Refs
const PickersComponent = forwardRef(({ ...props }: PickerProps, ref) => {
@@ -77,109 +90,108 @@ const AddEventSidebar = (props: AddEventSidebarType) => {
inputRef={ref}
fullWidth
{...props}
label={props.label || ''}
className='is-full'
label={props.label || ""}
className="is-full"
error={props.error}
/>
)
})
);
});
// Hooks
const isBelowSmScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
const isBelowSmScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down("sm"),
);
const {
control,
setValue,
clearErrors,
handleSubmit,
formState: { errors }
} = useForm({ defaultValues: { title: '' } })
formState: { errors },
} = useForm({ defaultValues: { title: "" } });
const resetToStoredValues = useCallback(() => {
if (calendarStore.selectedEvent !== null) {
const event = calendarStore.selectedEvent
const event = calendarStore.selectedEvent;
setValue('title', event.title || '')
setValue("title", event.title || "");
setValues({
url: event.url || '',
title: event.title || '',
url: event.url || "",
title: event.title || "",
allDay: event.allDay,
guests: event.extendedProps.guests || [],
description: event.extendedProps.description || '',
calendar: event.extendedProps.calendar || 'Business',
description: event.extendedProps.description || "",
calendar: event.extendedProps.calendar || "Business",
endDate: event.end !== null ? event.end : event.start,
startDate: event.start !== null ? event.start : new Date()
})
startDate: event.start !== null ? event.start : new Date(),
});
}
}, [setValue, calendarStore.selectedEvent])
}, [setValue, calendarStore.selectedEvent]);
const resetToEmptyValues = useCallback(() => {
setValue('title', '')
setValues(defaultState)
}, [setValue])
setValue("title", "");
setValues(defaultState);
}, [setValue]);
const handleSidebarClose = () => {
setValues(defaultState)
clearErrors()
dispatch(selectedEvent(null))
handleAddEventSidebarToggle()
}
setValues(defaultState);
clearErrors();
dispatch(selectedEvent(null));
handleAddEventSidebarToggle();
};
const onSubmit = (data: { title: string }) => {
const modifiedEvent: AddEventType = {
url: values.url,
display: 'block',
display: "block",
title: data.title,
end: values.endDate,
allDay: values.allDay,
start: values.startDate,
extendedProps: {
calendar: capitalize(values.calendar),
guests: values.guests && values.guests.length ? values.guests : undefined,
description: values.description.length ? values.description : undefined
}
}
const eventData = {
title: modifiedEvent.title || '',
description: modifiedEvent.extendedProps?.description || '',
calendar: (modifiedEvent.extendedProps?.calendar || 'Business') as 'Personal' | 'Business' | 'Family' | 'Holiday' | 'ETC',
start: modifiedEvent.start ? new Date(modifiedEvent.start).toISOString() : new Date().toISOString(),
end: modifiedEvent.end ? new Date(modifiedEvent.end).toISOString() : new Date().toISOString(),
allDay: modifiedEvent.allDay || false,
tags: modifiedEvent.extendedProps?.tags || [],
deadline: modifiedEvent.extendedProps?.deadline || Math.floor(Date.now() / 1000),
extendedProps: modifiedEvent.extendedProps || {}
}
guests:
values.guests && values.guests.length ? values.guests : undefined,
description: values.description.length ? values.description : undefined,
},
};
if (
calendarStore.selectedEvent === null ||
(calendarStore.selectedEvent !== null && !calendarStore.selectedEvent.title.length)
(calendarStore.selectedEvent !== null &&
!calendarStore.selectedEvent.title.length)
) {
dispatch(createEventAsync(eventData))
dispatch(addEvent(modifiedEvent));
} else {
dispatch(updateEventAsync({ id: calendarStore.selectedEvent.id, data: eventData }))
dispatch(
updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }),
);
}
dispatch(filterEvents())
dispatch(filterEvents());
handleSidebarClose()
}
handleSidebarClose();
};
const handleDeleteButtonClick = () => {
if (calendarStore.selectedEvent) {
dispatch(deleteEventAsync(calendarStore.selectedEvent.id))
dispatch(filterEvents())
dispatch(deleteEvent(calendarStore.selectedEvent.id));
dispatch(filterEvents());
}
handleSidebarClose()
}
// calendarApi.getEventById(calendarStore.selectedEvent.id).remove()
handleSidebarClose();
};
const handleStartDate = (date: Date | null) => {
if (date && date > values.endDate) {
setValues({ ...values, startDate: new Date(date), endDate: new Date(date) })
}
setValues({
...values,
startDate: new Date(date),
endDate: new Date(date),
});
}
};
const RenderSidebarFooter = () => {
if (
@@ -187,184 +199,180 @@ const AddEventSidebar = (props: AddEventSidebarType) => {
(calendarStore.selectedEvent && !calendarStore.selectedEvent.title.length)
) {
return (
<div className='flex gap-4'>
<Button type='submit' variant='contained'>
<div className="flex gap-4">
<Button type="submit" variant="contained">
Add
</Button>
<Button variant='outlined' color='secondary' onClick={resetToEmptyValues}>
<Button
variant="outlined"
color="secondary"
onClick={resetToEmptyValues}
>
Reset
</Button>
</div>
)
);
} else {
return (
<div className='flex gap-4'>
<Button type='submit' variant='contained'>
<div className="flex gap-4">
<Button type="submit" variant="contained">
Update
</Button>
<Button variant='outlined' color='secondary' onClick={resetToStoredValues}>
<Button
variant="outlined"
color="secondary"
onClick={resetToStoredValues}
>
Reset
</Button>
</div>
)
}
);
}
};
const ScrollWrapper = isBelowSmScreen ? 'div' : PerfectScrollbar
const ScrollWrapper = isBelowSmScreen ? "div" : PerfectScrollbar;
useEffect(() => {
if (calendarStore.selectedEvent !== null) {
resetToStoredValues()
resetToStoredValues();
} else {
resetToEmptyValues()
resetToEmptyValues();
}
}, [addEventSidebarOpen, resetToStoredValues, resetToEmptyValues, calendarStore.selectedEvent])
}, [
addEventSidebarOpen,
resetToStoredValues,
resetToEmptyValues,
calendarStore.selectedEvent,
]);
return (
<Drawer
anchor='right'
anchor="right"
open={addEventSidebarOpen}
onClose={handleSidebarClose}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: ['100%', 400] } }}
sx={{ "& .MuiDrawer-paper": { width: ["100%", 400] } }}
>
<Box className='flex justify-between items-center sidebar-header plb-5 pli-6 border-be'>
<Typography variant='h5'>
{calendarStore.selectedEvent && calendarStore.selectedEvent.title.length ? 'Update Event' : 'Add Event'}
<Box className="flex justify-between items-center sidebar-header plb-5 pli-6 border-be">
<Typography variant="h5">
{calendarStore.selectedEvent &&
calendarStore.selectedEvent.title.length
? "Update Event"
: "Add Event"}
</Typography>
{calendarStore.selectedEvent && calendarStore.selectedEvent.title.length ? (
<Box className='flex items-center' sx={{ gap: calendarStore.selectedEvent !== null ? 1 : 0 }}>
<IconButton size='small' onClick={handleDeleteButtonClick}>
<i className='tabler-trash text-2xl text-textPrimary' />
{calendarStore.selectedEvent &&
calendarStore.selectedEvent.title.length ? (
<Box
className="flex items-center"
sx={{ gap: calendarStore.selectedEvent !== null ? 1 : 0 }}
>
<IconButton size="small" onClick={handleDeleteButtonClick}>
<i className="tabler-trash text-2xl text-textPrimary" />
</IconButton>
<IconButton size='small' onClick={handleSidebarClose}>
<i className='tabler-x text-2xl text-textPrimary' />
<IconButton size="small" onClick={handleSidebarClose}>
<i className="tabler-x text-2xl text-textPrimary" />
</IconButton>
</Box>
) : (
<IconButton size='small' onClick={handleSidebarClose}>
<i className='tabler-x text-2xl text-textPrimary' />
<IconButton size="small" onClick={handleSidebarClose}>
<i className="tabler-x text-2xl text-textPrimary" />
</IconButton>
)}
</Box>
<ScrollWrapper
{...(isBelowSmScreen
? { className: 'bs-full overflow-y-auto overflow-x-hidden' }
? { className: "bs-full overflow-y-auto overflow-x-hidden" }
: { options: { wheelPropagation: false, suppressScrollX: true } })}
>
<Box className='sidebar-body plb-5 pli-6'>
<form onSubmit={handleSubmit(onSubmit)} autoComplete='off' className='flex flex-col gap-6'>
<Box className="sidebar-body plb-5 pli-6">
<form
onSubmit={handleSubmit(onSubmit)}
autoComplete="off"
className="flex flex-col gap-6"
>
<Controller
name='title'
name="title"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<CustomTextField
fullWidth
label='عنوان'
label="Title"
value={value}
onChange={onChange}
{...(errors.title && { error: true, helperText: 'This field is required' })}
{...(errors.title && {
error: true,
helperText: "This field is required",
})}
/>
)}
/>
<CustomTextField
select
fullWidth
label='تقویم'
value={values.calendar}
onChange={e => setValues({ ...values, calendar: e.target.value })}
>
<MenuItem value='Personal'>Personal</MenuItem>
<MenuItem value='Business'>Business</MenuItem>
<MenuItem value='Family'>Family</MenuItem>
<MenuItem value='Holiday'>Holiday</MenuItem>
<MenuItem value='ETC'>ETC</MenuItem>
</CustomTextField>
<AppReactDatepicker
selectsStart
id='event-start-date'
id="event-start-date"
endDate={values.endDate}
selected={values.startDate}
startDate={values.startDate}
showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? 'yyyy-MM-dd hh:mm' : 'yyyy-MM-dd'}
customInput={<PickersComponent label='Start Date' registername='startDate' />}
onChange={(date: Date | null) => date !== null && setValues({ ...values, startDate: new Date(date) })}
dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={
<PickersComponent label="Start Date" registername="startDate" />
}
onChange={(date: Date | null) =>
date !== null &&
setValues({ ...values, startDate: new Date(date) })
}
onSelect={handleStartDate}
/>
<AppReactDatepicker
selectsEnd
id='event-end-date'
id="event-end-date"
endDate={values.endDate}
selected={values.endDate}
minDate={values.startDate}
startDate={values.startDate}
showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? 'yyyy-MM-dd hh:mm' : 'yyyy-MM-dd'}
customInput={<PickersComponent label='End Date' registername='endDate' />}
onChange={(date: Date | null) => date !== null && setValues({ ...values, endDate: new Date(date) })}
dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={
<PickersComponent label="End Date" registername="endDate" />
}
onChange={(date: Date | null) =>
date !== null &&
setValues({ ...values, endDate: new Date(date) })
}
/>
<FormControl>
<FormControlLabel
label='All Day'
label="All Day"
control={
<Switch checked={values.allDay} onChange={e => setValues({ ...values, allDay: e.target.checked })} />
<Switch
checked={values.allDay}
onChange={(e) =>
setValues({ ...values, allDay: e.target.checked })
}
/>
}
/>
</FormControl>
<CustomTextField
fullWidth
type='url'
id='event-url'
label='Event URL'
value={values.url}
onChange={e => setValues({ ...values, url: e.target.value })}
/>
<CustomTextField
fullWidth
select
label='Guests'
value={values.guests}
id='event-guests-select'
// eslint-disable-next-line lines-around-comment
// @ts-ignore
onChange={(e: SelectChangeEvent<(typeof values)['guests']>) => {
setValues({
...values,
guests: typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value
})
}}
slotProps={{
select: {
multiple: true
}
}}
>
<MenuItem value='bruce'>Bruce</MenuItem>
<MenuItem value='clark'>Clark</MenuItem>
<MenuItem value='diana'>Diana</MenuItem>
<MenuItem value='john'>John</MenuItem>
<MenuItem value='barry'>Barry</MenuItem>
</CustomTextField>
<CustomTextField
rows={4}
multiline
fullWidth
label='توضیحات'
id='event-description'
label="Description"
id="event-description"
value={values.description}
onChange={e => setValues({ ...values, description: e.target.value })}
onChange={(e) =>
setValues({ ...values, description: e.target.value })
}
/>
<div className='flex items-center'>
<div className="flex items-center">
<RenderSidebarFooter />
</div>
</form>
</Box>
</ScrollWrapper>
</Drawer>
)
}
);
};
export default AddEventSidebar
export default AddEventSidebar;
+107 -168
View File
@@ -1,53 +1,68 @@
// React Imports
import { useEffect, useRef } from 'react'
import { useEffect, useRef } from "react";
// MUI Imports
import { useTheme } from '@mui/material/styles'
import { useTheme } from "@mui/material/styles";
// Third-party imports
import 'bootstrap-icons/font/bootstrap-icons.css'
import type { Dispatch } from "@reduxjs/toolkit";
import "bootstrap-icons/font/bootstrap-icons.css";
import FullCalendar from '@fullcalendar/react'
import faLocale from '@fullcalendar/core/locales/fa'
import listPlugin from '@fullcalendar/list'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import type { CalendarOptions } from '@fullcalendar/core'
import FullCalendar from "@fullcalendar/react";
import listPlugin from "@fullcalendar/list";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import type { CalendarOptions } from "@fullcalendar/core";
import faLocale from "@fullcalendar/core/locales/fa";
// Type Imports
import type { AddEventType, CalendarColors, CalendarType } from '@/types/apps/calendarTypes'
import type { AppDispatch } from '@/redux-store'
import type {
AddEventType,
CalendarColors,
CalendarType,
} from "@/types/apps/calendarTypes";
// Slice Imports
import { filterEvents, selectedEvent, updateEventAsync } from '@/redux-store/slices/calendar'
import {
filterEvents,
selectedEvent,
updateEvent,
} from "@/redux-store/slices/calendar";
type CalenderProps = {
calendarStore: CalendarType
calendarApi: any
setCalendarApi: (val: any) => void
calendarsColor: CalendarColors
dispatch?: AppDispatch
handleLeftSidebarToggle?: () => void
handleAddEventSidebarToggle?: () => void
editable?: boolean
showSidebarToggle?: boolean
onDateClick?: (date: Date) => void
onEventClick?: (event: any) => void
}
calendarStore: CalendarType;
calendarApi: any;
setCalendarApi: (val: any) => void;
calendarsColor: CalendarColors;
dispatch: Dispatch;
handleLeftSidebarToggle: () => void;
handleAddEventSidebarToggle: () => void;
handleEventDetailsOpen: () => void;
};
const blankEvent: AddEventType = {
title: '',
start: '',
end: '',
title: "",
start: "",
end: "",
allDay: false,
url: '',
url: "",
extendedProps: {
calendar: '',
calendar: "",
guests: [],
description: ''
}
}
description: "",
},
};
const formatPersianDay = (date: Date) =>
new Intl.DateTimeFormat("fa-IR-u-ca-persian", { day: "numeric" }).format(
date,
);
const formatPersianWeekday = (date: Date) =>
new Intl.DateTimeFormat("fa-IR-u-ca-persian", { weekday: "short" }).format(
date,
);
const Calendar = (props: CalenderProps) => {
// Props
@@ -58,110 +73,58 @@ const Calendar = (props: CalenderProps) => {
calendarsColor,
dispatch,
handleAddEventSidebarToggle,
handleEventDetailsOpen,
handleLeftSidebarToggle,
editable = true,
showSidebarToggle = true,
onDateClick,
onEventClick
} = props
} = props;
// Refs
const calendarRef = useRef()
const calendarRef = useRef();
// Hooks
const theme = useTheme()
const theme = useTheme();
useEffect(() => {
if (calendarApi === null) {
// @ts-ignore
setCalendarApi(calendarRef.current?.getApi())
setCalendarApi(calendarRef.current?.getApi());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, []);
// calendarOptions(Props)
const calendarOptions: CalendarOptions = {
events: calendarStore.events,
plugins: [interactionPlugin, dayGridPlugin, timeGridPlugin, listPlugin],
initialView: 'dayGridMonth',
locale: 'fa',
locale: faLocale,
initialView: "dayGridMonth",
headerToolbar: {
start: `${showSidebarToggle ? 'sidebarToggle,' : ''}prev,next,title`,
end: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
start: "sidebarToggle, prev, next, title",
end: "dayGridMonth,timeGridWeek,timeGridDay,listMonth",
},
buttonText: {
today: "امروز",
month: "ماه",
week: "هفته",
day: "روز",
list: "فهرست",
},
views: {
week: {
titleFormat(arg: any) {
const start = arg.start
const end = arg.end
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
if (!start && !end) {
return ''
}
if (start && !end) {
return formatter.format(start instanceof Date ? start : new Date(start.marker ?? start))
}
if (!start && end) {
return formatter.format(end instanceof Date ? end : new Date(end.marker ?? end))
}
const s = start instanceof Date ? start : new Date(start.marker ?? start)
const e = end instanceof Date ? end : new Date(end.marker ?? end)
return `${formatter.format(s)} - ${formatter.format(e)}`
}
}
titleFormat: { year: "numeric", month: "short", day: "numeric" },
},
},
dayHeaderContent(arg) {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { weekday: 'short' })
return formatter.format(arg.date)
return formatPersianWeekday(arg.date);
},
titleFormat(arg: any) {
const { start, end } = arg
const monthFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: 'long'
})
// FullCalendar بعضی وقت‌ها به‌جای Date، DateMarker خودش را می‌فرستد؛
// با new Date(...) مطمئن می‌شویم ورودی برای Intl معتبر است.
const target = start || end
if (!target) {
return ''
}
const asDate = target instanceof Date ? target : new Date(target as any)
if (Number.isNaN(asDate.getTime())) {
return ''
}
return monthFormatter.format(asDate)
},
buttonText: {
today: 'امروز',
month: 'ماه',
week: 'هفته',
day: 'روز',
list: 'لیست'
dayCellContent(arg) {
return formatPersianDay(arg.date);
},
/*
Enable dragging and resizing event
? Docs: https://fullcalendar.io/docs/editable
*/
editable,
editable: true,
/*
Enable resizing event from start
@@ -189,28 +152,30 @@ const Calendar = (props: CalenderProps) => {
eventClassNames({ event: calendarEvent }: any) {
// @ts-ignore
const colorName = calendarsColor[calendarEvent._def.extendedProps.calendar]
const colorName =
calendarsColor[calendarEvent._def.extendedProps.calendar];
return [
// Background Color
`event-bg-${colorName}`
]
`event-bg-${colorName}`,
];
},
eventClick({ event: clickedEvent, jsEvent }: any) {
jsEvent.preventDefault()
jsEvent.preventDefault();
onEventClick?.(clickedEvent)
if (dispatch && handleAddEventSidebarToggle) {
dispatch(selectedEvent(clickedEvent))
handleAddEventSidebarToggle()
}
if (clickedEvent.url) {
// Open the URL in a new tab
window.open(clickedEvent.url, '_blank')
}
dispatch(
selectedEvent({
id: clickedEvent.id,
url: clickedEvent.url,
title: clickedEvent.title,
allDay: clickedEvent.allDay,
end: clickedEvent.end,
start: clickedEvent.start,
extendedProps: clickedEvent.extendedProps,
}),
);
handleEventDetailsOpen();
//* Only grab required field otherwise it goes in infinity loop
//! Always grab all fields rendered by form (even if it get `undefined`)
@@ -220,28 +185,22 @@ const Calendar = (props: CalenderProps) => {
customButtons: {
sidebarToggle: {
icon: 'tabler tabler-menu-2',
icon: "tabler tabler-menu-2",
click() {
handleLeftSidebarToggle?.()
}
}
handleLeftSidebarToggle();
},
},
},
dateClick(info: any) {
onDateClick?.(info.date)
const ev = { ...blankEvent };
if (!dispatch || !handleAddEventSidebarToggle) {
return
}
ev.start = info.date;
ev.end = info.date;
ev.allDay = true;
const ev = { ...blankEvent }
ev.start = info.date
ev.end = info.date
ev.allDay = true
dispatch(selectedEvent(ev))
handleAddEventSidebarToggle()
dispatch(selectedEvent(ev));
handleAddEventSidebarToggle();
},
/*
@@ -250,18 +209,8 @@ const Calendar = (props: CalenderProps) => {
? We can use `eventDragStop` but it doesn't return updated event so we have to use `eventDrop` which returns updated event
*/
eventDrop({ event: droppedEvent }: any) {
if (!dispatch) {
return
}
// Convert FullCalendar event to API format
const eventData = {
start: droppedEvent.start ? new Date(droppedEvent.start).toISOString() : '',
end: droppedEvent.end ? new Date(droppedEvent.end).toISOString() : '',
allDay: droppedEvent.allDay || false
}
dispatch(updateEventAsync({ id: droppedEvent.id, data: eventData }))
dispatch(filterEvents())
dispatch(updateEvent(droppedEvent));
dispatch(filterEvents());
},
/*
@@ -269,27 +218,17 @@ const Calendar = (props: CalenderProps) => {
? Docs: https://fullcalendar.io/docs/eventResize
*/
eventResize({ event: resizedEvent }: any) {
if (!dispatch) {
return
}
// Convert FullCalendar event to API format
const eventData = {
start: resizedEvent.start ? new Date(resizedEvent.start).toISOString() : '',
end: resizedEvent.end ? new Date(resizedEvent.end).toISOString() : '',
allDay: resizedEvent.allDay || false
}
dispatch(updateEventAsync({ id: resizedEvent.id, data: eventData }))
dispatch(filterEvents())
dispatch(updateEvent(resizedEvent));
dispatch(filterEvents());
},
// @ts-ignore
ref: calendarRef,
direction: theme.direction
}
direction: theme.direction,
};
return <FullCalendar {...calendarOptions} />
}
return <FullCalendar {...calendarOptions} />;
};
export default Calendar
export default Calendar;
+69 -48
View File
@@ -1,58 +1,79 @@
'use client'
// React Imports
import { useState } from 'react'
// MUI Imports
import { useMediaQuery } from '@mui/material'
import type { Theme } from '@mui/material/styles'
// Third-party Imports
import { useDispatch, useSelector } from 'react-redux'
// Type Imports
import type { CalendarColors, CalendarType } from '@/types/apps/calendarTypes'
// Component Imports
import Calendar from './Calendar'
import SidebarLeft from './SidebarLeft'
import AddEventSidebar from './AddEventSidebar'
// CalendarColors Object
const calendarsColor: CalendarColors = {
Personal: 'error',
Business: 'primary',
Family: 'warning',
Holiday: 'success',
ETC: 'info'
}
const AppCalendar = () => {
return <></>
// // States
// const [calendarApi, setCalendarApi] = useState<null | any>(null)
// const [leftSidebarOpen, setLeftSidebarOpen] = useState<boolean>(false)
// const [addEventSidebarOpen, setAddEventSidebarOpen] = useState<boolean>(false)
// States
const [calendarApi, setCalendarApi] = useState<null | any>(null)
const [leftSidebarOpen, setLeftSidebarOpen] = useState<boolean>(false)
const [addEventSidebarOpen, setAddEventSidebarOpen] = useState<boolean>(false)
// // Hooks
// const dispatch = useAppDispatch()
// const calendarStore = useSelector((state: { calendarReducer: CalendarType & { loading: boolean; error: string | null } }) => state.calendarReducer)
// const mdAbove = useMediaQuery((theme: Theme) => theme.breakpoints.up('md'))
// Hooks
const dispatch = useDispatch()
const calendarStore = useSelector((state: { calendarReducer: CalendarType }) => state.calendarReducer)
const mdAbove = useMediaQuery((theme: Theme) => theme.breakpoints.up('md'))
// // Fetch events on mount
// useEffect(() => {
// dispatch(fetchEvents())
// }, [dispatch])
const handleLeftSidebarToggle = () => setLeftSidebarOpen(!leftSidebarOpen)
// const handleLeftSidebarToggle = () => setLeftSidebarOpen(!leftSidebarOpen)
const handleAddEventSidebarToggle = () => setAddEventSidebarOpen(!addEventSidebarOpen)
// const handleAddEventSidebarToggle = () => setAddEventSidebarOpen(!addEventSidebarOpen)
// return (
// <>
// <SidebarLeft
// mdAbove={mdAbove}
// dispatch={dispatch}
// calendarApi={calendarApi}
// calendarStore={calendarStore}
// calendarsColor={calendarsColor}
// leftSidebarOpen={leftSidebarOpen}
// handleLeftSidebarToggle={handleLeftSidebarToggle}
// handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// />
// <div className='p-6 pbe-0 flex-grow overflow-visible bg-backgroundPaper rounded'>
// <Calendar
// dispatch={dispatch}
// calendarApi={calendarApi}
// calendarStore={calendarStore}
// setCalendarApi={setCalendarApi}
// calendarsColor={calendarsColor}
// handleLeftSidebarToggle={handleLeftSidebarToggle}
// handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// />
// </div>
// <AddEventSidebar
// dispatch={dispatch}
// calendarApi={calendarApi}
// calendarStore={calendarStore}
// addEventSidebarOpen={addEventSidebarOpen}
// handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// />
// </>
// )
return (
<>
<SidebarLeft
mdAbove={mdAbove}
dispatch={dispatch}
calendarApi={calendarApi}
calendarStore={calendarStore}
calendarsColor={calendarsColor}
leftSidebarOpen={leftSidebarOpen}
handleLeftSidebarToggle={handleLeftSidebarToggle}
handleAddEventSidebarToggle={handleAddEventSidebarToggle}
/>
<div className='p-6 pbe-0 flex-grow overflow-visible bg-backgroundPaper rounded'>
<Calendar
dispatch={dispatch}
calendarApi={calendarApi}
calendarStore={calendarStore}
setCalendarApi={setCalendarApi}
calendarsColor={calendarsColor}
handleLeftSidebarToggle={handleLeftSidebarToggle}
handleAddEventSidebarToggle={handleAddEventSidebarToggle}
/>
</div>
<AddEventSidebar
dispatch={dispatch}
calendarApi={calendarApi}
calendarStore={calendarStore}
addEventSidebarOpen={addEventSidebarOpen}
handleAddEventSidebarToggle={handleAddEventSidebarToggle}
/>
</>
)
}
export default AppCalendar
+113 -195
View File
@@ -1,6 +1,23 @@
// MUI Imports
import Button from '@mui/material/Button'
import Drawer from '@mui/material/Drawer'
import Divider from '@mui/material/Divider'
import Checkbox from '@mui/material/Checkbox'
import Typography from '@mui/material/Typography'
import FormControlLabel from '@mui/material/FormControlLabel'
// Third-party imports
import classnames from 'classnames'
// Types Imports
import type { SidebarLeftProps, CalendarFiltersType } from '@/types/apps/calendarTypes'
import type { ThemeColor } from '@core/types'
// Styled Component Imports
import AppReactDatepicker from '@/libs/styles/AppReactDatepicker'
// Slice Imports
import { filterAllCalendarLabels, filterCalendarLabel, selectedEvent } from '@/redux-store/slices/calendar'
const SidebarLeft = (props: SidebarLeftProps) => {
// Props
@@ -15,205 +32,106 @@ const SidebarLeft = (props: SidebarLeftProps) => {
handleAddEventSidebarToggle
} = props
// // Vars
// const colorsArr = calendarsColor ? Object.entries(calendarsColor) : []
// Vars
const colorsArr = calendarsColor ? Object.entries(calendarsColor) : []
// const renderFilters = colorsArr.length
// ? colorsArr.map(([key, value]: string[]) => {
// return (
// <FormControlLabel
// className='mbe-1'
// key={key}
// label={key}
// control={
// <Checkbox
// color={value as ThemeColor}
// checked={calendarStore.selectedCalendars.indexOf(key as CalendarFiltersType) > -1}
// onChange={() => dispatch(filterCalendarLabel(key as CalendarFiltersType))}
// />
// }
// />
// )
// })
// : null
const renderFilters = colorsArr.length
? colorsArr.map(([key, value]: string[]) => {
return (
<FormControlLabel
className='mbe-1'
key={key}
label={key}
control={
<Checkbox
color={value as ThemeColor}
checked={calendarStore.selectedCalendars.indexOf(key as CalendarFiltersType) > -1}
onChange={() => dispatch(filterCalendarLabel(key as CalendarFiltersType))}
/>
}
/>
)
})
: null
// const handleSidebarToggleSidebar = () => {
// dispatch(selectedEvent(null))
// handleAddEventSidebarToggle()
// }
const handleSidebarToggleSidebar = () => {
dispatch(selectedEvent(null))
handleAddEventSidebarToggle()
}
// if (renderFilters) {
// return (
// <Drawer
// open={leftSidebarOpen}
// onClose={handleLeftSidebarToggle}
// variant={mdAbove ? 'permanent' : 'temporary'}
// ModalProps={{
// disablePortal: true,
// disableAutoFocus: true,
// disableScrollLock: true,
// keepMounted: true // Better open performance on mobile.
// }}
// className={classnames('block', { static: mdAbove, absolute: !mdAbove })}
// PaperProps={{
// className: classnames('items-start is-[280px] shadow-none rounded rounded-se-none rounded-ee-none', {
// static: mdAbove,
// absolute: !mdAbove
// })
// }}
// sx={{
// zIndex: 3,
// '& .MuiDrawer-paper': {
// zIndex: mdAbove ? 2 : 'drawer'
// },
// '& .MuiBackdrop-root': {
// borderRadius: 1,
// position: 'absolute'
// }
// }}
// >
// <div className='is-full p-6'>
// <Button
// fullWidth
// variant='contained'
// onClick={handleSidebarToggleSidebar}
// startIcon={<i className='tabler-plus' />}
// >
// Add Event
// </Button>
// </div>
// <Divider className='is-full' />
// <AppJalaliDatepicker
// onChange={date => calendarApi?.gotoDate(date)}
// boxProps={{
// className: 'flex justify-center is-full',
// sx: { '& .react-datepicker': { boxShadow: 'none !important', border: 'none !important' } }
// }}
// />
// <Divider className='is-full' />
if (renderFilters) {
return (
<Drawer
open={leftSidebarOpen}
onClose={handleLeftSidebarToggle}
variant={mdAbove ? 'permanent' : 'temporary'}
ModalProps={{
disablePortal: true,
disableAutoFocus: true,
disableScrollLock: true,
keepMounted: true // Better open performance on mobile.
}}
className={classnames('block', { static: mdAbove, absolute: !mdAbove })}
PaperProps={{
className: classnames('items-start is-[280px] shadow-none rounded rounded-se-none rounded-ee-none', {
static: mdAbove,
absolute: !mdAbove
})
}}
sx={{
zIndex: 3,
'& .MuiDrawer-paper': {
zIndex: mdAbove ? 2 : 'drawer'
},
'& .MuiBackdrop-root': {
borderRadius: 1,
position: 'absolute'
}
}}
>
<div className='is-full p-6'>
<Button
fullWidth
variant='contained'
onClick={handleSidebarToggleSidebar}
startIcon={<i className='tabler-plus' />}
>
Add Event
</Button>
</div>
<Divider className='is-full' />
<AppReactDatepicker
inline
onChange={date => calendarApi.gotoDate(date)}
boxProps={{
className: 'flex justify-center is-full',
sx: { '& .react-datepicker': { boxShadow: 'none !important', border: 'none !important' } }
}}
/>
<Divider className='is-full' />
// <div className='flex flex-col p-6 is-full'>
// <Typography variant='h5' className='mbe-4'>
// Event Filters
// </Typography>
// <FormControlLabel
// className='mbe-1'
// label='View All'
// control={
// <Checkbox
// color='secondary'
// checked={calendarStore.selectedCalendars.length === colorsArr.length}
// onChange={e => dispatch(filterAllCalendarLabels(e.target.checked))}
// />
// }
// />
// {renderFilters}
// </div>
// </Drawer>
// )
// } else {
// return null
// }
// // Vars
// const colorsArr = calendarsColor ? Object.entries(calendarsColor) : []
// const renderFilters = colorsArr.length
// ? colorsArr.map(([key, value]: string[]) => {
// return (
// <FormControlLabel
// className='mbe-1'
// key={key}
// label={key}
// control={
// <Checkbox
// color={value as ThemeColor}
// checked={calendarStore.selectedCalendars.indexOf(key as CalendarFiltersType) > -1}
// onChange={() => dispatch(filterCalendarLabel(key as CalendarFiltersType))}
// />
// }
// />
// )
// })
// : null
// const handleSidebarToggleSidebar = () => {
// dispatch(selectedEvent(null))
// handleAddEventSidebarToggle()
// }
// if (renderFilters) {
// return (
// <Drawer
// open={leftSidebarOpen}
// onClose={handleLeftSidebarToggle}
// variant={mdAbove ? 'permanent' : 'temporary'}
// ModalProps={{
// disablePortal: true,
// disableAutoFocus: true,
// disableScrollLock: true,
// keepMounted: true // Better open performance on mobile.
// }}
// className={classnames('block', { static: mdAbove, absolute: !mdAbove })}
// PaperProps={{
// className: classnames('items-start is-[280px] shadow-none rounded rounded-se-none rounded-ee-none', {
// static: mdAbove,
// absolute: !mdAbove
// })
// }}
// sx={{
// zIndex: 3,
// '& .MuiDrawer-paper': {
// zIndex: mdAbove ? 2 : 'drawer'
// },
// '& .MuiBackdrop-root': {
// borderRadius: 1,
// position: 'absolute'
// }
// }}
// >
// <div className='is-full p-6'>
// <Button
// fullWidth
// variant='contained'
// onClick={handleSidebarToggleSidebar}
// startIcon={<i className='tabler-plus' />}
// >
// Add Event
// </Button>
// </div>
// <Divider className='is-full' />
// <AppJalaliDatepicker
// onChange={date => calendarApi?.gotoDate(date)}
// boxProps={{
// className: 'flex justify-center is-full',
// sx: { '& .react-datepicker': { boxShadow: 'none !important', border: 'none !important' } }
// }}
// />
// <Divider className='is-full' />
// <div className='flex flex-col p-6 is-full'>
// <Typography variant='h5' className='mbe-4'>
// Event Filters
// </Typography>
// <FormControlLabel
// className='mbe-1'
// label='View All'
// control={
// <Checkbox
// color='secondary'
// checked={calendarStore.selectedCalendars.length === colorsArr.length}
// onChange={e => dispatch(filterAllCalendarLabels(e.target.checked))}
// />
// }
// />
// {renderFilters}
// </div>
// </Drawer>
// )
// } else {
// return null
// }
return <></>
<div className='flex flex-col p-6 is-full'>
<Typography variant='h5' className='mbe-4'>
Event Filters
</Typography>
<FormControlLabel
className='mbe-1'
label='View All'
control={
<Checkbox
color='secondary'
checked={calendarStore.selectedCalendars.length === colorsArr.length}
onChange={e => dispatch(filterAllCalendarLabels(e.target.checked))}
/>
}
/>
{renderFilters}
</div>
</Drawer>
)
} else {
return null
}
}
export default SidebarLeft
@@ -0,0 +1,268 @@
"use client";
import { memo } from "react";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import Divider from "@mui/material/Divider";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { Theme } from "@mui/material/styles";
import { format, parseISO } from "date-fns";
import { useTranslations } from "next-intl";
import { selectedEvent } from "@/redux-store/slices/calendar";
import type { AppDispatch } from "@/redux-store";
import type { CalendarType } from "@/types/apps/calendarTypes";
const modalTransitionDuration = {
appear: 0,
enter: 140,
exit: 100,
};
type FarmerCalendarEventDetailsProps = {
open: boolean;
onClose: () => void;
onEdit: () => void;
calendarStore: CalendarType;
dispatch: AppDispatch;
};
const toDate = (value: unknown) => {
if (!value) return null;
if (value instanceof Date) return value;
if (Array.isArray(value)) {
const [year, month = 1, day = 1, hour = 0, minute = 0, second = 0] =
value.map(Number);
const fallback = new Date(year, month - 1, day, hour, minute, second);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
if (typeof value === "string") {
const parsed = parseISO(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
if (typeof value === "number") {
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
return null;
};
const FarmerCalendarEventDetails = ({
open,
onClose,
onEdit,
calendarStore,
dispatch,
}: FarmerCalendarEventDetailsProps) => {
const t = useTranslations("farmerCalendar.details");
const tagLabel = t.has("fields.tag") ? t("fields.tag") : "Tag";
const emptyTagLabel = t.has("emptyTag")
? t("emptyTag")
: "No tag has been set.";
const isMobile = useMediaQuery(
(theme: Theme) => theme.breakpoints.down("sm"),
{ noSsr: true },
);
const event = calendarStore.selectedEvent;
const handleClose = () => {
dispatch(selectedEvent(null));
onClose();
};
if (!event) return null;
const startDate = toDate(event.start);
const endDate = toDate(event.end);
const category = event.extendedProps?.calendar || "ETC";
const categoryLabel = t(
`categories.${String(category).toLowerCase()}` as
| "categories.personal"
| "categories.business"
| "categories.family"
| "categories.holiday"
| "categories.etc",
);
const description = event.extendedProps?.description || t("emptyDescription");
const tag =
Array.isArray(event.extendedProps?.tags) &&
typeof event.extendedProps.tags[0] === "string" &&
event.extendedProps.tags[0].trim().length
? event.extendedProps.tags[0].trim()
: emptyTagLabel;
const content = (
<Stack spacing={3}>
<Stack spacing={1.25}>
<Chip
label={categoryLabel}
color="success"
variant="outlined"
sx={{ alignSelf: "flex-start" }}
/>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
{event.title || t("untitled")}
</Typography>
</Stack>
<Stack spacing={2}>
<div>
<Typography variant="body2" color="text.secondary">
{t("fields.date")}
</Typography>
<Typography variant="body1" sx={{ mt: 0.75, fontWeight: 600 }}>
{startDate ? format(startDate, "EEEE, MMMM d, yyyy") : "-"}
</Typography>
</div>
<div>
<Typography variant="body2" color="text.secondary">
{t("fields.time")}
</Typography>
<Typography variant="body1" sx={{ mt: 0.75, fontWeight: 600 }}>
{event.allDay
? t("allDay")
: startDate
? endDate
? t("timeRange", {
start: format(startDate, "p"),
end: format(endDate, "p"),
})
: format(startDate, "p")
: "-"}
</Typography>
</div>
<div>
<Typography variant="body2" color="text.secondary">
{tagLabel}
</Typography>
<Typography variant="body1" sx={{ mt: 0.75, fontWeight: 600 }}>
{tag}
</Typography>
</div>
<div>
<Typography variant="body2" color="text.secondary">
{t("fields.description")}
</Typography>
<Typography
variant="body1"
color="text.primary"
sx={{ mt: 0.75, lineHeight: 1.9 }}
>
{description}
</Typography>
</div>
</Stack>
<Divider />
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
<Button variant="outlined" color="secondary" onClick={handleClose}>
{t("actions.close")}
</Button>
<Button variant="contained" onClick={onEdit}>
{t("actions.edit")}
</Button>
</Stack>
</Stack>
);
if (isMobile) {
return (
<Drawer
anchor="bottom"
open={open}
onClose={handleClose}
ModalProps={{ keepMounted: true }}
transitionDuration={modalTransitionDuration}
slotProps={{
backdrop: {
sx: { backgroundColor: "rgba(15, 23, 42, 0.2)" },
},
}}
PaperProps={{
sx: {
maxHeight: "80vh",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
}}
>
<div className="flex items-center justify-between border-bs plb-4 pli-6">
<div className="flex flex-col gap-0.5">
<Typography variant="h5">{t("title")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("subtitle")}
</Typography>
</div>
<IconButton
size="small"
onClick={handleClose}
aria-label={t("close")}
>
<i className="tabler-x text-2xl" />
</IconButton>
</div>
<div className="overflow-y-auto pli-6 plb-6">{content}</div>
</Drawer>
);
}
return (
<Dialog
fullWidth
maxWidth="sm"
open={open}
onClose={handleClose}
keepMounted
transitionDuration={modalTransitionDuration}
slotProps={{
backdrop: {
sx: { backgroundColor: "rgba(15, 23, 42, 0.2)" },
},
}}
sx={{
"& .MuiDialog-paper": {
borderRadius: 5,
overflow: "hidden",
},
}}
>
<div className="flex items-center justify-between border-be plb-5 pli-6">
<div className="flex flex-col gap-0.5">
<Typography variant="h5">{t("title")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("subtitle")}
</Typography>
</div>
<IconButton size="small" onClick={handleClose} aria-label={t("close")}>
<i className="tabler-x text-2xl" />
</IconButton>
</div>
<DialogContent>{content}</DialogContent>
</Dialog>
);
};
export default memo(FarmerCalendarEventDetails);
@@ -0,0 +1,484 @@
"use client";
import { forwardRef, memo, useCallback, useEffect, useState } from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import Drawer from "@mui/material/Drawer";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { Theme } from "@mui/material/styles";
import { Controller, useForm } from "react-hook-form";
import { useTranslations } from "next-intl";
import CustomTextField from "@core/components/mui/TextField";
import { eventService } from "@/libs/api";
import AppReactDatepicker from "@/libs/styles/AppReactDatepicker";
import {
addEvent,
deleteEvent,
filterEvents,
selectedEvent,
updateEvent,
} from "@/redux-store/slices/calendar";
import type {
AddEventSidebarType,
AddEventType,
} from "@/types/apps/calendarTypes";
type FarmerCalendarEventModalProps = Omit<
AddEventSidebarType,
"addEventSidebarOpen" | "handleAddEventSidebarToggle"
> & {
open: boolean;
onClose: () => void;
};
type PickerProps = {
label?: string;
error?: boolean;
};
type DefaultStateType = {
title: string;
tag: string;
allDay: boolean;
description: string;
endDate: Date;
startDate: Date;
};
const asSingleDate = (
value: Date | [Date | null, Date | null] | null,
): Date | null => (value instanceof Date ? value : null);
const defaultState: DefaultStateType = {
title: "",
tag: "",
allDay: true,
description: "",
endDate: new Date(),
startDate: new Date(),
};
const modalTransitionDuration = {
appear: 0,
enter: 140,
exit: 100,
};
const PickerInput = forwardRef<HTMLInputElement, PickerProps>(
({ ...props }, ref) => (
<CustomTextField
inputRef={ref}
fullWidth
{...props}
label={props.label || ""}
className="is-full"
error={props.error}
/>
),
);
PickerInput.displayName = "PickerInput";
const FarmerCalendarEventModal = ({
calendarStore,
dispatch,
open,
onClose,
}: FarmerCalendarEventModalProps) => {
const t = useTranslations("farmerCalendar.modal");
const tagLabel = t.has("fields.tag") ? t("fields.tag") : "Tag";
const tagPlaceholder = t.has("fields.tagPlaceholder")
? t("fields.tagPlaceholder")
: "Select a tag";
const isMobile = useMediaQuery(
(theme: Theme) => theme.breakpoints.down("sm"),
{ noSsr: true },
);
const [values, setValues] = useState<DefaultStateType>(defaultState);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [tagLoading, setTagLoading] = useState(false);
const {
control,
setValue,
clearErrors,
handleSubmit,
formState: { errors },
} = useForm({ defaultValues: { title: "" } });
const resetToStoredValues = useCallback(() => {
if (calendarStore.selectedEvent !== null) {
const event = calendarStore.selectedEvent;
setValue("title", event.title || "");
setValues({
title: event.title || "",
tag:
Array.isArray(event.extendedProps?.tags) &&
typeof event.extendedProps.tags[0] === "string"
? event.extendedProps.tags[0]
: "",
allDay: event.allDay ?? true,
description: event.extendedProps?.description || "",
endDate: event.end !== null ? event.end : event.start,
startDate: event.start !== null ? event.start : new Date(),
});
}
}, [calendarStore.selectedEvent, setValue]);
const resetToEmptyValues = useCallback(() => {
setValue("title", "");
setValues(defaultState);
}, [setValue]);
const handleClose = () => {
setValues(defaultState);
clearErrors();
dispatch(selectedEvent(null));
onClose();
};
const onSubmit = (data: { title: string }) => {
const modifiedEvent: AddEventType = {
display: "block",
title: data.title,
end: values.endDate,
allDay: values.allDay,
start: values.startDate,
extendedProps: {
calendar:
calendarStore.selectedEvent?.extendedProps?.calendar || "Business",
description: values.description.length ? values.description : undefined,
tags: values.tag.trim().length ? [values.tag.trim()] : [],
},
};
if (
calendarStore.selectedEvent === null ||
(calendarStore.selectedEvent !== null &&
!calendarStore.selectedEvent.title.length)
) {
dispatch(addEvent(modifiedEvent));
} else {
dispatch(
updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }),
);
}
dispatch(filterEvents());
handleClose();
};
const handleDeleteButtonClick = () => {
if (calendarStore.selectedEvent?.id) {
dispatch(deleteEvent(calendarStore.selectedEvent.id));
dispatch(filterEvents());
}
handleClose();
};
const handleStartDate = (date: Date | null) => {
if (!date) return;
setValues((previous) => ({
...previous,
startDate: new Date(date),
endDate: date > previous.endDate ? new Date(date) : previous.endDate,
}));
};
const isEditMode =
calendarStore.selectedEvent !== null &&
Boolean(calendarStore.selectedEvent.title.length);
useEffect(() => {
if (!open) return;
if (calendarStore.selectedEvent !== null) {
resetToStoredValues();
} else {
resetToEmptyValues();
}
}, [
open,
calendarStore.selectedEvent,
resetToEmptyValues,
resetToStoredValues,
]);
useEffect(() => {
if (!open) return;
if (tagOptions.length) return;
let active = true;
const fetchTags = async () => {
setTagLoading(true);
try {
const events = await eventService.listEvents();
const options = Array.from(
new Set(
events.flatMap((event) =>
Array.isArray(event.tags)
? event.tags
.filter((tag): tag is string => typeof tag === "string")
.map((tag) => tag.trim())
.filter(Boolean)
: [],
),
),
);
if (active) {
setTagOptions(options);
}
} finally {
if (active) {
setTagLoading(false);
}
}
};
fetchTags();
return () => {
active = false;
};
}, [open, tagOptions.length]);
const content = (
<form
onSubmit={handleSubmit(onSubmit)}
autoComplete="off"
className="flex flex-col gap-6"
>
<Controller
name="title"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<CustomTextField
fullWidth
label={t("fields.title")}
value={value}
onChange={onChange}
{...(errors.title && {
error: true,
helperText: t("validation.titleRequired"),
})}
/>
)}
/>
<AppReactDatepicker
id="farmer-calendar-event-start-date"
endDate={values.endDate}
selected={values.startDate}
startDate={values.startDate}
showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={<PickerInput label={t("fields.startDate")} />}
onChange={(date) => {
const nextDate = asSingleDate(date);
if (nextDate !== null) {
setValues((previous) => ({
...previous,
startDate: new Date(nextDate),
}));
}
}}
onSelect={handleStartDate}
/>
<AppReactDatepicker
id="farmer-calendar-event-end-date"
endDate={values.endDate}
selected={values.endDate}
minDate={values.startDate}
startDate={values.startDate}
showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={<PickerInput label={t("fields.endDate")} />}
onChange={(date) => {
const nextDate = asSingleDate(date);
if (nextDate !== null) {
setValues((previous) => ({
...previous,
endDate: new Date(nextDate),
}));
}
}}
/>
<FormControl>
<FormControlLabel
label={t("fields.allDay")}
control={
<Switch
checked={values.allDay}
onChange={(event) =>
setValues((previous) => ({
...previous,
allDay: event.target.checked,
}))
}
/>
}
/>
</FormControl>
<CustomTextField
select
fullWidth
label={tagLabel}
id="farmer-calendar-event-tag"
value={values.tag}
disabled={tagLoading}
onChange={(event) =>
setValues((previous) => ({
...previous,
tag: event.target.value,
}))
}
>
<MenuItem value="">{tagLoading ? "..." : tagPlaceholder}</MenuItem>
{tagOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</CustomTextField>
<CustomTextField
rows={4}
multiline
fullWidth
label={t("fields.description")}
id="farmer-calendar-event-description"
value={values.description}
onChange={(event) =>
setValues((previous) => ({
...previous,
description: event.target.value,
}))
}
/>
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
{isEditMode ? (
<Button
variant="outlined"
color="error"
onClick={handleDeleteButtonClick}
>
{t("actions.delete")}
</Button>
) : null}
<Button variant="outlined" color="secondary" onClick={handleClose}>
{t("actions.cancel")}
</Button>
<Button type="submit" variant="contained">
{isEditMode ? t("actions.update") : t("actions.create")}
</Button>
</Stack>
</form>
);
if (isMobile) {
return (
<Drawer
anchor="bottom"
variant="temporary"
open={open}
onClose={handleClose}
ModalProps={{ keepMounted: true }}
transitionDuration={modalTransitionDuration}
slotProps={{
backdrop: {
sx: { backgroundColor: "rgba(15, 23, 42, 0.2)" },
},
}}
PaperProps={{
sx: {
maxHeight: "85vh",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
}}
>
<div className="flex items-center justify-between border-bs plb-4 pli-6">
<div className="flex flex-col gap-0.5">
<Typography variant="h5">
{isEditMode ? t("titleEdit") : t("titleCreate")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("description")}
</Typography>
</div>
<IconButton
size="small"
onClick={handleClose}
aria-label={t("close")}
>
<i className="tabler-x text-2xl" />
</IconButton>
</div>
<div className="overflow-y-auto pli-6 plb-6">{content}</div>
</Drawer>
);
}
return (
<Dialog
fullWidth
maxWidth="sm"
open={open}
onClose={handleClose}
keepMounted
transitionDuration={modalTransitionDuration}
slotProps={{
backdrop: {
sx: { backgroundColor: "rgba(15, 23, 42, 0.2)" },
},
}}
sx={{
"& .MuiDialog-paper": {
borderRadius: 5,
overflow: "hidden",
},
}}
>
<div className="flex items-center justify-between border-be plb-5 pli-6">
<div className="flex flex-col gap-0.5">
<Typography variant="h5">
{isEditMode ? t("titleEdit") : t("titleCreate")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("description")}
</Typography>
</div>
<IconButton size="small" onClick={handleClose} aria-label={t("close")}>
<i className="tabler-x text-2xl" />
</IconButton>
</div>
<DialogContent>{content}</DialogContent>
</Dialog>
);
};
export default memo(FarmerCalendarEventModal);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
export const FARMER_CALENDAR_TAG_OPTIONS = [
"آبیاری",
"کوددهی",
"سم پاشی",
"برداشت",
"پایش",
"نگهداری",
"تیم",
"لجستیک",
] as const;
export type FarmerCalendarTag =
(typeof FARMER_CALENDAR_TAG_OPTIONS)[number];