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": "هنوز مکالمه‌ای ندارید", "empty": "هنوز مکالمه‌ای ندارید",
"chatLabel": "چت" "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-apexcharts": "1.4.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-colorful": "5.6.1", "react-colorful": "5.6.1",
"react-date-object": "1.1.9", "react-date-object": "^2.1.9",
"react-datepicker": "7.3.0", "react-datepicker": "7.3.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-dropzone": "14.3.5", "react-dropzone": "14.3.5",
@@ -9044,9 +9044,9 @@
} }
}, },
"node_modules/react-date-object": { "node_modules/react-date-object": {
"version": "1.1.9", "version": "2.1.9",
"resolved": "https://mirror-npm.runflare.com/react-date-object/-/react-date-object-1.1.9.tgz", "resolved": "https://mirror-npm.runflare.com/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-NEbp/JSqwpF3nm4bXcez9XGwmlpOjSPJSYoRyPC1XOxzRHHaijp6xjMbYpyKB3O4yrluxsbwBHbO1WgeFKip3Q==", "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-datepicker": { "node_modules/react-datepicker": {
@@ -9197,12 +9197,6 @@
"react-dom": ">=16.8.0" "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": { "node_modules/react-onclickoutside": {
"version": "6.13.1", "version": "6.13.1",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -76,7 +76,7 @@
"react-apexcharts": "1.4.1", "react-apexcharts": "1.4.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-colorful": "5.6.1", "react-colorful": "5.6.1",
"react-date-object": "1.1.9", "react-date-object": "^2.1.9",
"react-datepicker": "7.3.0", "react-datepicker": "7.3.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-dropzone": "14.3.5", "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' import type { BoxProps } from '@mui/material/Box'
// Third-party Imports // Third-party Imports
import { Calendar } from 'react-multi-date-picker' import DatePicker from 'react-multi-date-picker'
import DateObject from 'react-date-object' import DateObject from 'react-date-object'
import persian from 'react-date-object/calendars/persian' import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa' 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 { interface AppJalaliDatepickerProps {
value?: Date | null value?: Date | null
onChange?: (date: Date) => void onChange?: (date: Date) => void
placeholder?: string
boxProps?: BoxProps boxProps?: BoxProps
} }
const AppJalaliDatepicker = (props: AppJalaliDatepickerProps) => { const AppJalaliDatepicker = (props: AppJalaliDatepickerProps) => {
const { value: externalValue, onChange, boxProps } = props const { value: externalValue, onChange, placeholder, boxProps } = props
const theme = useTheme() const theme = useTheme()
const [internalValue, setInternalValue] = useState<DateObject | undefined>(() => { const [internalValue, setInternalValue] = useState<DateObject | undefined>(() => {
@@ -51,46 +52,18 @@ const AppJalaliDatepicker = (props: AppJalaliDatepickerProps) => {
return ( return (
<Box <Box
{...boxProps} {...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} value={displayValue}
onChange={handleChange as any} onChange={handleChange as any}
calendar={persian} calendar={persian}
locale={persian_fa} locale={persian_fa}
className="teal" format='YYYY/MM/DD'
calendarPosition='bottom-right'
inputClass='rmdp-input'
placeholder={placeholder || 'انتخاب تاریخ'}
className='teal'
/> />
</Box> </Box>
) )
+242 -504
View File
@@ -1,526 +1,264 @@
'use client' "use client";
// React Imports import type { ComponentProps, ReactElement } from "react";
import type { ComponentProps } from 'react' import { cloneElement, isValidElement } from "react";
// MUI imports import Box from "@mui/material/Box";
import Box from '@mui/material/Box' import { useTheme } from "@mui/material/styles";
import { styled } from '@mui/material/styles' import type { BoxProps } from "@mui/material/Box";
import type { BoxProps } from '@mui/material/Box'
// Third-party Imports import DatePicker, { Calendar } from "react-multi-date-picker";
import ReactDatePickerComponent from 'react-datepicker' 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-multi-date-picker/styles/colors/teal.css";
import 'react-datepicker/dist/react-datepicker.css'
type Props = ComponentProps<typeof ReactDatePickerComponent> & { type Props = Omit<
boxProps?: BoxProps 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 convertFormat = (format?: string) => {
const StyledReactDatePicker = styled(Box)<BoxProps>(({ theme }) => { if (!format) return "YYYY/MM/DD";
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)'
},
// Time Picker return format
'&:not(.react-datepicker--time-only)': { .replace(/yyyy/g, "YYYY")
'& .react-datepicker__time-container': { .replace(/yy/g, "YY")
borderLeftColor: 'var(--mui-palette-divider)', .replace(/dd/g, "DD")
[theme.breakpoints.down('sm')]: { .replace(/d/g, "D")
width: '5.5rem' .replace(/HH/g, "HH")
}, .replace(/hh/g, "HH")
[theme.breakpoints.up('sm')]: { .replace(/mm/g, "mm");
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)'
},
'& .react-datepicker__time': { const toDate = (value: Value | null | undefined): Date | null => {
background: 'var(--mui-palette-background-paper)', if (!value) return null;
'& .react-datepicker__time-box .react-datepicker__time-list-item--disabled': { if (value instanceof Date) return value;
pointerEvents: 'none', if (typeof value === "string" || typeof value === "number") {
color: 'var(--mui-palette-text-disabled)', const parsed = new Date(value);
'&.react-datepicker__time-list-item--selected': {
fontWeight: 'normal',
backgroundColor: 'var(--mui-palette-action-disabledBackground)'
}
}
},
'& .react-datepicker__time-list-item': { return Number.isNaN(parsed.getTime()) ? null : parsed;
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)'
} }
}, if (Array.isArray(value)) return null;
if (typeof (value as DateObject).toDate === "function")
return (value as DateObject).toDate();
'& .react-datepicker__time-box': { return null;
width: '100%' };
},
'& .react-datepicker__time-list': {
'&::-webkit-scrollbar': {
width: 8
},
/* Track */ const baseSx: BoxProps["sx"] = {
'&::-webkit-scrollbar-track': { "& .rmdp-container": {
background: 'var(--mui-palette-background-paper)' width: "100%",
}, },
"& .rmdp-wrapper, & .rmdp-calendar": {
/* Handle */ width: "100%",
'&::-webkit-scrollbar-thumb': { border: "1px solid var(--mui-palette-divider)",
borderRadius: 10, borderRadius: "var(--mui-shape-borderRadius)",
background: '#aaa' backgroundColor: "var(--mui-palette-background-paper)",
boxShadow: "var(--mui-customShadows-md)",
color: "var(--mui-palette-text-primary)",
}, },
"& .rmdp-shadow": {
/* Handle on hover */ boxShadow: "var(--mui-customShadows-md)",
'&::-webkit-scrollbar-thumb:hover': {
background: '#999'
}
}
}, },
'& .react-datepicker__day:hover, & .react-datepicker__month-text:hover, & .react-datepicker__quarter-text:hover, & .react-datepicker__year-text:hover': "& .rmdp-top-class": {
{ width: "100%",
backgroundColor: 'var(--mui-palette-action-hover)'
}, },
'[data-skin="bordered"] &': { "& .rmdp-header-values": {
boxShadow: 'none', color: "var(--mui-palette-text-primary)",
border: `1px solid var(--mui-palette-divider)` fontWeight: 700,
}
}, },
'& .react-datepicker__close-icon': { "& .rmdp-arrow-container:hover": {
top: 10, backgroundColor: "var(--mui-palette-primary-lightOpacity)",
paddingRight: theme.spacing(4), },
...(theme.direction === 'rtl' ? { right: 0, left: 'auto' } : {}), "& .rmdp-arrow": {
'&:after': { borderColor: "var(--mui-palette-text-secondary)",
width: 'unset', },
height: 'unset', "& .rmdp-week-day": {
fontSize: '1.5rem', color: "var(--mui-palette-text-secondary)",
color: 'var(--mui-palette-text-primary)', fontWeight: 700,
backgroundColor: 'transparent !important' },
} "& .rmdp-day, & .rmdp-time-picker div input": {
} color: "var(--mui-palette-text-primary)",
} },
}) as typeof Box "& .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) => { const AppReactDatepicker = (props: Props) => {
// Props const {
const { boxProps, ...rest } = props boxProps,
selected,
startDate,
endDate,
selectsRange,
showTimeSelect,
customInput,
placeholderText,
onChange,
onSelect,
inline,
dateFormat,
minDate,
maxDate,
shouldCloseOnSelect,
...rest
} = props;
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;
}
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 ( return (
<StyledReactDatePicker {...boxProps}> <Box
<ReactDatePickerComponent popperPlacement='bottom-start' {...rest} /> onClick={openCalendar}
</StyledReactDatePicker> 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;
export default AppReactDatepicker 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 // React Imports
import { useState, useEffect, forwardRef, useCallback } from 'react' import { useState, useEffect, forwardRef, useCallback } from "react";
// MUI Imports // MUI Imports
import Box from '@mui/material/Box' import Box from "@mui/material/Box";
import Drawer from '@mui/material/Drawer' import Drawer from "@mui/material/Drawer";
import Switch from '@mui/material/Switch' import Switch from "@mui/material/Switch";
import Button from '@mui/material/Button' import Button from "@mui/material/Button";
import MenuItem from '@mui/material/MenuItem' import IconButton from "@mui/material/IconButton";
import IconButton from '@mui/material/IconButton' import Typography from "@mui/material/Typography";
import Typography from '@mui/material/Typography' import useMediaQuery from "@mui/material/useMediaQuery";
import useMediaQuery from '@mui/material/useMediaQuery' import FormControl from "@mui/material/FormControl";
import FormControl from '@mui/material/FormControl' import FormControlLabel from "@mui/material/FormControlLabel";
import FormControlLabel from '@mui/material/FormControlLabel' import type { Theme } from "@mui/material/styles";
import type { SelectChangeEvent } from '@mui/material/Select'
import type { Theme } from '@mui/material/styles'
// Third-party Imports // Third-party Imports
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from "react-hook-form";
import PerfectScrollbar from 'react-perfect-scrollbar' import PerfectScrollbar from "react-perfect-scrollbar";
// Type Imports // Type Imports
import type { AddEventSidebarType, AddEventType } from '@/types/apps/calendarTypes' import type {
AddEventSidebarType,
AddEventType,
} from "@/types/apps/calendarTypes";
// Component Imports // Component Imports
import CustomTextField from '@core/components/mui/TextField' import CustomTextField from "@core/components/mui/TextField";
// Styled Component Imports // Styled Component Imports
import AppReactDatepicker from '@/libs/styles/AppReactDatepicker' import AppReactDatepicker from "@/libs/styles/AppReactDatepicker";
// Slice Imports // 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 { interface PickerProps {
label?: string label?: string;
error?: boolean error?: boolean;
registername?: string registername?: string;
} }
interface DefaultStateType { interface DefaultStateType {
url: string url: string;
title: string title: string;
allDay: boolean allDay: boolean;
calendar: string calendar: string;
description: string description: string;
endDate: Date endDate: Date;
startDate: Date startDate: Date;
guests: string[] | undefined guests: string[] | undefined;
} }
// Vars // Vars
const capitalize = (string: string) => string && string[0].toUpperCase() + string.slice(1) const capitalize = (string: string) =>
string && string[0].toUpperCase() + string.slice(1);
// Vars // Vars
const defaultState: DefaultStateType = { const defaultState: DefaultStateType = {
url: '', url: "",
title: '', title: "",
guests: [], guests: [],
allDay: true, allDay: true,
description: '', description: "",
endDate: new Date(), endDate: new Date(),
calendar: 'Business', calendar: "Business",
startDate: new Date() startDate: new Date(),
} };
const AddEventSidebar = (props: AddEventSidebarType) => { const AddEventSidebar = (props: AddEventSidebarType) => {
// Props // Props
const { calendarStore, dispatch, addEventSidebarOpen, handleAddEventSidebarToggle } = props const {
calendarStore,
dispatch,
addEventSidebarOpen,
handleAddEventSidebarToggle,
} = props;
// States // States
const [values, setValues] = useState<DefaultStateType>(defaultState) const [values, setValues] = useState<DefaultStateType>(defaultState);
// Refs // Refs
const PickersComponent = forwardRef(({ ...props }: PickerProps, ref) => { const PickersComponent = forwardRef(({ ...props }: PickerProps, ref) => {
@@ -77,109 +90,108 @@ const AddEventSidebar = (props: AddEventSidebarType) => {
inputRef={ref} inputRef={ref}
fullWidth fullWidth
{...props} {...props}
label={props.label || ''} label={props.label || ""}
className='is-full' className="is-full"
error={props.error} error={props.error}
/> />
) );
}) });
// Hooks // Hooks
const isBelowSmScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')) const isBelowSmScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down("sm"),
);
const { const {
control, control,
setValue, setValue,
clearErrors, clearErrors,
handleSubmit, handleSubmit,
formState: { errors } formState: { errors },
} = useForm({ defaultValues: { title: '' } }) } = useForm({ defaultValues: { title: "" } });
const resetToStoredValues = useCallback(() => { const resetToStoredValues = useCallback(() => {
if (calendarStore.selectedEvent !== null) { if (calendarStore.selectedEvent !== null) {
const event = calendarStore.selectedEvent const event = calendarStore.selectedEvent;
setValue('title', event.title || '') setValue("title", event.title || "");
setValues({ setValues({
url: event.url || '', url: event.url || "",
title: event.title || '', title: event.title || "",
allDay: event.allDay, allDay: event.allDay,
guests: event.extendedProps.guests || [], guests: event.extendedProps.guests || [],
description: event.extendedProps.description || '', description: event.extendedProps.description || "",
calendar: event.extendedProps.calendar || 'Business', calendar: event.extendedProps.calendar || "Business",
endDate: event.end !== null ? event.end : event.start, 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(() => { const resetToEmptyValues = useCallback(() => {
setValue('title', '') setValue("title", "");
setValues(defaultState) setValues(defaultState);
}, [setValue]) }, [setValue]);
const handleSidebarClose = () => { const handleSidebarClose = () => {
setValues(defaultState) setValues(defaultState);
clearErrors() clearErrors();
dispatch(selectedEvent(null)) dispatch(selectedEvent(null));
handleAddEventSidebarToggle() handleAddEventSidebarToggle();
} };
const onSubmit = (data: { title: string }) => { const onSubmit = (data: { title: string }) => {
const modifiedEvent: AddEventType = { const modifiedEvent: AddEventType = {
url: values.url, url: values.url,
display: 'block', display: "block",
title: data.title, title: data.title,
end: values.endDate, end: values.endDate,
allDay: values.allDay, allDay: values.allDay,
start: values.startDate, start: values.startDate,
extendedProps: { extendedProps: {
calendar: capitalize(values.calendar), calendar: capitalize(values.calendar),
guests: values.guests && values.guests.length ? values.guests : undefined, guests:
description: values.description.length ? values.description : undefined 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 || {}
}
if ( if (
calendarStore.selectedEvent === null || calendarStore.selectedEvent === null ||
(calendarStore.selectedEvent !== null && !calendarStore.selectedEvent.title.length) (calendarStore.selectedEvent !== null &&
!calendarStore.selectedEvent.title.length)
) { ) {
dispatch(createEventAsync(eventData)) dispatch(addEvent(modifiedEvent));
} else { } else {
dispatch(updateEventAsync({ id: calendarStore.selectedEvent.id, data: eventData })) dispatch(
updateEvent({ ...modifiedEvent, id: calendarStore.selectedEvent.id }),
);
} }
dispatch(filterEvents()) dispatch(filterEvents());
handleSidebarClose() handleSidebarClose();
} };
const handleDeleteButtonClick = () => { const handleDeleteButtonClick = () => {
if (calendarStore.selectedEvent) { if (calendarStore.selectedEvent) {
dispatch(deleteEventAsync(calendarStore.selectedEvent.id)) dispatch(deleteEvent(calendarStore.selectedEvent.id));
dispatch(filterEvents()) dispatch(filterEvents());
} }
handleSidebarClose() // calendarApi.getEventById(calendarStore.selectedEvent.id).remove()
} handleSidebarClose();
};
const handleStartDate = (date: Date | null) => { const handleStartDate = (date: Date | null) => {
if (date && date > values.endDate) { 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 = () => { const RenderSidebarFooter = () => {
if ( if (
@@ -187,184 +199,180 @@ const AddEventSidebar = (props: AddEventSidebarType) => {
(calendarStore.selectedEvent && !calendarStore.selectedEvent.title.length) (calendarStore.selectedEvent && !calendarStore.selectedEvent.title.length)
) { ) {
return ( return (
<div className='flex gap-4'> <div className="flex gap-4">
<Button type='submit' variant='contained'> <Button type="submit" variant="contained">
Add Add
</Button> </Button>
<Button variant='outlined' color='secondary' onClick={resetToEmptyValues}> <Button
variant="outlined"
color="secondary"
onClick={resetToEmptyValues}
>
Reset Reset
</Button> </Button>
</div> </div>
) );
} else { } else {
return ( return (
<div className='flex gap-4'> <div className="flex gap-4">
<Button type='submit' variant='contained'> <Button type="submit" variant="contained">
Update Update
</Button> </Button>
<Button variant='outlined' color='secondary' onClick={resetToStoredValues}> <Button
variant="outlined"
color="secondary"
onClick={resetToStoredValues}
>
Reset Reset
</Button> </Button>
</div> </div>
) );
}
} }
};
const ScrollWrapper = isBelowSmScreen ? 'div' : PerfectScrollbar const ScrollWrapper = isBelowSmScreen ? "div" : PerfectScrollbar;
useEffect(() => { useEffect(() => {
if (calendarStore.selectedEvent !== null) { if (calendarStore.selectedEvent !== null) {
resetToStoredValues() resetToStoredValues();
} else { } else {
resetToEmptyValues() resetToEmptyValues();
} }
}, [addEventSidebarOpen, resetToStoredValues, resetToEmptyValues, calendarStore.selectedEvent]) }, [
addEventSidebarOpen,
resetToStoredValues,
resetToEmptyValues,
calendarStore.selectedEvent,
]);
return ( return (
<Drawer <Drawer
anchor='right' anchor="right"
open={addEventSidebarOpen} open={addEventSidebarOpen}
onClose={handleSidebarClose} onClose={handleSidebarClose}
ModalProps={{ keepMounted: true }} 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'> <Box className="flex justify-between items-center sidebar-header plb-5 pli-6 border-be">
<Typography variant='h5'> <Typography variant="h5">
{calendarStore.selectedEvent && calendarStore.selectedEvent.title.length ? 'Update Event' : 'Add Event'} {calendarStore.selectedEvent &&
calendarStore.selectedEvent.title.length
? "Update Event"
: "Add Event"}
</Typography> </Typography>
{calendarStore.selectedEvent && calendarStore.selectedEvent.title.length ? ( {calendarStore.selectedEvent &&
<Box className='flex items-center' sx={{ gap: calendarStore.selectedEvent !== null ? 1 : 0 }}> calendarStore.selectedEvent.title.length ? (
<IconButton size='small' onClick={handleDeleteButtonClick}> <Box
<i className='tabler-trash text-2xl text-textPrimary' /> 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>
<IconButton size='small' onClick={handleSidebarClose}> <IconButton size="small" onClick={handleSidebarClose}>
<i className='tabler-x text-2xl text-textPrimary' /> <i className="tabler-x text-2xl text-textPrimary" />
</IconButton> </IconButton>
</Box> </Box>
) : ( ) : (
<IconButton size='small' onClick={handleSidebarClose}> <IconButton size="small" onClick={handleSidebarClose}>
<i className='tabler-x text-2xl text-textPrimary' /> <i className="tabler-x text-2xl text-textPrimary" />
</IconButton> </IconButton>
)} )}
</Box> </Box>
<ScrollWrapper <ScrollWrapper
{...(isBelowSmScreen {...(isBelowSmScreen
? { className: 'bs-full overflow-y-auto overflow-x-hidden' } ? { className: "bs-full overflow-y-auto overflow-x-hidden" }
: { options: { wheelPropagation: false, suppressScrollX: true } })} : { options: { wheelPropagation: false, suppressScrollX: true } })}
> >
<Box className='sidebar-body plb-5 pli-6'> <Box className="sidebar-body plb-5 pli-6">
<form onSubmit={handleSubmit(onSubmit)} autoComplete='off' className='flex flex-col gap-6'> <form
onSubmit={handleSubmit(onSubmit)}
autoComplete="off"
className="flex flex-col gap-6"
>
<Controller <Controller
name='title' name="title"
control={control} control={control}
rules={{ required: true }} rules={{ required: true }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomTextField <CustomTextField
fullWidth fullWidth
label='عنوان' label="Title"
value={value} value={value}
onChange={onChange} 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 <AppReactDatepicker
selectsStart selectsStart
id='event-start-date' id="event-start-date"
endDate={values.endDate} endDate={values.endDate}
selected={values.startDate} selected={values.startDate}
startDate={values.startDate} startDate={values.startDate}
showTimeSelect={!values.allDay} showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? 'yyyy-MM-dd hh:mm' : 'yyyy-MM-dd'} dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={<PickersComponent label='Start Date' registername='startDate' />} customInput={
onChange={(date: Date | null) => date !== null && setValues({ ...values, startDate: new Date(date) })} <PickersComponent label="Start Date" registername="startDate" />
}
onChange={(date: Date | null) =>
date !== null &&
setValues({ ...values, startDate: new Date(date) })
}
onSelect={handleStartDate} onSelect={handleStartDate}
/> />
<AppReactDatepicker <AppReactDatepicker
selectsEnd selectsEnd
id='event-end-date' id="event-end-date"
endDate={values.endDate} endDate={values.endDate}
selected={values.endDate} selected={values.endDate}
minDate={values.startDate} minDate={values.startDate}
startDate={values.startDate} startDate={values.startDate}
showTimeSelect={!values.allDay} showTimeSelect={!values.allDay}
dateFormat={!values.allDay ? 'yyyy-MM-dd hh:mm' : 'yyyy-MM-dd'} dateFormat={!values.allDay ? "yyyy-MM-dd hh:mm" : "yyyy-MM-dd"}
customInput={<PickersComponent label='End Date' registername='endDate' />} customInput={
onChange={(date: Date | null) => date !== null && setValues({ ...values, endDate: new Date(date) })} <PickersComponent label="End Date" registername="endDate" />
}
onChange={(date: Date | null) =>
date !== null &&
setValues({ ...values, endDate: new Date(date) })
}
/> />
<FormControl> <FormControl>
<FormControlLabel <FormControlLabel
label='All Day' label="All Day"
control={ 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> </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 <CustomTextField
rows={4} rows={4}
multiline multiline
fullWidth fullWidth
label='توضیحات' label="Description"
id='event-description' id="event-description"
value={values.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 /> <RenderSidebarFooter />
</div> </div>
</form> </form>
</Box> </Box>
</ScrollWrapper> </ScrollWrapper>
</Drawer> </Drawer>
) );
} };
export default AddEventSidebar export default AddEventSidebar;
+107 -168
View File
@@ -1,53 +1,68 @@
// React Imports // React Imports
import { useEffect, useRef } from 'react' import { useEffect, useRef } from "react";
// MUI Imports // MUI Imports
import { useTheme } from '@mui/material/styles' import { useTheme } from "@mui/material/styles";
// Third-party imports // 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 FullCalendar from "@fullcalendar/react";
import faLocale from '@fullcalendar/core/locales/fa' import listPlugin from "@fullcalendar/list";
import listPlugin from '@fullcalendar/list' import dayGridPlugin from "@fullcalendar/daygrid";
import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from "@fullcalendar/timegrid";
import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from "@fullcalendar/interaction";
import interactionPlugin from '@fullcalendar/interaction' import type { CalendarOptions } from "@fullcalendar/core";
import type { CalendarOptions } from '@fullcalendar/core' import faLocale from "@fullcalendar/core/locales/fa";
// Type Imports // Type Imports
import type { AddEventType, CalendarColors, CalendarType } from '@/types/apps/calendarTypes' import type {
import type { AppDispatch } from '@/redux-store' AddEventType,
CalendarColors,
CalendarType,
} from "@/types/apps/calendarTypes";
// Slice Imports // Slice Imports
import { filterEvents, selectedEvent, updateEventAsync } from '@/redux-store/slices/calendar' import {
filterEvents,
selectedEvent,
updateEvent,
} from "@/redux-store/slices/calendar";
type CalenderProps = { type CalenderProps = {
calendarStore: CalendarType calendarStore: CalendarType;
calendarApi: any calendarApi: any;
setCalendarApi: (val: any) => void setCalendarApi: (val: any) => void;
calendarsColor: CalendarColors calendarsColor: CalendarColors;
dispatch?: AppDispatch dispatch: Dispatch;
handleLeftSidebarToggle?: () => void handleLeftSidebarToggle: () => void;
handleAddEventSidebarToggle?: () => void handleAddEventSidebarToggle: () => void;
editable?: boolean handleEventDetailsOpen: () => void;
showSidebarToggle?: boolean };
onDateClick?: (date: Date) => void
onEventClick?: (event: any) => void
}
const blankEvent: AddEventType = { const blankEvent: AddEventType = {
title: '', title: "",
start: '', start: "",
end: '', end: "",
allDay: false, allDay: false,
url: '', url: "",
extendedProps: { extendedProps: {
calendar: '', calendar: "",
guests: [], 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) => { const Calendar = (props: CalenderProps) => {
// Props // Props
@@ -58,110 +73,58 @@ const Calendar = (props: CalenderProps) => {
calendarsColor, calendarsColor,
dispatch, dispatch,
handleAddEventSidebarToggle, handleAddEventSidebarToggle,
handleEventDetailsOpen,
handleLeftSidebarToggle, handleLeftSidebarToggle,
editable = true, } = props;
showSidebarToggle = true,
onDateClick,
onEventClick
} = props
// Refs // Refs
const calendarRef = useRef() const calendarRef = useRef();
// Hooks // Hooks
const theme = useTheme() const theme = useTheme();
useEffect(() => { useEffect(() => {
if (calendarApi === null) { if (calendarApi === null) {
// @ts-ignore // @ts-ignore
setCalendarApi(calendarRef.current?.getApi()) setCalendarApi(calendarRef.current?.getApi());
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, []);
// calendarOptions(Props) // calendarOptions(Props)
const calendarOptions: CalendarOptions = { const calendarOptions: CalendarOptions = {
events: calendarStore.events, events: calendarStore.events,
plugins: [interactionPlugin, dayGridPlugin, timeGridPlugin, listPlugin], plugins: [interactionPlugin, dayGridPlugin, timeGridPlugin, listPlugin],
initialView: 'dayGridMonth', locale: faLocale,
locale: 'fa', initialView: "dayGridMonth",
headerToolbar: { headerToolbar: {
start: `${showSidebarToggle ? 'sidebarToggle,' : ''}prev,next,title`, start: "sidebarToggle, prev, next, title",
end: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth' end: "dayGridMonth,timeGridWeek,timeGridDay,listMonth",
},
buttonText: {
today: "امروز",
month: "ماه",
week: "هفته",
day: "روز",
list: "فهرست",
}, },
views: { views: {
week: { week: {
titleFormat(arg: any) { titleFormat: { year: "numeric", month: "short", day: "numeric" },
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)}`
}
}
}, },
dayHeaderContent(arg) { dayHeaderContent(arg) {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { weekday: 'short' }) return formatPersianWeekday(arg.date);
return formatter.format(arg.date)
}, },
titleFormat(arg: any) { dayCellContent(arg) {
const { start, end } = arg return formatPersianDay(arg.date);
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: 'لیست'
}, },
/* /*
Enable dragging and resizing event Enable dragging and resizing event
? Docs: https://fullcalendar.io/docs/editable ? Docs: https://fullcalendar.io/docs/editable
*/ */
editable, editable: true,
/* /*
Enable resizing event from start Enable resizing event from start
@@ -189,28 +152,30 @@ const Calendar = (props: CalenderProps) => {
eventClassNames({ event: calendarEvent }: any) { eventClassNames({ event: calendarEvent }: any) {
// @ts-ignore // @ts-ignore
const colorName = calendarsColor[calendarEvent._def.extendedProps.calendar] const colorName =
calendarsColor[calendarEvent._def.extendedProps.calendar];
return [ return [
// Background Color // Background Color
`event-bg-${colorName}` `event-bg-${colorName}`,
] ];
}, },
eventClick({ event: clickedEvent, jsEvent }: any) { eventClick({ event: clickedEvent, jsEvent }: any) {
jsEvent.preventDefault() jsEvent.preventDefault();
onEventClick?.(clickedEvent) dispatch(
selectedEvent({
if (dispatch && handleAddEventSidebarToggle) { id: clickedEvent.id,
dispatch(selectedEvent(clickedEvent)) url: clickedEvent.url,
handleAddEventSidebarToggle() title: clickedEvent.title,
} allDay: clickedEvent.allDay,
end: clickedEvent.end,
if (clickedEvent.url) { start: clickedEvent.start,
// Open the URL in a new tab extendedProps: clickedEvent.extendedProps,
window.open(clickedEvent.url, '_blank') }),
} );
handleEventDetailsOpen();
//* Only grab required field otherwise it goes in infinity loop //* Only grab required field otherwise it goes in infinity loop
//! Always grab all fields rendered by form (even if it get `undefined`) //! Always grab all fields rendered by form (even if it get `undefined`)
@@ -220,28 +185,22 @@ const Calendar = (props: CalenderProps) => {
customButtons: { customButtons: {
sidebarToggle: { sidebarToggle: {
icon: 'tabler tabler-menu-2', icon: "tabler tabler-menu-2",
click() { click() {
handleLeftSidebarToggle?.() handleLeftSidebarToggle();
} },
} },
}, },
dateClick(info: any) { dateClick(info: any) {
onDateClick?.(info.date) const ev = { ...blankEvent };
if (!dispatch || !handleAddEventSidebarToggle) { ev.start = info.date;
return ev.end = info.date;
} ev.allDay = true;
const ev = { ...blankEvent } dispatch(selectedEvent(ev));
handleAddEventSidebarToggle();
ev.start = info.date
ev.end = info.date
ev.allDay = true
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 ? 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) { eventDrop({ event: droppedEvent }: any) {
if (!dispatch) { dispatch(updateEvent(droppedEvent));
return dispatch(filterEvents());
}
// 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())
}, },
/* /*
@@ -269,27 +218,17 @@ const Calendar = (props: CalenderProps) => {
? Docs: https://fullcalendar.io/docs/eventResize ? Docs: https://fullcalendar.io/docs/eventResize
*/ */
eventResize({ event: resizedEvent }: any) { eventResize({ event: resizedEvent }: any) {
if (!dispatch) { dispatch(updateEvent(resizedEvent));
return dispatch(filterEvents());
}
// 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())
}, },
// @ts-ignore // @ts-ignore
ref: calendarRef, 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' '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 = () => { const AppCalendar = () => {
return <></> // States
// // States const [calendarApi, setCalendarApi] = useState<null | any>(null)
// const [calendarApi, setCalendarApi] = useState<null | any>(null) const [leftSidebarOpen, setLeftSidebarOpen] = useState<boolean>(false)
// const [leftSidebarOpen, setLeftSidebarOpen] = useState<boolean>(false) const [addEventSidebarOpen, setAddEventSidebarOpen] = useState<boolean>(false)
// const [addEventSidebarOpen, setAddEventSidebarOpen] = useState<boolean>(false)
// // Hooks // Hooks
// const dispatch = useAppDispatch() const dispatch = useDispatch()
// const calendarStore = useSelector((state: { calendarReducer: CalendarType & { loading: boolean; error: string | null } }) => state.calendarReducer) const calendarStore = useSelector((state: { calendarReducer: CalendarType }) => state.calendarReducer)
// const mdAbove = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')) const mdAbove = useMediaQuery((theme: Theme) => theme.breakpoints.up('md'))
// // Fetch events on mount const handleLeftSidebarToggle = () => setLeftSidebarOpen(!leftSidebarOpen)
// useEffect(() => {
// dispatch(fetchEvents())
// }, [dispatch])
// const handleLeftSidebarToggle = () => setLeftSidebarOpen(!leftSidebarOpen) const handleAddEventSidebarToggle = () => setAddEventSidebarOpen(!addEventSidebarOpen)
// const handleAddEventSidebarToggle = () => setAddEventSidebarOpen(!addEventSidebarOpen) return (
<>
// return ( <SidebarLeft
// <> mdAbove={mdAbove}
// <SidebarLeft dispatch={dispatch}
// mdAbove={mdAbove} calendarApi={calendarApi}
// dispatch={dispatch} calendarStore={calendarStore}
// calendarApi={calendarApi} calendarsColor={calendarsColor}
// calendarStore={calendarStore} leftSidebarOpen={leftSidebarOpen}
// calendarsColor={calendarsColor} handleLeftSidebarToggle={handleLeftSidebarToggle}
// leftSidebarOpen={leftSidebarOpen} handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// handleLeftSidebarToggle={handleLeftSidebarToggle} />
// handleAddEventSidebarToggle={handleAddEventSidebarToggle} <div className='p-6 pbe-0 flex-grow overflow-visible bg-backgroundPaper rounded'>
// /> <Calendar
// <div className='p-6 pbe-0 flex-grow overflow-visible bg-backgroundPaper rounded'> dispatch={dispatch}
// <Calendar calendarApi={calendarApi}
// dispatch={dispatch} calendarStore={calendarStore}
// calendarApi={calendarApi} setCalendarApi={setCalendarApi}
// calendarStore={calendarStore} calendarsColor={calendarsColor}
// setCalendarApi={setCalendarApi} handleLeftSidebarToggle={handleLeftSidebarToggle}
// calendarsColor={calendarsColor} handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// handleLeftSidebarToggle={handleLeftSidebarToggle} />
// handleAddEventSidebarToggle={handleAddEventSidebarToggle} </div>
// /> <AddEventSidebar
// </div> dispatch={dispatch}
// <AddEventSidebar calendarApi={calendarApi}
// dispatch={dispatch} calendarStore={calendarStore}
// calendarApi={calendarApi} addEventSidebarOpen={addEventSidebarOpen}
// calendarStore={calendarStore} handleAddEventSidebarToggle={handleAddEventSidebarToggle}
// addEventSidebarOpen={addEventSidebarOpen} />
// handleAddEventSidebarToggle={handleAddEventSidebarToggle} </>
// /> )
// </>
// )
} }
export default AppCalendar 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 // Types Imports
import type { SidebarLeftProps, CalendarFiltersType } from '@/types/apps/calendarTypes' 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) => { const SidebarLeft = (props: SidebarLeftProps) => {
// Props // Props
@@ -15,205 +32,106 @@ const SidebarLeft = (props: SidebarLeftProps) => {
handleAddEventSidebarToggle handleAddEventSidebarToggle
} = props } = props
// // Vars // Vars
// const colorsArr = calendarsColor ? Object.entries(calendarsColor) : [] const colorsArr = calendarsColor ? Object.entries(calendarsColor) : []
// const renderFilters = colorsArr.length const renderFilters = colorsArr.length
// ? colorsArr.map(([key, value]: string[]) => { ? colorsArr.map(([key, value]: string[]) => {
// return ( return (
// <FormControlLabel <FormControlLabel
// className='mbe-1' className='mbe-1'
// key={key} key={key}
// label={key} label={key}
// control={ control={
// <Checkbox <Checkbox
// color={value as ThemeColor} color={value as ThemeColor}
// checked={calendarStore.selectedCalendars.indexOf(key as CalendarFiltersType) > -1} checked={calendarStore.selectedCalendars.indexOf(key as CalendarFiltersType) > -1}
// onChange={() => dispatch(filterCalendarLabel(key as CalendarFiltersType))} onChange={() => dispatch(filterCalendarLabel(key as CalendarFiltersType))}
// /> />
// } }
// /> />
// ) )
// }) })
// : null : null
// const handleSidebarToggleSidebar = () => { const handleSidebarToggleSidebar = () => {
// dispatch(selectedEvent(null)) dispatch(selectedEvent(null))
// handleAddEventSidebarToggle() handleAddEventSidebarToggle()
// } }
// if (renderFilters) { if (renderFilters) {
// return ( return (
// <Drawer <Drawer
// open={leftSidebarOpen} open={leftSidebarOpen}
// onClose={handleLeftSidebarToggle} onClose={handleLeftSidebarToggle}
// variant={mdAbove ? 'permanent' : 'temporary'} variant={mdAbove ? 'permanent' : 'temporary'}
// ModalProps={{ ModalProps={{
// disablePortal: true, disablePortal: true,
// disableAutoFocus: true, disableAutoFocus: true,
// disableScrollLock: true, disableScrollLock: true,
// keepMounted: true // Better open performance on mobile. keepMounted: true // Better open performance on mobile.
// }} }}
// className={classnames('block', { static: mdAbove, absolute: !mdAbove })} className={classnames('block', { static: mdAbove, absolute: !mdAbove })}
// PaperProps={{ PaperProps={{
// className: classnames('items-start is-[280px] shadow-none rounded rounded-se-none rounded-ee-none', { className: classnames('items-start is-[280px] shadow-none rounded rounded-se-none rounded-ee-none', {
// static: mdAbove, static: mdAbove,
// absolute: !mdAbove absolute: !mdAbove
// }) })
// }} }}
// sx={{ sx={{
// zIndex: 3, zIndex: 3,
// '& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
// zIndex: mdAbove ? 2 : 'drawer' zIndex: mdAbove ? 2 : 'drawer'
// }, },
// '& .MuiBackdrop-root': { '& .MuiBackdrop-root': {
// borderRadius: 1, borderRadius: 1,
// position: 'absolute' position: 'absolute'
// } }
// }} }}
// > >
// <div className='is-full p-6'> <div className='is-full p-6'>
// <Button <Button
// fullWidth fullWidth
// variant='contained' variant='contained'
// onClick={handleSidebarToggleSidebar} onClick={handleSidebarToggleSidebar}
// startIcon={<i className='tabler-plus' />} startIcon={<i className='tabler-plus' />}
// > >
// Add Event Add Event
// </Button> </Button>
// </div> </div>
// <Divider className='is-full' /> <Divider className='is-full' />
// <AppJalaliDatepicker <AppReactDatepicker
// onChange={date => calendarApi?.gotoDate(date)} inline
// boxProps={{ onChange={date => calendarApi.gotoDate(date)}
// className: 'flex justify-center is-full', boxProps={{
// sx: { '& .react-datepicker': { boxShadow: 'none !important', border: 'none !important' } } className: 'flex justify-center is-full',
// }} sx: { '& .react-datepicker': { boxShadow: 'none !important', border: 'none !important' } }
// /> }}
// <Divider className='is-full' /> />
<Divider className='is-full' />
// <div className='flex flex-col p-6 is-full'> <div className='flex flex-col p-6 is-full'>
// <Typography variant='h5' className='mbe-4'> <Typography variant='h5' className='mbe-4'>
// Event Filters Event Filters
// </Typography> </Typography>
// <FormControlLabel <FormControlLabel
// className='mbe-1' className='mbe-1'
// label='View All' label='View All'
// control={ control={
// <Checkbox <Checkbox
// color='secondary' color='secondary'
// checked={calendarStore.selectedCalendars.length === colorsArr.length} checked={calendarStore.selectedCalendars.length === colorsArr.length}
// onChange={e => dispatch(filterAllCalendarLabels(e.target.checked))} onChange={e => dispatch(filterAllCalendarLabels(e.target.checked))}
// /> />
// } }
// /> />
// {renderFilters} {renderFilters}
// </div> </div>
// </Drawer> </Drawer>
// ) )
// } else { } else {
// return null 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 <></>
} }
export default SidebarLeft 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];