feat-project-workflow-updates

مخزن کامیت contained در
مسعود نیک زاد 2026-08-31 14:30:21 +03:30
والد 4b9621f41f
کامیت bb30ac6f30
27فایلهای تغییر یافته به همراه1316 افزوده شده و 109 حذف شده

1
.gitignore فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1 @@
.tmp/

مشاهده پرونده

@ -13,6 +13,7 @@ use App\Models\Decision;
use App\Models\Meeting; use App\Models\Meeting;
use App\Models\MeetingActionItem; use App\Models\MeetingActionItem;
use App\Models\MeetingType; use App\Models\MeetingType;
use App\Models\Setting;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Services\NotificationService; use App\Services\NotificationService;
@ -411,6 +412,58 @@ class MeetingController extends Controller
]); ]);
} }
public function effectiveness(Request $request, Meeting $meeting): JsonResponse
{
$enabled = (bool) (Setting::where('key', 'meeting_effectiveness_enabled')->value('value') ?? false);
$reviews = $meeting->effectivenessReviews()->with('user:id,name')->latest()->get();
$ratingFields = ['objective_clarity', 'time_management', 'participation', 'outcome_quality', 'overall_rating'];
$averages = collect($ratingFields)->mapWithKeys(fn (string $field) => [
$field => $reviews->isEmpty() ? null : round((float) $reviews->avg($field), 1),
]);
return response()->json([
'success' => true,
'data' => [
'enabled' => $enabled,
'can_review' => $enabled && $meeting->status === 'completed',
'current_user_review' => $reviews->firstWhere('user_id', $request->user()->id),
'summary' => ['count' => $reviews->count(), 'averages' => $averages],
],
'message' => 'ارزیابی اثربخشی جلسه',
]);
}
public function storeEffectiveness(Request $request, Meeting $meeting): JsonResponse
{
$enabled = (bool) (Setting::where('key', 'meeting_effectiveness_enabled')->value('value') ?? false);
if (! $enabled) {
return response()->json(['success' => false, 'message' => 'ارزیابی اثربخشی جلسه غیرفعال است.'], 422);
}
if ($meeting->status !== 'completed') {
return response()->json(['success' => false, 'message' => 'ارزیابی فقط پس از پایان جلسه قابل ثبت است.'], 422);
}
$data = $request->validate([
'objective_clarity' => 'required|integer|between:1,5',
'time_management' => 'required|integer|between:1,5',
'participation' => 'required|integer|between:1,5',
'outcome_quality' => 'required|integer|between:1,5',
'overall_rating' => 'required|integer|between:1,5',
'comment' => 'nullable|string|max:2000',
]);
$review = $meeting->effectivenessReviews()->updateOrCreate(
['user_id' => $request->user()->id],
$data,
);
return response()->json([
'success' => true,
'data' => $review->fresh()->load('user:id,name'),
'message' => $review->wasRecentlyCreated ? 'ارزیابی جلسه ثبت شد' : 'ارزیابی جلسه به‌روزرسانی شد',
], $review->wasRecentlyCreated ? 201 : 200);
}
public function addDecision(Request $request, Meeting $meeting): JsonResponse public function addDecision(Request $request, Meeting $meeting): JsonResponse
{ {
$data = $request->validate([ $data = $request->validate([

مشاهده پرونده

@ -6,10 +6,23 @@ use App\Http\Controllers\Controller;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class SettingController extends Controller class SettingController extends Controller
{ {
public function branding(Request $request): JsonResponse
{
$companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه';
$logoPath = Setting::where('key', 'company_logo')->value('value');
return response()->json([
'success' => true,
'data' => $this->brandingData($request, $companyName, $logoPath),
'message' => 'هویت سازمان',
]);
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
@ -103,4 +116,65 @@ class SettingController extends Controller
], 500); ], 500);
} }
} }
public function uploadCompanyLogo(Request $request): JsonResponse
{
$request->validate([
'logo' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
$setting = Setting::firstOrNew(['key' => 'company_logo']);
$previousPath = is_string($setting->value) ? $setting->value : null;
$path = $request->file('logo')->store('organization-logos', 'public');
$setting->fill(['value' => $path, 'group' => 'general', 'type' => 'string'])->save();
if ($previousPath && str_starts_with($previousPath, 'organization-logos/')) {
Storage::disk('public')->delete($previousPath);
}
$companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه';
return response()->json([
'success' => true,
'data' => [
'setting' => $setting->fresh(),
'branding' => $this->brandingData($request, $companyName, $path),
],
'message' => 'لوگوی شرکت بارگذاری شد',
], 201);
}
public function removeCompanyLogo(Request $request): JsonResponse
{
$setting = Setting::where('key', 'company_logo')->first();
$path = is_string($setting?->value) ? $setting->value : null;
if ($path && str_starts_with($path, 'organization-logos/')) {
Storage::disk('public')->delete($path);
}
$setting?->update(['value' => '']);
$companyName = Setting::where('key', 'company_name')->value('value') ?: 'مدیریت پروژه';
return response()->json([
'success' => true,
'data' => [
'setting' => $setting?->fresh(),
'branding' => $this->brandingData($request, $companyName, null),
],
'message' => 'لوگوی شرکت حذف شد',
]);
}
private function brandingData(Request $request, mixed $companyName, mixed $logoPath): array
{
$path = is_string($logoPath) ? $logoPath : null;
$logoUrl = $path && Storage::disk('public')->exists($path)
? $request->getSchemeAndHttpHost().Storage::disk('public')->url($path)
: null;
return [
'company_name' => is_string($companyName) ? $companyName : 'مدیریت پروژه',
'logo_path' => $path,
'logo_url' => $logoUrl,
];
}
} }

مشاهده پرونده

@ -79,6 +79,11 @@ class Meeting extends Model
return $this->hasMany(Blocker::class); return $this->hasMany(Blocker::class);
} }
public function effectivenessReviews(): HasMany
{
return $this->hasMany(MeetingEffectivenessReview::class);
}
public function comments(): MorphMany public function comments(): MorphMany
{ {
return $this->morphMany(Comment::class, 'commentable'); return $this->morphMany(Comment::class, 'commentable');

مشاهده پرونده

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MeetingEffectivenessReview extends Model
{
protected $fillable = [
'meeting_id', 'user_id', 'objective_clarity', 'time_management',
'participation', 'outcome_quality', 'overall_rating', 'comment',
];
protected $casts = [
'objective_clarity' => 'integer',
'time_management' => 'integer',
'participation' => 'integer',
'outcome_quality' => 'integer',
'overall_rating' => 'integer',
];
public function meeting(): BelongsTo
{
return $this->belongsTo(Meeting::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -112,4 +112,9 @@ class User extends Authenticatable
{ {
return $this->hasMany(ActivityLog::class); return $this->hasMany(ActivityLog::class);
} }
public function meetingEffectivenessReviews(): HasMany
{
return $this->hasMany(MeetingEffectivenessReview::class);
}
} }

مشاهده پرونده

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('meeting_effectiveness_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('meeting_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedTinyInteger('objective_clarity');
$table->unsignedTinyInteger('time_management');
$table->unsignedTinyInteger('participation');
$table->unsignedTinyInteger('outcome_quality');
$table->unsignedTinyInteger('overall_rating');
$table->text('comment')->nullable();
$table->timestamps();
$table->unique(['meeting_id', 'user_id']);
$table->index(['meeting_id', 'overall_rating']);
});
}
public function down(): void
{
Schema::dropIfExists('meeting_effectiveness_reviews');
}
};

13
backend/php.ini normal فایل
مشاهده پرونده

@ -0,0 +1,13 @@
extension_dir = "${PHP_EXT_DIR}"
extension=curl
extension=fileinfo
extension=gd
extension=mbstring
extension=mysqli
extension=openssl
extension=pdo_mysql
extension=pdo_sqlite
extension=zip
date.timezone = "Asia/Tehran"

مشاهده پرونده

@ -137,6 +137,8 @@ Route::middleware(['auth:sanctum', 'resource.access'])->group(function () {
Route::delete('/meetings/{meeting}', [MeetingController::class, 'destroy'])->middleware('permission:meetings.delete'); Route::delete('/meetings/{meeting}', [MeetingController::class, 'destroy'])->middleware('permission:meetings.delete');
Route::post('/meetings/{meeting}/start', [MeetingController::class, 'start'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/start', [MeetingController::class, 'start'])->middleware('permission:meetings.edit');
Route::post('/meetings/{meeting}/complete', [MeetingController::class, 'complete'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/complete', [MeetingController::class, 'complete'])->middleware('permission:meetings.edit');
Route::get('/meetings/{meeting}/effectiveness', [MeetingController::class, 'effectiveness'])->middleware('permission:meetings.view');
Route::post('/meetings/{meeting}/effectiveness', [MeetingController::class, 'storeEffectiveness'])->middleware('permission:meetings.view');
Route::post('/meetings/{meeting}/decisions', [MeetingController::class, 'addDecision'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/decisions', [MeetingController::class, 'addDecision'])->middleware('permission:meetings.edit');
Route::post('/meetings/{meeting}/structured-action-items', [MeetingController::class, 'addStructuredActionItem'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/structured-action-items', [MeetingController::class, 'addStructuredActionItem'])->middleware('permission:meetings.edit');
Route::post('/meetings/{meeting}/blockers', [MeetingController::class, 'addBlocker'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/blockers', [MeetingController::class, 'addBlocker'])->middleware('permission:meetings.edit');
@ -200,8 +202,11 @@ Route::middleware(['auth:sanctum', 'resource.access'])->group(function () {
Route::apiResource('permissions', PermissionController::class)->only(['index', 'show'])->middleware('permission:roles.view'); Route::apiResource('permissions', PermissionController::class)->only(['index', 'show'])->middleware('permission:roles.view');
Route::get('/settings', [SettingController::class, 'index'])->middleware('permission:settings.view'); Route::get('/settings', [SettingController::class, 'index'])->middleware('permission:settings.view');
Route::get('/branding', [SettingController::class, 'branding']);
Route::post('/settings', [SettingController::class, 'store'])->middleware(['permission:settings.edit', 'throttle:sensitive']); Route::post('/settings', [SettingController::class, 'store'])->middleware(['permission:settings.edit', 'throttle:sensitive']);
Route::put('/settings/{setting}', [SettingController::class, 'update'])->middleware(['permission:settings.edit', 'throttle:sensitive']); Route::put('/settings/{setting}', [SettingController::class, 'update'])->middleware(['permission:settings.edit', 'throttle:sensitive']);
Route::post('/settings/company-logo', [SettingController::class, 'uploadCompanyLogo'])->middleware(['permission:settings.edit', 'throttle:sensitive']);
Route::delete('/settings/company-logo', [SettingController::class, 'removeCompanyLogo'])->middleware(['permission:settings.edit', 'throttle:sensitive']);
Route::get('/search', [SearchController::class, 'search']); Route::get('/search', [SearchController::class, 'search']);
}); });

مشاهده پرونده

@ -44,7 +44,11 @@ class BacklogLifecycleTest extends TestCase
'type' => 'feature', 'type' => 'feature',
'project_id' => $project->id, 'project_id' => $project->id,
'priority' => 'medium', 'priority' => 'medium',
])->assertCreated()->assertJsonPath('data.description', 'توضیحات اولیه'); ])->assertCreated()
->assertJsonPath('data.description', 'توضیحات اولیه')
->assertJsonPath('data.status', 'active')
->assertJsonPath('data.is_archived', false)
->assertJsonPath('data.archived_at', null);
$backlogId = $response->json('data.id'); $backlogId = $response->json('data.id');
@ -60,6 +64,8 @@ class BacklogLifecycleTest extends TestCase
'id' => $backlogId, 'id' => $backlogId,
'description' => 'توضیحات ویرایش‌شده', 'description' => 'توضیحات ویرایش‌شده',
'status' => 'active', 'status' => 'active',
'archived_at' => null,
'converted_to_task_id' => null,
]); ]);
} }

مشاهده پرونده

@ -5,6 +5,8 @@ namespace Tests\Feature;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase; use Tests\TestCase;
class SettingTest extends TestCase class SettingTest extends TestCase
@ -78,4 +80,35 @@ class SettingTest extends TestCase
$this->assertSame('smtp.example.com', $plain->fresh()->value['host']); $this->assertSame('smtp.example.com', $plain->fresh()->value['host']);
} }
public function test_company_logo_can_be_uploaded_exposed_as_branding_and_removed(): void
{
Storage::fake('public');
$user = User::factory()->create(['status' => 'active']);
$this->grantAdminRole($user);
$token = $user->createToken('api-token')->plainTextToken;
$response = $this->withToken($token)
->postJson('/api/settings/company-logo', [
'logo' => UploadedFile::fake()->image('company-logo.png', 240, 240),
])
->assertCreated()
->assertJsonPath('data.setting.key', 'company_logo');
$path = $response->json('data.setting.value');
Storage::disk('public')->assertExists($path);
$brandingResponse = $this->withToken($token)
->getJson('/api/branding')
->assertOk()
->assertJsonPath('data.logo_path', $path);
$this->assertStringEndsWith('/storage/'.$path, $brandingResponse->json('data.logo_url'));
$this->withToken($token)
->deleteJson('/api/settings/company-logo')
->assertOk()
->assertJsonPath('data.branding.logo_url', null);
Storage::disk('public')->assertMissing($path);
}
} }

مشاهده پرونده

@ -6,6 +6,7 @@ use App\Models\Meeting;
use App\Models\MeetingType; use App\Models\MeetingType;
use App\Models\Notification; use App\Models\Notification;
use App\Models\Project; use App\Models\Project;
use App\Models\Setting;
use App\Models\Sprint; use App\Models\Sprint;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
@ -165,5 +166,35 @@ class WorkspaceFeaturesTest extends TestCase
->assertJsonCount(1, 'data.structured_decisions') ->assertJsonCount(1, 'data.structured_decisions')
->assertJsonCount(1, 'data.structured_action_items') ->assertJsonCount(1, 'data.structured_action_items')
->assertJsonCount(1, 'data.blockers'); ->assertJsonCount(1, 'data.blockers');
Setting::updateOrCreate(
['key' => 'meeting_effectiveness_enabled'],
['value' => true, 'group' => 'meeting', 'type' => 'boolean'],
);
$review = [
'objective_clarity' => 5,
'time_management' => 4,
'participation' => 4,
'outcome_quality' => 5,
'overall_rating' => 5,
'comment' => 'جلسه مفید و نتیجه‌محور بود.',
];
$this->withToken($token)
->postJson("/api/meetings/{$meeting->id}/effectiveness", $review)
->assertCreated()
->assertJsonPath('data.overall_rating', 5);
$this->withToken($token)
->postJson("/api/meetings/{$meeting->id}/effectiveness", [...$review, 'overall_rating' => 4])
->assertOk()
->assertJsonPath('data.overall_rating', 4);
$this->withToken($token)
->getJson("/api/meetings/{$meeting->id}/effectiveness")
->assertOk()
->assertJsonPath('data.summary.count', 1)
->assertJsonPath('data.summary.averages.overall_rating', 4)
->assertJsonPath('data.can_review', true);
} }
} }

مشاهده پرونده

@ -37,29 +37,43 @@ test('dashboard command surfaces and settings render without runtime errors', as
upcoming_meetings: [], upcoming_meetings: [],
trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }], trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }],
} } }); } } });
if (path === '/api/reports/project-status') return route.fulfill({ json: { success: true, data: [
{ status: 'in_progress', label: 'in_progress', count: 3, value: 3 },
{ status: 'cancelled', label: 'cancelled', count: 1, value: 1 },
] } });
if (path === '/api/reports/team-performance') return route.fulfill({ json: { success: true, data: [
{ name: 'عضو نمونه تیم', completed: 8, in_progress: 3, delayed: 1 },
] } });
if (path === '/api/calendar/events') return route.fulfill({ json: { success: true, data: [] } }); if (path === '/api/calendar/events') return route.fulfill({ json: { success: true, data: [] } });
if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0, archived_count: 0 } } }); if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0, archived_count: 0 } } });
if (path === '/api/settings') return route.fulfill({ json: { success: true, data: [ if (path === '/api/settings') return route.fulfill({ json: { success: true, data: [
{ id: 1, key: 'working_days', value: ['saturday', 'sunday'], group: 'calendar', type: 'list' }, { id: 1, key: 'working_days', value: ['saturday', 'sunday'], group: 'calendar', type: 'list' },
{ id: 2, key: 'default_meeting_reminder_minutes', value: 30, group: 'meeting', type: 'number' }, { id: 2, key: 'default_meeting_reminder_minutes', value: 30, group: 'meeting', type: 'number' },
] } }); ] } });
if (path === '/api/branding') return route.fulfill({ json: { success: true, data: { company_name: 'شرکت آزمون', logo_path: null, logo_url: null } } });
return route.fulfill({ json: { success: true, data: [] } }); return route.fulfill({ json: { success: true, data: [] } });
}); });
await page.goto('/'); await page.goto('/');
await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible(); await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible();
await expect(page.getByRole('cell', { name: '۱۴۰۵ خرداد', exact: true })).toBeVisible();
await expect(page.getByText('2026-06')).toHaveCount(0);
await expect(page.locator('img.sidebar-brand-mark')).toBeVisible(); await expect(page.locator('img.sidebar-brand-mark')).toBeVisible();
await page.getByRole('button', { name: 'جمع کردن منوی اصلی' }).click();
await expect(page.locator('.main-content')).toHaveClass(/sidebar-collapsed/);
await page.getByRole('button', { name: 'باز کردن منوی اصلی' }).click();
await expect(page.locator('.main-content')).not.toHaveClass(/sidebar-collapsed/);
await page.getByRole('button', { name: 'تقویم رویدادها' }).click(); await page.getByRole('button', { name: 'تقویم رویدادها' }).click();
await expect(page.getByRole('region', { name: 'تقویم رویدادها' })).toBeVisible(); await expect(page.getByRole('region', { name: 'تقویم رویدادها' })).toBeVisible();
await expect(page.getByText('برنامه روز')).toBeVisible(); await expect(page.getByText('برنامه روز')).toBeVisible();
await page.mouse.click(900, 760); await page.keyboard.press('Escape');
await expect(page.locator('.calendar-popover')).toBeHidden(); await expect(page.locator('.calendar-popover')).toBeHidden();
await page.getByRole('button', { name: 'اعلان‌ها' }).click(); await page.getByRole('button', { name: 'اعلان‌ها' }).click();
await expect(page.getByLabel('مرکز اعلان‌ها')).toBeVisible(); await expect(page.getByLabel('مرکز اعلان‌ها')).toBeVisible();
await expect(page.getByText(/برای آرشیو به راست/)).toBeVisible(); await expect(page.getByText(/برای آرشیو به راست/)).toBeVisible();
await page.mouse.click(900, 760); await page.keyboard.press('Escape');
await expect(page.locator('.notification-center')).toBeHidden(); await expect(page.locator('.notification-center')).toBeHidden();
await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click(); await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click();
@ -84,10 +98,32 @@ test('dashboard command surfaces and settings render without runtime errors', as
await page.goto('/settings'); await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible();
await expect(page.getByText('شخصی‌سازی')).toBeVisible(); await expect(page.getByRole('heading', { name: 'شخصی‌سازی' })).toBeVisible();
await expect(page.getByRole('button', { name: 'تنظیم جدید' })).toBeVisible();
await expect(page.getByRole('button', { name: 'ساده' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'پیشرفته' })).toHaveCount(0);
await page.getByRole('button', { name: /تقویم و زمان کاری/ }).click();
await expect(page.getByText('شنبه', { exact: true })).toBeVisible();
await page.getByText('جزئیات فنی', { exact: true }).first().click();
await expect(page.locator('.settings-technical-details').first().locator('input[readonly]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true });
await page.goto('/reports');
await expect(page.getByRole('heading', { name: 'جزئیات' })).toBeVisible();
await expect(page.getByText('در حال انجام', { exact: true }).last()).toBeVisible();
await expect(page.getByText('لغوشده', { exact: true }).last()).toBeVisible();
await expect(page.getByText('in_progress', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'عملکرد تیم' }).click();
const teamNameTick = page.locator('.team-performance-chart text').filter({ hasText: 'عضو نمونه تیم' });
await expect(teamNameTick).toBeVisible();
const tickAndGrid = await page.locator('.team-performance-chart').evaluate((chart) => {
const tick = [...chart.querySelectorAll('text')].find((node) => node.textContent.includes('عضو نمونه تیم'));
const gridLine = chart.querySelector('.recharts-cartesian-grid line');
return { tickRight: tick.getBoundingClientRect().right, gridLeft: gridLine.getBoundingClientRect().left };
});
expect(tickAndGrid.tickRight).toBeLessThanOrEqual(tickAndGrid.gridLeft + 1);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop')); await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop'));
await page.goto('/'); await page.goto('/');
@ -107,6 +143,194 @@ test('dashboard command surfaces and settings render without runtime errors', as
expect(runtimeErrors).toEqual([]); expect(runtimeErrors).toEqual([]);
}); });
test('dark projects and task details keep readable centered surfaces', async ({ page }, testInfo) => {
const user = { id: 1, name: 'مدیر رابط', email: 'manager@example.test' };
const project = { id: 10, title: 'پروژه خوانا در تاریکی', description: 'شرح نمونه پروژه', status: 'in_progress', priority: 'high', progress: 45, project_manager: user, end_date: '2026-09-20' };
const task = { id: 20, title: 'تسک مودال مرکزی', description: 'شرح تسک برای بررسی مودال', project_id: project.id, project, assignee: user, assignee_id: user.id, priority: 'medium', status: 'todo', start_date: '2026-09-01', due_date: '2026-09-10', estimated_time: 4 };
await page.addInitScript(({ testUser }) => {
localStorage.setItem('token', 'visual-details-token');
localStorage.setItem('user', JSON.stringify(testUser));
localStorage.setItem('theme', 'dark');
}, { testUser: user });
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/api/user') return route.fulfill({ json: { success: true, data: user } });
if (url.pathname === '/api/projects' && route.request().method() === 'GET') return route.fulfill({ json: { success: true, data: [project], meta: { current_page: 1, last_page: 1 } } });
if (url.pathname === '/api/tasks' && route.request().method() === 'GET') return route.fulfill({ json: { success: true, data: [task], meta: { current_page: 1, last_page: 1 } } });
if (url.pathname === '/api/users') return route.fulfill({ json: { success: true, data: [user] } });
if (url.pathname === '/api/departments') return route.fulfill({ json: { success: true, data: [], flat: [] } });
if (url.pathname === `/api/tasks/${task.id}/checklists`) return route.fulfill({ json: { success: true, data: [] } });
if (url.pathname === '/api/comments') return route.fulfill({ json: { success: true, data: [] } });
if (url.pathname === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } });
return route.fulfill({ json: { success: true, data: [] } });
});
await page.goto('/projects');
const projectTitle = page.getByRole('link', { name: project.title });
await expect(projectTitle).toBeVisible();
expect(await projectTitle.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 255, 255)');
await page.goto('/tasks');
await page.getByRole('cell', { name: task.title, exact: true }).click();
const dialog = page.getByRole('dialog', { name: task.title });
await expect(dialog).toBeVisible();
const metrics = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const styles = getComputedStyle(element);
return {
centerOffsetX: Math.abs((rect.left + rect.width / 2) - window.innerWidth / 2),
centerOffsetY: Math.abs((rect.top + rect.height / 2) - window.innerHeight / 2),
radii: [styles.borderTopLeftRadius, styles.borderTopRightRadius, styles.borderBottomRightRadius, styles.borderBottomLeftRadius],
};
});
expect(metrics.centerOffsetX).toBeLessThan(3);
expect(metrics.centerOffsetY).toBeLessThan(3);
expect(metrics.radii.every((radius) => parseFloat(radius) >= 18)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('dark-task-modal.png'), fullPage: true });
});
test('backlog conversion date pickers stay visible inside the modal flow', async ({ page }, testInfo) => {
const user = { id: 1, name: 'مدیر بک‌لاگ', email: 'backlog@example.test' };
const member = { id: 2, name: 'عضو پروژه', email: 'member@example.test', status: 'active' };
const project = { id: 10, title: 'پروژه تقویم', project_manager: user, members: [member] };
const backlog = {
id: 30,
title: 'بک‌لاگ قابل تبدیل',
description: 'بررسی نمایش تقویم در مودال',
type: 'feature',
priority: 'medium',
project_id: project.id,
project,
is_archived: false,
};
let conversionPayload;
await page.addInitScript(({ testUser }) => {
localStorage.setItem('token', 'backlog-date-token');
localStorage.setItem('user', JSON.stringify(testUser));
}, { testUser: user });
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
if (url.pathname === '/api/user') return route.fulfill({ json: { success: true, data: user } });
if (url.pathname === '/api/backlog-items' && method === 'GET') return route.fulfill({ json: { success: true, data: [backlog] } });
if (url.pathname === '/api/projects' && method === 'GET') return route.fulfill({ json: { success: true, data: [project] } });
if (url.pathname === '/api/users' && method === 'GET') return route.fulfill({ json: { success: true, data: [user, member] } });
if (url.pathname === `/api/projects/${project.id}` && method === 'GET') return route.fulfill({ json: { success: true, data: project } });
if (url.pathname === `/api/backlog-items/${backlog.id}/convert-to-task` && method === 'POST') {
conversionPayload = route.request().postDataJSON();
return route.fulfill({ json: { success: true, data: { id: 99 } } });
}
if (url.pathname === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } });
return route.fulfill({ json: { success: true, data: [] } });
});
const assertCalendarIsInViewport = async (calendar) => {
await expect(calendar).toBeVisible();
const metrics = await calendar.evaluate((element) => {
const rect = element.getBoundingClientRect();
const centerElement = document.elementFromPoint(rect.left + (rect.width / 2), rect.top + 12);
return {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
isTopLayerHit: Boolean(centerElement?.closest('.persian-date-popover')),
};
});
expect(metrics.top).toBeGreaterThanOrEqual(0);
expect(metrics.left).toBeGreaterThanOrEqual(0);
expect(metrics.right).toBeLessThanOrEqual(metrics.viewportWidth);
expect(metrics.bottom).toBeLessThanOrEqual(metrics.viewportHeight);
expect(metrics.isTopLayerHit).toBe(true);
};
await page.goto('/backlog');
await page.getByRole('button', { name: `تبدیل ${backlog.title} به تسک` }).click();
const conversionDialog = page.getByRole('dialog', { name: 'تبدیل بک‌لاگ به تسک' });
await expect(conversionDialog).toBeVisible();
await conversionDialog.locator('#task-assignee').selectOption(String(member.id));
const startDateTrigger = conversionDialog.locator('.form-group').filter({ hasText: 'تاریخ شروع' }).locator('.persian-date-input');
await startDateTrigger.click();
const startCalendar = page.getByRole('dialog', { name: 'انتخاب تاریخ شمسی' });
await assertCalendarIsInViewport(startCalendar);
await page.keyboard.press('Escape');
await expect(startCalendar).toBeHidden();
await expect(conversionDialog).toBeVisible();
await expect(startDateTrigger).toBeFocused();
await startDateTrigger.click();
await assertCalendarIsInViewport(startCalendar);
await startCalendar.locator('button[aria-current="date"]').click();
await expect(startDateTrigger).toHaveAttribute('aria-expanded', 'false');
await page.setViewportSize({ width: 390, height: 700 });
const dueDateTrigger = conversionDialog.locator('.form-group').filter({ hasText: 'تاریخ پایان' }).locator('.persian-date-input');
await dueDateTrigger.click();
const dueCalendar = page.getByRole('dialog', { name: 'انتخاب تاریخ شمسی' });
await assertCalendarIsInViewport(dueCalendar);
await page.screenshot({ path: testInfo.outputPath('mobile-backlog-date-picker.png'), fullPage: true });
await dueCalendar.locator('button[aria-current="date"]').click();
await conversionDialog.getByRole('button', { name: 'ایجاد و تخصیص تسک' }).click();
await expect(conversionDialog).toBeHidden();
expect(conversionPayload.assignee_id).toBe(String(member.id));
expect(conversionPayload.start_date).toBeTruthy();
expect(conversionPayload.due_date).toBe(conversionPayload.start_date);
});
test('kanban task cards show assignee photos and keep initials as fallback', async ({ page }) => {
const user = { id: 1, name: 'مدیر کانبان', email: 'kanban@example.test' };
const avatarDataUrl = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="20" height="20"%3E%3Crect width="20" height="20" fill="%236366f1"/%3E%3C/svg%3E';
const tasks = [
{
id: 71,
title: 'تسک دارای عکس',
status: 'todo',
priority: 'medium',
assignee: { id: 2, name: 'علی رضایی', avatar: 'avatars/ali.webp', avatar_url: avatarDataUrl },
project: { id: 10, title: 'پروژه کانبان' },
},
{
id: 72,
title: 'تسک بدون عکس',
status: 'in_progress',
priority: 'low',
assignee: { id: 3, name: 'بهار احمدی', avatar: null, avatar_url: null },
project: { id: 10, title: 'پروژه کانبان' },
},
];
await page.addInitScript(({ testUser }) => {
localStorage.setItem('token', 'kanban-avatar-token');
localStorage.setItem('user', JSON.stringify(testUser));
}, { testUser: user });
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/api/user') return route.fulfill({ json: { success: true, data: user } });
if (path === '/api/tasks') return route.fulfill({ json: { success: true, data: tasks } });
if (path === '/api/projects') return route.fulfill({ json: { success: true, data: [{ id: 10, title: 'پروژه کانبان' }] } });
if (path === '/api/users') return route.fulfill({ json: { success: true, data: tasks.map((task) => task.assignee) } });
if (path === '/api/branding') return route.fulfill({ json: { success: true, data: { company_name: 'مدیریت پروژه', logo_url: null } } });
if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0 } } });
return route.fulfill({ json: { success: true, data: [] } });
});
await page.goto('/kanban');
const photoCard = page.locator('.kanban-task-card[data-task-id="71"]');
const fallbackCard = page.locator('.kanban-task-card[data-task-id="72"]');
await expect(photoCard.getByRole('img', { name: 'عکس پروفایل علی رضایی' })).toBeVisible();
await expect(fallbackCard.locator('.kanban-assignee-avatar')).toHaveText('ب');
await expect(fallbackCard.locator('.kanban-assignee-avatar img')).toHaveCount(0);
});
test('project task can be transferred to an approved project member', async ({ page }) => { test('project task can be transferred to an approved project member', async ({ page }) => {
const assignPermission = { id: 1, name: 'tasks.assign', display_name: 'تخصیص وظایف' }; const assignPermission = { id: 1, name: 'tasks.assign', display_name: 'تخصیص وظایف' };
const user = { const user = {

مشاهده پرونده

@ -35,13 +35,16 @@ function PageFallback() {
function AppLayout({ children, title }) { function AppLayout({ children, title }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 768px)').matches);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
if (event.key === 'Escape') setMobileNavOpen(false); if (event.key === 'Escape') setMobileNavOpen(false);
}; };
const handleResize = () => { const handleResize = () => {
if (window.innerWidth > 768) setMobileNavOpen(false); const nextIsMobile = window.matchMedia('(max-width: 768px)').matches;
setIsMobile(nextIsMobile);
if (!nextIsMobile) setMobileNavOpen(false);
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@ -56,12 +59,21 @@ function AppLayout({ children, title }) {
setMobileNavOpen((current) => !current); setMobileNavOpen((current) => !current);
}; };
const togglePrimaryNav = () => {
if (isMobile) {
toggleMobileNav();
return;
}
setMobileNavOpen(false);
setCollapsed((current) => !current);
};
return ( return (
<div className="layout"> <div className="layout">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} /> <Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
{mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />} {mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />}
<div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}> <div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}>
<Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} onToggleMobileNav={toggleMobileNav} /> <Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} isMobile={isMobile} onToggleNavigation={togglePrimaryNav} />
<div className="main-inner">{children}</div> <div className="main-inner">{children}</div>
</div> </div>
</div> </div>

مشاهده پرونده

@ -11,7 +11,7 @@ import useDismissibleLayer from '../hooks/useDismissibleLayer';
import Modal from './Modal'; import Modal from './Modal';
import Profile from '../pages/Profile'; import Profile from '../pages/Profile';
export default function Header({ title, collapsed = false, mobileNavOpen = false, onToggleMobileNav }) { export default function Header({ title, collapsed = false, mobileNavOpen = false, isMobile = false, onToggleNavigation }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeLayer, setActiveLayer] = useState(null); const [activeLayer, setActiveLayer] = useState(null);
@ -20,6 +20,7 @@ export default function Header({ title, collapsed = false, mobileNavOpen = false
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const navigationExpanded = isMobile ? mobileNavOpen : !collapsed;
const calendarButtonRef = useRef(null); const calendarButtonRef = useRef(null);
const calendarPanelRef = useRef(null); const calendarPanelRef = useRef(null);
const notificationButtonRef = useRef(null); const notificationButtonRef = useRef(null);
@ -97,11 +98,11 @@ export default function Header({ title, collapsed = false, mobileNavOpen = false
<div className="header-actions"> <div className="header-actions">
<button <button
type="button" type="button"
className={`header-icon-button mobile-nav-toggle ${mobileNavOpen ? 'active' : ''}`} className={`header-icon-button primary-nav-toggle ${navigationExpanded ? 'active' : ''}`}
onClick={onToggleMobileNav} onClick={onToggleNavigation}
aria-label={mobileNavOpen ? 'بستن منوی اصلی' : 'باز کردن منوی اصلی'} aria-label={navigationExpanded ? 'جمع کردن منوی اصلی' : 'باز کردن منوی اصلی'}
aria-controls="main-navigation-drawer" aria-controls="main-navigation-drawer"
aria-expanded={mobileNavOpen} aria-expanded={navigationExpanded}
> >
<Menu size={20} /> <Menu size={20} />
</button> </button>

مشاهده پرونده

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { import {
getJalaliWeekday, getJalaliWeekday,
@ -31,6 +31,9 @@ export default function PersianDateInput({ value, onChange, className = '', plac
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [view, setView] = useState(selected || today); const [view, setView] = useState(selected || today);
const wrapRef = useRef(null); const wrapRef = useRef(null);
const triggerRef = useRef(null);
const popoverRef = useRef(null);
const popoverId = useId();
useEffect(() => { useEffect(() => {
if (selected) setView(selected); if (selected) setView(selected);
@ -44,6 +47,67 @@ export default function PersianDateInput({ value, onChange, className = '', plac
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
useEffect(() => {
if (!open) return undefined;
const handleEscape = (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('keydown', handleEscape, true);
return () => document.removeEventListener('keydown', handleEscape, true);
}, [open]);
useLayoutEffect(() => {
if (!open) return undefined;
const trigger = triggerRef.current;
const popover = popoverRef.current;
if (!trigger || !popover) return undefined;
const supportsPopover = typeof popover.showPopover === 'function';
const containingModal = popover.closest('.modal');
if (!supportsPopover) containingModal?.classList.add('modal-has-floating-date');
if (supportsPopover && !popover.matches(':popover-open')) popover.showPopover();
const positionPopover = () => {
const triggerRect = trigger.getBoundingClientRect();
const popoverRect = popover.getBoundingClientRect();
const viewportGap = 8;
const controlGap = 6;
const width = popoverRect.width || Math.min(292, window.innerWidth - (viewportGap * 2));
const height = popoverRect.height;
const roomBelow = window.innerHeight - triggerRect.bottom - viewportGap;
const roomAbove = triggerRect.top - viewportGap;
const openAbove = height > roomBelow && roomAbove > roomBelow;
const preferredTop = openAbove
? triggerRect.top - height - controlGap
: triggerRect.bottom + controlGap;
const preferredLeft = document.documentElement.dir === 'rtl'
? triggerRect.right - width
: triggerRect.left;
popover.style.top = `${Math.max(viewportGap, Math.min(preferredTop, window.innerHeight - height - viewportGap))}px`;
popover.style.left = `${Math.max(viewportGap, Math.min(preferredLeft, window.innerWidth - width - viewportGap))}px`;
popover.dataset.placement = openAbove ? 'top' : 'bottom';
};
positionPopover();
window.addEventListener('resize', positionPopover);
window.addEventListener('scroll', positionPopover, true);
return () => {
window.removeEventListener('resize', positionPopover);
window.removeEventListener('scroll', positionPopover, true);
containingModal?.classList.remove('modal-has-floating-date');
if (supportsPopover && popover.matches(':popover-open')) popover.hidePopover();
};
}, [open, view]);
const days = useMemo(() => { const days = useMemo(() => {
const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1); const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1);
const length = jalaliMonthLength(view.jy, view.jm); const length = jalaliMonthLength(view.jy, view.jm);
@ -79,13 +143,29 @@ export default function PersianDateInput({ value, onChange, className = '', plac
return ( return (
<div className="persian-date-picker" ref={wrapRef}> <div className="persian-date-picker" ref={wrapRef}>
<button type="button" className={`form-input persian-date-input${className ? ` ${className}` : ''}`} onClick={() => setOpen(!open)} {...props}> <button
ref={triggerRef}
type="button"
className={`form-input persian-date-input${className ? ` ${className}` : ''}`}
onClick={() => setOpen(!open)}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popoverId}
{...props}
>
<CalendarDays size={16} /> <CalendarDays size={16} />
<span>{displayValue || placeholder}</span> <span>{displayValue || placeholder}</span>
{value && <X size={14} className="persian-date-clear" onClick={clearDate} />} {value && <X size={14} className="persian-date-clear" onClick={clearDate} />}
</button> </button>
{open && ( {open && (
<div className="persian-date-popover"> <div
ref={popoverRef}
id={popoverId}
className="persian-date-popover"
popover="manual"
role="dialog"
aria-label="انتخاب تاریخ شمسی"
>
<div className="persian-date-head"> <div className="persian-date-head">
<button type="button" onClick={() => moveMonth(-1)}><ChevronRight size={16} /></button> <button type="button" onClick={() => moveMonth(-1)}><ChevronRight size={16} /></button>
<strong>{monthNames[view.jm - 1]} {view.jy}</strong> <strong>{monthNames[view.jm - 1]} {view.jy}</strong>
@ -99,7 +179,14 @@ export default function PersianDateInput({ value, onChange, className = '', plac
const isSelected = selected && day === selected.jd && view.jm === selected.jm && view.jy === selected.jy; const isSelected = selected && day === selected.jd && view.jm === selected.jm && view.jy === selected.jy;
const isToday = today && day === today.jd && view.jm === today.jm && view.jy === today.jy; const isToday = today && day === today.jd && view.jm === today.jm && view.jy === today.jy;
return day ? ( return day ? (
<button key={`${view.jy}-${view.jm}-${day}`} type="button" className={`${isSelected ? 'selected' : ''}${isToday ? ' today' : ''}`} onClick={() => pickDay(day)}> <button
key={`${view.jy}-${view.jm}-${day}`}
type="button"
className={`${isSelected ? 'selected' : ''}${isToday ? ' today' : ''}`}
aria-pressed={Boolean(isSelected)}
aria-current={isToday ? 'date' : undefined}
onClick={() => pickDay(day)}
>
{day} {day}
</button> </button>
) : <span key={`empty-${index}`} />; ) : <span key={`empty-${index}`} />;

مشاهده پرونده

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import api from '../services/api';
import { LayoutDashboard, FolderKanban, CheckSquare, Kanban, Inbox, Timer, Users, Calendar, Paperclip, TrendingUp, Building2, Shield, Settings, PanelRightClose, PanelRightOpen, X } from 'lucide-react'; import { LayoutDashboard, FolderKanban, CheckSquare, Kanban, Inbox, Timer, Users, Calendar, Paperclip, TrendingUp, Building2, Shield, Settings, PanelRightClose, PanelRightOpen, X } from 'lucide-react';
const iconSize = 18; const iconSize = 18;
@ -22,6 +24,16 @@ const menuItems = [
export default function Sidebar({ collapsed, onToggle, mobileOpen = false, onMobileClose }) { export default function Sidebar({ collapsed, onToggle, mobileOpen = false, onMobileClose }) {
const { user } = useAuth(); const { user } = useAuth();
const fallbackLogo = '/brand/project-mark.png';
const [branding, setBranding] = useState({ company_name: 'مدیریت پروژه', logo_url: null });
useEffect(() => {
api.get('/branding').then(({ data }) => setBranding(data.data || {})).catch(() => {});
const handleBrandingUpdate = (event) => setBranding(event.detail || {});
window.addEventListener('branding:updated', handleBrandingUpdate);
return () => window.removeEventListener('branding:updated', handleBrandingUpdate);
}, []);
return ( return (
<aside id="main-navigation-drawer" className={`app-sidebar ${mobileOpen ? 'mobile-open' : ''}`} aria-label="منوی اصلی" style={{ <aside id="main-navigation-drawer" className={`app-sidebar ${mobileOpen ? 'mobile-open' : ''}`} aria-label="منوی اصلی" style={{
width: collapsed ? '72px' : 'var(--sidebar-width)', width: collapsed ? '72px' : 'var(--sidebar-width)',
@ -39,8 +51,8 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen = false, onMob
}}> }}>
<div style={{ padding: collapsed ? '0.875rem 0.75rem' : '1rem 1rem 1rem 1.25rem', display: 'flex', alignItems: 'center', gap: '0.75rem', borderBottom: '1px solid var(--gray-100)', justifyContent: collapsed ? 'center' : 'space-between' }}> <div style={{ padding: collapsed ? '0.875rem 0.75rem' : '1rem 1rem 1rem 1.25rem', display: 'flex', alignItems: 'center', gap: '0.75rem', borderBottom: '1px solid var(--gray-100)', justifyContent: collapsed ? 'center' : 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0 }}>
<img className="sidebar-brand-mark" src="/brand/project-mark.png" alt="" /> <img className="sidebar-brand-mark" src={branding.logo_url || fallbackLogo} alt={`لوگوی ${branding.company_name || 'شرکت'}`} onError={(event) => { event.currentTarget.src = fallbackLogo; }} />
{!collapsed && <span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--gray-900)', whiteSpace: 'nowrap' }}>مدیریت پروژه</span>} {!collapsed && <span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--gray-900)', whiteSpace: 'nowrap' }}>{branding.company_name || 'مدیریت پروژه'}</span>}
</div> </div>
<button <button
type="button" type="button"

مشاهده پرونده

@ -6,7 +6,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import api from '../services/api'; import api from '../services/api';
import { formatJalaliDate, formatJalaliDateTime } from '../utils/date'; import { formatJalaliDate, formatJalaliDateTime, formatJalaliMonth } from '../utils/date';
const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' }; const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' };
const healthClass = { on_track: 'success', at_risk: 'warning', off_track: 'danger' }; const healthClass = { on_track: 'success', at_risk: 'warning', off_track: 'danger' };
@ -64,6 +64,11 @@ export default function Dashboard() {
return (data?.projects || []).filter((project) => String(project.id) === projectFilter); return (data?.projects || []).filter((project) => String(project.id) === projectFilter);
}, [data, projectFilter]); }, [data, projectFilter]);
const localizedTrends = useMemo(() => (data?.trends || []).map((item) => ({
...item,
month_label: formatJalaliMonth(item.month),
})), [data?.trends]);
if (loading) { if (loading) {
return <div className="page-container monitor-page"><div className="monitor-loading">{Array.from({ length: 10 }).map((_, index) => <div key={index} className="skeleton" />)}</div></div>; return <div className="page-container monitor-page"><div className="monitor-loading">{Array.from({ length: 10 }).map((_, index) => <div key={index} className="skeleton" />)}</div></div>;
} }
@ -108,17 +113,17 @@ export default function Dashboard() {
<div className="monitor-primary-grid"> <div className="monitor-primary-grid">
<Panel title="روند جریان کار" subtitle="تسک‌های ایجادشده در برابر تکمیل‌شده در شش ماه اخیر" className="monitor-trend"> <Panel title="روند جریان کار" subtitle="تسک‌های ایجادشده در برابر تکمیل‌شده در شش ماه اخیر" className="monitor-trend">
{(data?.trends || []).length === 0 ? <div className="monitor-empty">داده کافی برای نمایش روند وجود ندارد.</div> : ( {localizedTrends.length === 0 ? <div className="monitor-empty">داده کافی برای نمایش روند وجود ندارد.</div> : (
<> <>
<div className="monitor-chart" role="img" aria-label="نمودار روند ایجاد و تکمیل تسک‌ها"> <div className="monitor-chart" role="img" aria-label="نمودار روند ایجاد و تکمیل تسک‌ها">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.trends}> <AreaChart data={localizedTrends}>
<defs> <defs>
<linearGradient id="createdGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.28} /><stop offset="95%" stopColor="var(--info)" stopOpacity={0} /></linearGradient> <linearGradient id="createdGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.28} /><stop offset="95%" stopColor="var(--info)" stopOpacity={0} /></linearGradient>
<linearGradient id="completedGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--success)" stopOpacity={0.22} /><stop offset="95%" stopColor="var(--success)" stopOpacity={0} /></linearGradient> <linearGradient id="completedGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--success)" stopOpacity={0.22} /><stop offset="95%" stopColor="var(--success)" stopOpacity={0} /></linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--gray-200)" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="var(--gray-200)" vertical={false} />
<XAxis dataKey="month" tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} /> <XAxis dataKey="month_label" tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} /> <YAxis tick={{ fill: 'var(--gray-500)', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip contentStyle={{ background: 'var(--surface-elevated)', border: '1px solid var(--gray-200)', borderRadius: 10 }} /> <Tooltip contentStyle={{ background: 'var(--surface-elevated)', border: '1px solid var(--gray-200)', borderRadius: 10 }} />
<Area type="monotone" dataKey="created" name="ایجادشده" stroke="var(--info)" strokeWidth={2} fill="url(#createdGradient)" /> <Area type="monotone" dataKey="created" name="ایجادشده" stroke="var(--info)" strokeWidth={2} fill="url(#createdGradient)" />
@ -129,7 +134,7 @@ export default function Dashboard() {
<table className="sr-only-table"> <table className="sr-only-table">
<caption>دادههای روند جریان کار</caption> <caption>دادههای روند جریان کار</caption>
<thead><tr><th>ماه</th><th>ایجادشده</th><th>تکمیلشده</th></tr></thead> <thead><tr><th>ماه</th><th>ایجادشده</th><th>تکمیلشده</th></tr></thead>
<tbody>{data.trends.map((row) => <tr key={row.month}><td>{row.month}</td><td>{row.created}</td><td>{row.completed}</td></tr>)}</tbody> <tbody>{localizedTrends.map((row) => <tr key={row.month}><td>{row.month_label}</td><td>{row.created}</td><td>{row.completed}</td></tr>)}</tbody>
</table> </table>
</> </>
)} )}

مشاهده پرونده

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import api from '../services/api'; import api from '../services/api';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import PriorityBadge from '../components/PriorityBadge'; import PriorityBadge from '../components/PriorityBadge';
import EmptyState from '../components/EmptyState';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import FormSelect from '../components/FormSelect'; import FormSelect from '../components/FormSelect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -26,6 +25,29 @@ const blockerOptions = [
{ value: 'other', label: 'سایر' }, { value: 'other', label: 'سایر' },
]; ];
function AssigneeAvatar({ assignee }) {
const [failedUrl, setFailedUrl] = useState('');
const imageUrl = assignee?.avatar_url || (assignee?.avatar?.startsWith?.('http') ? assignee.avatar : '');
const showImage = imageUrl && failedUrl !== imageUrl;
const name = assignee?.name || 'بدون مسئول';
return (
<span className="avatar avatar-sm kanban-assignee-avatar" style={{ width: 20, height: 20, fontSize: '0.625rem', flexShrink: 0 }} aria-label={name}>
{showImage ? (
<img
src={imageUrl}
alt={`عکس پروفایل ${name}`}
width="20"
height="20"
loading="lazy"
decoding="async"
onError={() => setFailedUrl(imageUrl)}
/>
) : (assignee?.name?.[0] || '?')}
</span>
);
}
export default function Kanban() { export default function Kanban() {
const [board, setBoard] = useState({}); const [board, setBoard] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -217,7 +239,7 @@ export default function Kanban() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{(board[col.key] || []).map(task => ( {(board[col.key] || []).map(task => (
<div key={task.id} draggable onDragStart={() => handleDragStart(task, col.key)} onClick={() => openTaskDetail(task)} <div key={task.id} className="kanban-task-card" data-task-id={task.id} draggable onDragStart={() => handleDragStart(task, col.key)} onClick={() => openTaskDetail(task)}
style={{ background: 'var(--surface)', borderRadius: 'var(--radius-sm)', padding: '0.625rem', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', cursor: 'grab', border: '1px solid var(--gray-100)', wordBreak: 'break-word' }} style={{ background: 'var(--surface)', borderRadius: 'var(--radius-sm)', padding: '0.625rem', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', cursor: 'grab', border: '1px solid var(--gray-100)', wordBreak: 'break-word' }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.25rem', marginBottom: '0.375rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.25rem', marginBottom: '0.375rem' }}>
@ -226,7 +248,7 @@ export default function Kanban() {
</div> </div>
<div style={{ display: 'grid', gap: '0.375rem' }}> <div style={{ display: 'grid', gap: '0.375rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', minWidth: 0 }}>
<div className="avatar avatar-sm" style={{ width: 20, height: 20, fontSize: '0.625rem', flexShrink: 0 }}>{task.assignee?.name?.[0] || '?'}</div> <AssigneeAvatar assignee={task.assignee} />
<span style={{ fontSize: '0.6875rem', color: 'var(--gray-500)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.assignee?.name || 'بدون مسئول'}</span> <span style={{ fontSize: '0.6875rem', color: 'var(--gray-500)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.assignee?.name || 'بدون مسئول'}</span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', color: 'var(--gray-500)', fontSize: '0.6875rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', color: 'var(--gray-500)', fontSize: '0.6875rem' }}>

مشاهده پرونده

@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ArrowRight, Ban, CheckCircle2, ClipboardList, FileText, Play, Plus, Save, ShieldAlert, Users } from 'lucide-react'; import { ArrowRight, Ban, CheckCircle2, ClipboardList, FileText, Play, Plus, Save, ShieldAlert, Star, Users } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import api from '../services/api'; import api from '../services/api';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import { formatJalaliDate } from '../utils/date'; import { formatJalaliDate } from '../utils/date';
const stages = [['before', 'قبل از جلسه'], ['during', 'حین جلسه'], ['after', 'بعد از جلسه']]; const stages = [['before', 'قبل از جلسه'], ['during', 'حین جلسه'], ['after', 'بعد از جلسه']];
const emptyEffectivenessForm = { objective_clarity: 0, time_management: 0, participation: 0, outcome_quality: 0, overall_rating: 0, comment: '' };
export default function MeetingWorkspace() { export default function MeetingWorkspace() {
const { id } = useParams(); const { id } = useParams();
@ -17,6 +18,9 @@ export default function MeetingWorkspace() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ objective: '', agenda: '', notes: '', summary: '' }); const [form, setForm] = useState({ objective: '', agenda: '', notes: '', summary: '' });
const [quickForm, setQuickForm] = useState(null); const [quickForm, setQuickForm] = useState(null);
const [effectiveness, setEffectiveness] = useState(null);
const [effectivenessForm, setEffectivenessForm] = useState(emptyEffectivenessForm);
const [effectivenessSaving, setEffectivenessSaving] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@ -35,6 +39,22 @@ export default function MeetingWorkspace() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const loadEffectiveness = useCallback(async () => {
if (meeting?.status !== 'completed') return;
try {
const { data } = await api.get(`/meetings/${id}/effectiveness`);
const next = data.data;
setEffectiveness(next);
setEffectivenessForm(next.current_user_review
? { ...emptyEffectivenessForm, ...next.current_user_review, comment: next.current_user_review.comment || '' }
: emptyEffectivenessForm);
} catch {
setEffectiveness(null);
}
}, [id, meeting?.status]);
useEffect(() => { loadEffectiveness(); }, [loadEffectiveness]);
const saveNotes = async () => { const saveNotes = async () => {
setSaving(true); setSaving(true);
try { try {
@ -68,6 +88,25 @@ export default function MeetingWorkspace() {
} }
}; };
const saveEffectiveness = async (event) => {
event.preventDefault();
const ratingFields = ['objective_clarity', 'time_management', 'participation', 'outcome_quality', 'overall_rating'];
if (ratingFields.some((field) => !effectivenessForm[field])) {
toast.error('برای همه معیارهای ارزیابی امتیاز انتخاب کنید');
return;
}
setEffectivenessSaving(true);
try {
await api.post(`/meetings/${id}/effectiveness`, effectivenessForm);
toast.success('ارزیابی اثربخشی جلسه ثبت شد');
await loadEffectiveness();
} catch (err) {
toast.error(err.response?.data?.message || 'ثبت ارزیابی ناموفق بود');
} finally {
setEffectivenessSaving(false);
}
};
if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 7 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>; if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 7 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>;
if (error || !meeting) return <div className="page-container workspace-page"><div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={load}>تلاش دوباره</button></div></div>; if (error || !meeting) return <div className="page-container workspace-page"><div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={load}>تلاش دوباره</button></div></div>;
@ -123,6 +162,15 @@ export default function MeetingWorkspace() {
<ResultSection title="تصمیم‌ها" items={meeting.structured_decisions} /> <ResultSection title="تصمیم‌ها" items={meeting.structured_decisions} />
<ResultSection title="اقدام‌ها" items={meeting.structured_action_items} /> <ResultSection title="اقدام‌ها" items={meeting.structured_action_items} />
<ResultSection title="موانع" items={meeting.blockers} /> <ResultSection title="موانع" items={meeting.blockers} />
{effectiveness?.enabled && (
<EffectivenessPanel
data={effectiveness}
form={effectivenessForm}
setForm={setEffectivenessForm}
saving={effectivenessSaving}
onSubmit={saveEffectiveness}
/>
)}
</div> </div>
)} )}
</main> </main>
@ -150,6 +198,61 @@ function ResultSection({ title, items = [] }) {
return <section className="workspace-card"><header><div><CheckCircle2 size={18} /><h2>{title}</h2></div><span>{items?.length || 0}</span></header>{!items?.length ? <div className="workspace-empty">موردی ثبت نشده است.</div> : items.map((item) => <div className="workspace-data-row" key={item.id}><div><strong>{item.title}</strong><small>{item.description || item.status}</small></div><span className="badge badge-primary">{item.status || item.severity}</span></div>)}</section>; return <section className="workspace-card"><header><div><CheckCircle2 size={18} /><h2>{title}</h2></div><span>{items?.length || 0}</span></header>{!items?.length ? <div className="workspace-empty">موردی ثبت نشده است.</div> : items.map((item) => <div className="workspace-data-row" key={item.id}><div><strong>{item.title}</strong><small>{item.description || item.status}</small></div><span className="badge badge-primary">{item.status || item.severity}</span></div>)}</section>;
} }
const effectivenessFields = [
['objective_clarity', 'وضوح هدف جلسه'],
['time_management', 'مدیریت زمان'],
['participation', 'مشارکت اعضا'],
['outcome_quality', 'کیفیت خروجی‌ها'],
['overall_rating', 'امتیاز کلی'],
];
function EffectivenessPanel({ data, form, setForm, saving, onSubmit }) {
const averages = data.summary?.averages || {};
return (
<section className="workspace-card meeting-effectiveness-card">
<header>
<div><Star size={19} /><h2>ارزیابی اثربخشی جلسه</h2></div>
<span>{data.summary?.count || 0} پاسخ</span>
</header>
{(data.summary?.count || 0) > 0 && (
<div className="effectiveness-summary" aria-label="میانگین ارزیابی‌های جلسه">
{effectivenessFields.map(([key, label]) => <div key={key}><span>{label}</span><strong>{averages[key]?.toLocaleString('fa-IR') || '—'} از ۵</strong></div>)}
</div>
)}
{data.can_review && (
<form className="effectiveness-form" onSubmit={onSubmit}>
<p>{data.current_user_review ? 'می‌توانید ارزیابی قبلی خود را به‌روزرسانی کنید.' : 'تجربه این جلسه را ثبت کنید تا کیفیت جلسات بعدی بهتر شود.'}</p>
<div className="effectiveness-rating-grid">
{effectivenessFields.map(([key, label]) => (
<fieldset key={key}>
<legend>{label}</legend>
<div className="effectiveness-stars" role="radiogroup" aria-label={label}>
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
type="button"
role="radio"
aria-checked={form[key] === rating}
aria-label={`${rating} از ۵`}
className={form[key] >= rating ? 'selected' : ''}
onClick={() => setForm({ ...form, [key]: rating })}
><Star size={19} /></button>
))}
</div>
</fieldset>
))}
</div>
<label className="form-group">
<span className="form-label">نظر تکمیلی (اختیاری)</span>
<textarea className="form-textarea" maxLength={2000} value={form.comment} onChange={(event) => setForm({ ...form, comment: event.target.value })} placeholder="چه چیزی خوب بود و چه چیزی می‌تواند بهتر شود؟" />
</label>
<button type="submit" className="btn btn-primary" disabled={saving}><Save size={16} /> {saving ? 'در حال ثبت...' : data.current_user_review ? 'به‌روزرسانی ارزیابی' : 'ثبت ارزیابی'}</button>
</form>
)}
</section>
);
}
function QuickCapture({ meetingId, form, setForm, onClose, onSaved }) { function QuickCapture({ meetingId, form, setForm, onClose, onSaved }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);

مشاهده پرونده

@ -103,7 +103,7 @@ export default function Projects() {
}; };
return ( return (
<div className="page-container"> <div className="page-container projects-page">
<div className="page-header"> <div className="page-header">
<button className="btn btn-primary" onClick={openCreate}>+ پروژه جدید</button> <button className="btn btn-primary" onClick={openCreate}>+ پروژه جدید</button>
</div> </div>
@ -136,7 +136,7 @@ export default function Projects() {
) : viewMode === 'table' ? ( ) : viewMode === 'table' ? (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}> <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="table-container"> <div className="table-container">
<table> <table className="projects-table">
<thead> <thead>
<tr> <tr>
<th>عنوان</th> <th>عنوان</th>
@ -151,7 +151,7 @@ export default function Projects() {
<tbody> <tbody>
{projects.map(p => ( {projects.map(p => (
<tr key={p.id}> <tr key={p.id}>
<td><Link to={`/projects/${p.id}`} style={{ fontWeight: 500, color: 'var(--primary)' }}>{p.title}</Link></td> <td><Link to={`/projects/${p.id}`} className="project-title-link">{p.title}</Link></td>
<td><StatusBadge status={p.status} /></td> <td><StatusBadge status={p.status} /></td>
<td><PriorityBadge priority={p.priority} /></td> <td><PriorityBadge priority={p.priority} /></td>
<td style={{ minWidth: 120 }}> <td style={{ minWidth: 120 }}>
@ -185,12 +185,12 @@ export default function Projects() {
) : ( ) : (
<div className="grid grid-3"> <div className="grid grid-3">
{projects.map(p => ( {projects.map(p => (
<div key={p.id} className="card"> <div key={p.id} className="card project-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.75rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.75rem' }}>
<Link to={`/projects/${p.id}`} style={{ fontWeight: 600, color: 'var(--gray-900)' }}>{p.title}</Link> <Link to={`/projects/${p.id}`} className="project-title-link">{p.title}</Link>
<StatusBadge status={p.status} /> <StatusBadge status={p.status} />
</div> </div>
<p style={{ fontSize: '0.8125rem', color: 'var(--gray-500)', marginBottom: '0.75rem', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{p.description || '—'}</p> <p className="project-card-description">{p.description || '—'}</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
<PriorityBadge priority={p.priority} /> <PriorityBadge priority={p.priority} />
{p.risk_level && <span className="badge badge-gray">ریسک: {p.risk_level}</span>} {p.risk_level && <span className="badge badge-gray">ریسک: {p.risk_level}</span>}

مشاهده پرونده

@ -18,6 +18,33 @@ const tabs = [
const COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#14b8a6', '#ec4899']; const COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#14b8a6', '#ec4899'];
const priorityLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد', urgent: 'فوری' }; const priorityLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد', urgent: 'فوری' };
const riskLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد', critical: 'بحرانی' }; const riskLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد', critical: 'بحرانی' };
const projectStatusLabels = {
active: 'فعال',
archived: 'بایگانی‌شده',
pending: 'در انتظار',
planning: 'برنامه‌ریزی',
in_progress: 'در حال انجام',
waiting: 'در انتظار',
on_hold: 'متوقف‌شده',
suspended: 'متوقف‌شده',
completed: 'تکمیل‌شده',
done: 'تکمیل‌شده',
cancelled: 'لغوشده',
canceled: 'لغوشده',
};
const getProjectStatusLabel = (item) => {
const status = item.status || item.label || item.name || 'نامشخص';
return projectStatusLabels[status] || status;
};
function TeamNameTick({ x, y, payload }) {
return (
<text x={x - 10} y={y} dy="0.35em" textAnchor="start" direction="rtl" fill="var(--color-text-secondary)" fontSize={12}>
{payload.value}
</text>
);
}
export default function Reports() { export default function Reports() {
const [activeTab, setActiveTab] = useState('project-status'); const [activeTab, setActiveTab] = useState('project-status');
@ -35,14 +62,18 @@ export default function Reports() {
switch (activeTab) { switch (activeTab) {
case 'project-status': case 'project-status':
{
const localizedData = Array.isArray(data)
? data.map((item) => ({ ...item, status_label: getProjectStatusLabel(item) }))
: [];
return ( return (
<div className="grid grid-2"> <div className="grid grid-2">
<div className="card" style={{ padding: '1.5rem' }}> <div className="card" style={{ padding: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>توزیع وضعیت پروژهها</h3> <h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>توزیع وضعیت پروژهها</h3>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<PieChart> <PieChart>
<Pie data={data} dataKey="count" nameKey="status" cx="50%" cy="50%" outerRadius={100} label> <Pie data={localizedData} dataKey="count" nameKey="status_label" cx="50%" cy="50%" outerRadius={100} label>
{Array.isArray(data) && data.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)} {localizedData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie> </Pie>
<Legend /> <Legend />
<Tooltip /> <Tooltip />
@ -51,15 +82,16 @@ export default function Reports() {
</div> </div>
<div className="card" style={{ padding: '1.5rem' }}> <div className="card" style={{ padding: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>جزئیات</h3> <h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>جزئیات</h3>
{Array.isArray(data) && data.map((item, i) => ( {localizedData.map((item, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.625rem 0', borderBottom: '1px solid var(--gray-50)' }}> <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.625rem 0', borderBottom: '1px solid var(--gray-50)' }}>
<span style={{ fontSize: '0.875rem' }}>{item.status || item.label || item.name}</span> <span style={{ fontSize: '0.875rem' }}>{item.status_label}</span>
<span style={{ fontWeight: 600, color: COLORS[i % COLORS.length] }}>{item.count || item.value}</span> <span style={{ fontWeight: 600, color: COLORS[i % COLORS.length] }}>{item.count || item.value}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
); );
}
case 'delayed-tasks': case 'delayed-tasks':
return ( return (
@ -85,14 +117,18 @@ export default function Reports() {
); );
case 'team-performance': case 'team-performance':
{
const teamData = Array.isArray(data) ? data : [];
const chartHeight = Math.max(400, teamData.length * 56);
return ( return (
<div className="card" style={{ padding: '1.5rem' }}> <div className="card" style={{ padding: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>عملکرد اعضای تیم</h3> <h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>عملکرد اعضای تیم</h3>
<ResponsiveContainer width="100%" height={400}> <div className="team-performance-chart" dir="ltr" style={{ height: chartHeight }}>
<BarChart data={Array.isArray(data) ? data : []} layout="vertical"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={teamData} layout="vertical" margin={{ top: 8, right: 24, bottom: 8, left: 24 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" /> <XAxis type="number" />
<YAxis type="category" dataKey="name" width={120} /> <YAxis type="category" dataKey="name" orientation="left" width={170} tick={<TeamNameTick />} tickLine={false} axisLine={false} />
<Tooltip /> <Tooltip />
<Bar dataKey="completed" fill="#22c55e" name="تکمیل شده" /> <Bar dataKey="completed" fill="#22c55e" name="تکمیل شده" />
<Bar dataKey="in_progress" fill="#6366f1" name="در حال انجام" /> <Bar dataKey="in_progress" fill="#6366f1" name="در حال انجام" />
@ -100,7 +136,9 @@ export default function Reports() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
}
case 'workload': case 'workload':
return ( return (

مشاهده پرونده

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
Bell, Bell,
@ -17,13 +18,17 @@ import {
SlidersHorizontal, SlidersHorizontal,
Trash2, Trash2,
Users, Users,
X,
CalendarDays, CalendarDays,
TimerReset, TimerReset,
MessagesSquare, MessagesSquare,
UserRound,
Wrench,
Image as ImageIcon,
UploadCloud,
} from 'lucide-react'; } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
import ThemeAccentPicker from '../components/ThemeAccentPicker'; import ThemeAccentPicker from '../components/ThemeAccentPicker';
import Modal from '../components/Modal';
import { applyThemeMode, getThemeMode, themeModes } from '../utils/themeMode'; import { applyThemeMode, getThemeMode, themeModes } from '../utils/themeMode';
const groupMeta = { const groupMeta = {
@ -38,19 +43,64 @@ const groupMeta = {
people: { label: 'منابع انسانی', icon: Users }, people: { label: 'منابع انسانی', icon: Users },
personalization: { label: 'شخصی‌سازی', icon: SlidersHorizontal }, personalization: { label: 'شخصی‌سازی', icon: SlidersHorizontal },
calendar: { label: 'تقویم و زمان کاری', icon: CalendarDays }, calendar: { label: 'تقویم و زمان کاری', icon: CalendarDays },
sprint: { label: 'Sprint و Agile', icon: TimerReset }, sprint: { label: 'چابکی و اسپرینت', icon: TimerReset },
meeting: { label: 'جلسات و یادآوری', icon: MessagesSquare }, meeting: { label: 'جلسات و یادآوری', icon: MessagesSquare },
}; };
const groupDescriptions = { const groupDescriptions = {
general: 'هویت، اطلاعات تماس و تنظیمات عمومی سازمان', general: 'هویت، اطلاعات تماس و تنظیمات عمومی سازمان',
people: 'عنوان‌های شغلی و گزینه‌های مرتبط با اعضای سازمان',
task: 'وضعیت‌ها و اولویت‌های پیش‌فرض تسک‌ها',
project: 'چرخه عمر، اولویت و ارزیابی ریسک پروژه‌ها',
backlog: 'انواع و مراحل پالایش آیتم‌های بک‌لاگ',
personalization: 'ظاهر سامانه، حالت نمایش و رنگ‌بندی شخصی', personalization: 'ظاهر سامانه، حالت نمایش و رنگ‌بندی شخصی',
notification: 'نوع‌ها، کانال‌ها و سیاست دریافت اعلان', notification: 'نوع‌ها، کانال‌ها و سیاست دریافت اعلان',
calendar: 'منطقه زمانی، روزهای کاری و رفتار تقویم', calendar: 'منطقه زمانی، روزهای کاری و رفتار تقویم',
sprint: 'قواعد سلامت Sprint و سیاست‌های Agile', sprint: 'قواعد سلامت اسپرینت و سیاست‌های چابکی',
meeting: 'پیش‌فرض جلسه، یادآوری و ارزیابی اثربخشی', meeting: 'پیش‌فرض جلسه، یادآوری و ارزیابی اثربخشی',
security: 'کنترل‌های امنیتی و سیاست‌های دسترسی',
mail: 'ارسال ایمیل و زیرساخت SMTP', mail: 'ارسال ایمیل و زیرساخت SMTP',
security: 'نقش‌ها، مجوزها و امنیت حساب کاربری',
};
const groupOrder = ['general', 'people', 'task', 'project', 'backlog', 'calendar', 'sprint', 'meeting', 'notification', 'mail', 'security'];
const keyDescriptions = {
company_name: 'نامی که در هویت سازمان و بخش‌های مدیریتی نمایش داده می‌شود.',
company_phone: 'شماره تماس رسمی سازمان برای نمایش در اطلاعات عمومی.',
company_address: 'نشانی کامل دفتر یا محل اصلی سازمان.',
company_logo: 'لوگوی رسمی سازمان که در منوی اصلی سامانه نمایش داده می‌شود.',
default_task_statuses: 'مراحل مجاز گردش کار تسک‌ها را مشخص می‌کند.',
default_priorities: 'اولویت‌هایی که هنگام ساخت تسک و بک‌لاگ قابل انتخاب هستند.',
default_project_statuses: 'مراحل استاندارد چرخه عمر پروژه را مشخص می‌کند.',
risk_levels: 'سطوح قابل انتخاب برای ارزیابی ریسک پروژه.',
backlog_types: 'دسته‌بندی‌های قابل استفاده برای آیتم‌های بک‌لاگ.',
backlog_statuses: 'وضعیت‌های داخلی فرآیند پالایش بک‌لاگ.',
notification_types: 'رویدادهایی که اجازه تولید اعلان در سامانه دارند.',
smtp_settings: 'اطلاعات اتصال به سرویس ارسال ایمیل سازمان.',
job_titles: 'عنوان‌های شغلی قابل انتخاب برای اعضای سازمان.',
working_days: 'روزهایی که در برنامه‌ریزی و تقویم، روز کاری محسوب می‌شوند.',
organization_timezone: 'مبنای زمانی ثبت و نمایش رویدادهای سازمان.',
default_meeting_reminder_minutes: 'فاصله زمانی یادآوری پیش‌فرض پیش از شروع جلسه.',
meeting_effectiveness_enabled: 'نمایش فرم ارزیابی اثربخشی بعد از پایان جلسه.',
sprint_health_override_requires_reason: 'برای تغییر دستی وضعیت سلامت اسپرینت، ثبت دلیل را الزامی می‌کند.',
};
const choiceOptions = {
working_days: [
['saturday', 'شنبه'], ['sunday', 'یکشنبه'], ['monday', 'دوشنبه'], ['tuesday', 'سه‌شنبه'],
['wednesday', 'چهارشنبه'], ['thursday', 'پنجشنبه'], ['friday', 'جمعه'],
],
default_task_statuses: [
['todo', 'برای انجام'], ['in_progress', 'در حال انجام'], ['waiting', 'در انتظار'], ['review', 'بازبینی'], ['done', 'انجام‌شده'],
],
default_project_statuses: [
['planning', 'برنامه‌ریزی'], ['in_progress', 'در حال انجام'], ['waiting', 'در انتظار'], ['on_hold', 'متوقف'], ['completed', 'تکمیل‌شده'], ['cancelled', 'لغوشده'],
],
default_priorities: [['low', 'کم'], ['medium', 'متوسط'], ['high', 'زیاد'], ['urgent', 'فوری'], ['critical', 'بحرانی']],
risk_levels: [['low', 'کم'], ['medium', 'متوسط'], ['high', 'زیاد'], ['critical', 'بحرانی']],
backlog_types: [['idea', 'ایده'], ['feature', 'قابلیت'], ['bug', 'باگ'], ['improvement', 'بهبود'], ['task', 'تسک']],
backlog_statuses: [['new', 'جدید'], ['reviewed', 'بررسی‌شده'], ['ready', 'آماده'], ['rejected', 'ردشده']],
notification_types: [['task_assigned', 'تخصیص تسک'], ['task_overdue', 'تسک عقب‌افتاده'], ['meeting_created', 'جلسه جدید'], ['mention', 'اشاره به کاربر']],
}; };
const typeLabels = { const typeLabels = {
@ -66,7 +116,7 @@ const keyLabels = {
company_name: 'نام شرکت', company_name: 'نام شرکت',
company_phone: 'شماره تماس شرکت', company_phone: 'شماره تماس شرکت',
company_address: 'آدرس شرکت', company_address: 'آدرس شرکت',
company_logo: 'مسیر لوگوی شرکت', company_logo: 'لوگوی شرکت',
default_task_statuses: 'وضعیت‌های پیش‌فرض تسک', default_task_statuses: 'وضعیت‌های پیش‌فرض تسک',
default_priorities: 'اولویت‌های پیش‌فرض', default_priorities: 'اولویت‌های پیش‌فرض',
default_project_statuses: 'وضعیت‌های پیش‌فرض پروژه', default_project_statuses: 'وضعیت‌های پیش‌فرض پروژه',
@ -80,7 +130,7 @@ const keyLabels = {
organization_timezone: 'منطقه زمانی سازمان', organization_timezone: 'منطقه زمانی سازمان',
default_meeting_reminder_minutes: 'یادآوری پیش‌فرض جلسه', default_meeting_reminder_minutes: 'یادآوری پیش‌فرض جلسه',
meeting_effectiveness_enabled: 'ارزیابی اثربخشی جلسه', meeting_effectiveness_enabled: 'ارزیابی اثربخشی جلسه',
sprint_health_override_requires_reason: 'الزام دلیل برای تغییر سلامت Sprint', sprint_health_override_requires_reason: 'الزام دلیل برای تغییر سلامت اسپرینت',
}; };
const emptySetting = { const emptySetting = {
@ -128,12 +178,14 @@ export default function Settings() {
const [settings, setSettings] = useState([]); const [settings, setSettings] = useState([]);
const [drafts, setDrafts] = useState({}); const [drafts, setDrafts] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeGroup, setActiveGroup] = useState(''); const [activeGroup, setActiveGroup] = useState('personalization');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [savingId, setSavingId] = useState(null); const [savingId, setSavingId] = useState(null);
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState(emptySetting); const [addForm, setAddForm] = useState(emptySetting);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [branding, setBranding] = useState({ company_name: 'مدیریت پروژه', logo_url: null, logo_path: null });
const [logoUploading, setLogoUploading] = useState(false);
const fetchSettings = async () => { const fetchSettings = async () => {
setLoading(true); setLoading(true);
@ -143,6 +195,7 @@ export default function Settings() {
setSettings(nextSettings); setSettings(nextSettings);
setDrafts(Object.fromEntries(nextSettings.map((setting) => [setting.id, toDraft(setting)]))); setDrafts(Object.fromEntries(nextSettings.map((setting) => [setting.id, toDraft(setting)])));
setActiveGroup((current) => current || nextSettings[0]?.group || 'personalization'); setActiveGroup((current) => current || nextSettings[0]?.group || 'personalization');
api.get('/branding').then((response) => setBranding(response.data.data || {})).catch(() => {});
} catch { } catch {
toast.error('خطا در دریافت تنظیمات'); toast.error('خطا در دریافت تنظیمات');
} finally { } finally {
@ -158,9 +211,25 @@ export default function Settings() {
acc[group] = (acc[group] || 0) + 1; acc[group] = (acc[group] || 0) + 1;
return acc; return acc;
}, {}); }, {});
return Object.entries(counts).map(([key, count]) => ({ key, count })); return Object.entries(counts)
.map(([key, count]) => ({ key, count }))
.sort((a, b) => {
const aIndex = groupOrder.indexOf(a.key);
const bIndex = groupOrder.indexOf(b.key);
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
});
}, [settings]); }, [settings]);
const navigationGroups = useMemo(() => {
const guided = [...groups];
if (!guided.some(({ key }) => key === 'security')) guided.push({ key: 'security', count: 0 });
return guided.sort((a, b) => {
const aIndex = groupOrder.indexOf(a.key);
const bIndex = groupOrder.indexOf(b.key);
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
});
}, [groups]);
const visibleSettings = useMemo(() => { const visibleSettings = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return settings.filter((setting) => { return settings.filter((setting) => {
@ -231,6 +300,13 @@ export default function Settings() {
const updated = data.data; const updated = data.data;
setSettings((prev) => prev.map((item) => (item.id === setting.id ? updated : item))); setSettings((prev) => prev.map((item) => (item.id === setting.id ? updated : item)));
setDrafts((prev) => ({ ...prev, [setting.id]: toDraft(updated) })); setDrafts((prev) => ({ ...prev, [setting.id]: toDraft(updated) }));
if (setting.key === 'company_name') {
setBranding((current) => {
const next = { ...current, company_name: updated.value || 'مدیریت پروژه' };
window.dispatchEvent(new CustomEvent('branding:updated', { detail: next }));
return next;
});
}
toast.success('تنظیم ذخیره شد'); toast.success('تنظیم ذخیره شد');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.message || 'خطا در ذخیره تنظیم'); toast.error(err.response?.data?.message || 'خطا در ذخیره تنظیم');
@ -284,6 +360,45 @@ export default function Settings() {
} }
}; };
const applyBrandingResponse = ({ setting, branding: nextBranding }) => {
if (setting) {
setSettings((current) => current.map((item) => (item.key === 'company_logo' ? setting : item)));
setDrafts((current) => ({ ...current, [setting.id]: toDraft(setting) }));
}
setBranding(nextBranding || {});
window.dispatchEvent(new CustomEvent('branding:updated', { detail: nextBranding || {} }));
};
const uploadCompanyLogo = async (file) => {
if (!file) return;
const formData = new FormData();
formData.append('logo', file);
setLogoUploading(true);
try {
const { data } = await api.post('/settings/company-logo', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
applyBrandingResponse(data.data);
toast.success('لوگوی شرکت بارگذاری شد');
} catch (error) {
const firstError = Object.values(error.response?.data?.errors || {})[0]?.[0];
toast.error(firstError || error.response?.data?.message || 'بارگذاری لوگو ناموفق بود');
} finally {
setLogoUploading(false);
}
};
const removeCompanyLogo = async () => {
setLogoUploading(true);
try {
const { data } = await api.delete('/settings/company-logo');
applyBrandingResponse(data.data);
toast.success('لوگوی شرکت حذف شد');
} catch (error) {
toast.error(error.response?.data?.message || 'حذف لوگو ناموفق بود');
} finally {
setLogoUploading(false);
}
};
const activeGroupTitle = activeGroup === 'personalization' ? 'شخصی‌سازی' : getGroupLabel(activeGroup); const activeGroupTitle = activeGroup === 'personalization' ? 'شخصی‌سازی' : getGroupLabel(activeGroup);
return ( return (
@ -291,12 +406,11 @@ export default function Settings() {
<div className="page-header settings-header"> <div className="page-header settings-header">
<div> <div>
<h1>مرکز تنظیمات</h1> <h1>مرکز تنظیمات</h1>
<p className="settings-subtitle">تنظیمات شخصی و سیاستهای سازمان را از یک نقطه مدیریت کنید.</p> <p className="settings-subtitle">تنظیمات شخصی و سیاستهای سازمان را با کنترلهای روشن و قابل بازگشت مدیریت کنید.</p>
</div>
<div className="settings-header-actions">
<button className="btn btn-primary" onClick={() => setAddOpen(true)}><Plus size={16} /> تنظیم جدید</button>
</div> </div>
<button className="btn btn-primary" onClick={() => setAddOpen(true)}>
<Plus size={16} />
تنظیم جدید
</button>
</div> </div>
<div className="settings-summary"> <div className="settings-summary">
@ -311,11 +425,12 @@ export default function Settings() {
<button className={`settings-nav-item ${activeGroup === 'personalization' ? 'active' : ''}`} onClick={() => setActiveGroup('personalization')}> <button className={`settings-nav-item ${activeGroup === 'personalization' ? 'active' : ''}`} onClick={() => setActiveGroup('personalization')}>
<span><SlidersHorizontal size={16} /> شخصیسازی</span> <span><SlidersHorizontal size={16} /> شخصیسازی</span>
</button> </button>
{groups.map(({ key }) => { {navigationGroups.map(({ key, count }) => {
const Icon = groupMeta[key]?.icon || SlidersHorizontal; const Icon = groupMeta[key]?.icon || SlidersHorizontal;
return ( return (
<button key={key} className={`settings-nav-item ${activeGroup === key ? 'active' : ''}`} onClick={() => setActiveGroup(key)}> <button key={key} className={`settings-nav-item ${activeGroup === key ? 'active' : ''}`} onClick={() => setActiveGroup(key)}>
<span><Icon size={16} /> {getGroupLabel(key)}</span> <span><Icon size={16} /> {getGroupLabel(key)}</span>
{count > 0 && <strong>{count.toLocaleString('fa-IR')}</strong>}
</button> </button>
); );
})} })}
@ -327,14 +442,16 @@ export default function Settings() {
<h2>{activeGroupTitle}</h2> <h2>{activeGroupTitle}</h2>
<span>{groupDescriptions[activeGroup] || `${visibleSettings.length} مورد`}</span> <span>{groupDescriptions[activeGroup] || `${visibleSettings.length} مورد`}</span>
</div> </div>
<div className="settings-search"> {activeGroup !== 'personalization' && activeGroup !== 'security' && <div className="settings-search">
<Search size={16} /> <Search size={16} />
<input className="form-input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="جستجو در کلید، عنوان یا مقدار" /> <input className="form-input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="جستجو در عنوان، کلید یا مقدار" />
</div> </div>}
</div> </div>
{activeGroup === 'personalization' ? ( {activeGroup === 'personalization' ? (
<AppearancePanel /> <AppearancePanel />
) : activeGroup === 'security' ? (
<SecurityPanel />
) : loading ? ( ) : loading ? (
<div className="settings-list"> <div className="settings-list">
{[1, 2, 3, 4].map((item) => <div key={item} className="skeleton settings-skeleton" />)} {[1, 2, 3, 4].map((item) => <div key={item} className="skeleton settings-skeleton" />)}
@ -356,6 +473,7 @@ export default function Settings() {
onChange={(patch) => updateDraft(setting.id, patch)} onChange={(patch) => updateDraft(setting.id, patch)}
onReset={() => resetSetting(setting)} onReset={() => resetSetting(setting)}
onSave={() => saveSetting(setting)} onSave={() => saveSetting(setting)}
logoProps={{ branding, uploading: logoUploading, onUpload: uploadCompanyLogo, onRemove: removeCompanyLogo }}
/> />
))} ))}
</div> </div>
@ -370,14 +488,14 @@ export default function Settings() {
</div> </div>
)} )}
{addOpen && <div className="drawer-overlay" onClick={() => setAddOpen(false)} />}
{addOpen && ( {addOpen && (
<div className="drawer settings-drawer"> <Modal
<div className="drawer-header"> title="تنظیم جدید"
<h3 className="drawer-title">تنظیم جدید</h3> size="lg"
<button className="modal-close" onClick={() => setAddOpen(false)}><X size={18} /></button> onClose={() => !adding && setAddOpen(false)}
</div> footer={<><button type="button" className="btn btn-secondary" onClick={() => setAddOpen(false)} disabled={adding}>انصراف</button><button type="submit" form="advanced-setting-form" className="btn btn-primary" disabled={adding}><Save size={16} /> {adding ? 'در حال ذخیره...' : 'ذخیره'}</button></>}
<form className="drawer-body settings-add-form" onSubmit={addSetting}> >
<form id="advanced-setting-form" className="settings-add-form" onSubmit={addSetting}>
<Field label="کلید"> <Field label="کلید">
<input className="form-input" value={addForm.key} onChange={(e) => setAddForm({ ...addForm, key: e.target.value })} placeholder="example_setting_key" /> <input className="form-input" value={addForm.key} onChange={(e) => setAddForm({ ...addForm, key: e.target.value })} placeholder="example_setting_key" />
</Field> </Field>
@ -398,15 +516,8 @@ export default function Settings() {
<Field label="مقدار"> <Field label="مقدار">
<AddValueEditor form={addForm} onChange={(value) => setAddForm({ ...addForm, value })} /> <AddValueEditor form={addForm} onChange={(value) => setAddForm({ ...addForm, value })} />
</Field> </Field>
<div className="drawer-footer">
<button type="button" className="btn btn-secondary" onClick={() => setAddOpen(false)}>انصراف</button>
<button type="submit" className="btn btn-primary" disabled={adding}>
<Save size={16} />
{adding ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</form> </form>
</div> </Modal>
)} )}
</div> </div>
); );
@ -437,6 +548,22 @@ function AppearancePanel() {
); );
} }
function SecurityPanel() {
return (
<section className="settings-security-panel">
<div className="settings-guidance-icon"><ShieldCheck size={24} /></div>
<div>
<strong>امنیت و دسترسیها</strong>
<p>مدیریت نقشها از تنظیمات سازمانی جدا شده تا تغییرات حساس با دسترسی مناسب انجام شوند.</p>
</div>
<div className="settings-security-actions">
<Link className="btn btn-primary" to="/roles"><ShieldCheck size={16} /> نقشها و دسترسیها</Link>
<Link className="btn btn-secondary" to="/profile"><UserRound size={16} /> امنیت حساب من</Link>
</div>
</section>
);
}
function SummaryItem({ label, value, icon: Icon }) { function SummaryItem({ label, value, icon: Icon }) {
return ( return (
<div className="settings-stat"> <div className="settings-stat">
@ -447,7 +574,7 @@ function SummaryItem({ label, value, icon: Icon }) {
); );
} }
function SettingRow({ setting, draft, saving, onChange, onReset, onSave }) { function SettingRow({ setting, draft, saving, onChange, onReset, onSave, logoProps }) {
const originalType = inferType(setting); const originalType = inferType(setting);
const hasChanged = JSON.stringify({ const hasChanged = JSON.stringify({
value: normalizeByType(draft.type, draft.value), value: normalizeByType(draft.type, draft.value),
@ -464,19 +591,22 @@ function SettingRow({ setting, draft, saving, onChange, onReset, onSave }) {
<div className="settings-item-head"> <div className="settings-item-head">
<div className="settings-item-title"> <div className="settings-item-title">
<strong>{getKeyLabel(setting.key)}</strong> <strong>{getKeyLabel(setting.key)}</strong>
<span>{setting.key}</span> <p>{keyDescriptions[setting.key] || 'مقدار این تنظیم را متناسب با سیاست‌های سازمان انتخاب کنید.'}</p>
</div>
<div className="settings-item-meta">
<span className="badge badge-gray">{getGroupLabel(draft.group)}</span>
<span className="badge badge-info">{typeLabels[draft.type] || draft.type}</span>
</div> </div>
</div> </div>
<div className="settings-editor-grid"> <div className="settings-editor-grid guided">
<div className="settings-editor"> <div className="settings-editor">
<ValueEditor type={draft.type} value={draft.value} onChange={(value) => onChange({ value })} /> <GuidedValueEditor settingKey={setting.key} type={draft.type} value={draft.value} onChange={(value) => onChange({ value })} logoProps={logoProps} />
</div> </div>
<div className="settings-side-fields"> </div>
<details className="settings-technical-details">
<summary><Wrench size={16} /> جزئیات فنی</summary>
<div className="settings-technical-grid">
<Field label="کلید تنظیم">
<input className="form-input" dir="ltr" value={setting.key} readOnly />
</Field>
<Field label="گروه"> <Field label="گروه">
<input className="form-input" value={draft.group} onChange={(e) => onChange({ group: e.target.value })} /> <input className="form-input" value={draft.group} onChange={(e) => onChange({ group: e.target.value })} />
</Field> </Field>
@ -485,8 +615,11 @@ function SettingRow({ setting, draft, saving, onChange, onReset, onSave }) {
{Object.entries(typeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)} {Object.entries(typeLabels).map(([key, label]) => <option key={key} value={key}>{label}</option>)}
</select> </select>
</Field> </Field>
{setting.key !== 'company_logo' && <Field label="ویرایش مقدار خام">
<ValueEditor type={draft.type} value={draft.value} onChange={(value) => onChange({ value })} />
</Field>}
</div> </div>
</div> </details>
<div className="settings-actions"> <div className="settings-actions">
<button className="btn btn-secondary btn-sm" onClick={onReset} disabled={!hasChanged}>بازنشانی</button> <button className="btn btn-secondary btn-sm" onClick={onReset} disabled={!hasChanged}>بازنشانی</button>
@ -499,6 +632,118 @@ function SettingRow({ setting, draft, saving, onChange, onReset, onSave }) {
); );
} }
function GuidedValueEditor({ settingKey, type, value, onChange, logoProps }) {
if (settingKey === 'company_logo') {
return <CompanyLogoEditor {...logoProps} />;
}
if (choiceOptions[settingKey]) {
return <ChoiceListEditor options={choiceOptions[settingKey]} value={Array.isArray(value) ? value : []} onChange={onChange} />;
}
if (settingKey === 'smtp_settings') {
return <SmtpEditor value={isPlainObject(value) ? value : {}} onChange={onChange} />;
}
if (settingKey === 'organization_timezone') {
const timezones = ['Asia/Tehran', 'Asia/Dubai', 'Europe/Istanbul', 'UTC'];
const options = timezones.includes(value) ? timezones : [value, ...timezones].filter(Boolean);
return <select className="form-select" value={value || 'Asia/Tehran'} onChange={(e) => onChange(e.target.value)}>{options.map((timezone) => <option key={timezone} value={timezone}>{timezone}</option>)}</select>;
}
if (settingKey === 'company_address') {
return <textarea className="form-textarea settings-textarea" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
}
if (settingKey === 'company_phone') {
return <input className="form-input" type="tel" dir="ltr" value={value ?? ''} onChange={(e) => onChange(e.target.value)} placeholder="021-00000000" />;
}
if (settingKey === 'default_meeting_reminder_minutes') {
return <div className="settings-input-with-suffix"><input className="form-input" type="number" min="0" max="10080" value={value ?? ''} onChange={(e) => onChange(e.target.value)} /><span>دقیقه قبل</span></div>;
}
return <ValueEditor type={type} value={value} onChange={onChange} />;
}
function CompanyLogoEditor({ branding, uploading, onUpload, onRemove }) {
const inputRef = useRef(null);
return (
<div className="company-logo-editor">
<div className="company-logo-preview">
{branding?.logo_url
? <img src={branding.logo_url} alt={`لوگوی ${branding.company_name || 'شرکت'}`} />
: <ImageIcon size={30} aria-hidden="true" />}
</div>
<div className="company-logo-copy">
<strong>{branding?.logo_url ? 'لوگوی فعلی شرکت' : 'هنوز لوگویی بارگذاری نشده است'}</strong>
<span>فرمتهای PNG، JPG و WebP تا حداکثر ۲ مگابایت پذیرفته میشوند.</span>
<div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(event) => {
onUpload(event.target.files?.[0]);
event.target.value = '';
}}
/>
<button type="button" className="btn btn-primary" disabled={uploading} onClick={() => inputRef.current?.click()}>
<UploadCloud size={16} /> {uploading ? 'در حال بارگذاری...' : branding?.logo_url ? 'تعویض لوگو' : 'انتخاب و بارگذاری لوگو'}
</button>
{branding?.logo_url && <button type="button" className="btn btn-danger" disabled={uploading} onClick={onRemove}>حذف لوگو</button>}
</div>
</div>
</div>
);
}
function ChoiceListEditor({ options, value, onChange }) {
const knownValues = options.map(([key]) => key);
const mergedOptions = [
...options,
...value.filter((item) => !knownValues.includes(item)).map((item) => [item, item]),
];
const toggle = (key) => {
if (value.includes(key)) onChange(value.filter((item) => item !== key));
else onChange([...value, key]);
};
return (
<div className="settings-choice-grid">
{mergedOptions.map(([key, label]) => (
<label key={key} className={value.includes(key) ? 'selected' : ''}>
<input type="checkbox" checked={value.includes(key)} onChange={() => toggle(key)} />
<span>{label}</span>
{value.includes(key) && <Check size={15} />}
</label>
))}
</div>
);
}
function SmtpEditor({ value, onChange }) {
const update = (key, nextValue) => onChange({ ...value, [key]: nextValue });
return (
<div className="settings-smtp-grid">
<Field label="آدرس سرور">
<input className="form-input" dir="ltr" value={value.host || ''} onChange={(e) => update('host', e.target.value)} placeholder="smtp.example.com" />
</Field>
<Field label="پورت">
<input className="form-input" type="number" min="1" max="65535" value={value.port || ''} onChange={(e) => update('port', Number(e.target.value))} />
</Field>
<Field label="رمزگذاری">
<select className="form-select" value={value.encryption || 'tls'} onChange={(e) => update('encryption', e.target.value)}><option value="tls">TLS</option><option value="ssl">SSL</option><option value="none">بدون رمزگذاری</option></select>
</Field>
<Field label="ایمیل فرستنده">
<input className="form-input" type="email" dir="ltr" value={value.from_address || ''} onChange={(e) => update('from_address', e.target.value)} placeholder="no-reply@example.com" />
</Field>
</div>
);
}
function ValueEditor({ type, value, onChange }) { function ValueEditor({ type, value, onChange }) {
if (type === 'boolean') { if (type === 'boolean') {
const checked = value === true || value === 'true'; const checked = value === true || value === 'true';

مشاهده پرونده

@ -214,14 +214,8 @@ export default function Tasks() {
{deleting && <ConfirmDialog title="حذف تسک" message={`آیا از حذف "${deleting.title}" اطمینان دارید؟`} onConfirm={handleDelete} onCancel={() => setDeleting(null)} danger />} {deleting && <ConfirmDialog title="حذف تسک" message={`آیا از حذف "${deleting.title}" اطمینان دارید؟`} onConfirm={handleDelete} onCancel={() => setDeleting(null)} danger />}
{detailTask && ( {detailTask && (
<> <Modal title={detailTask.title} onClose={() => setDetailTask(null)} size="xl">
<div className="drawer-overlay" onClick={() => setDetailTask(null)} /> <div className="task-detail-popup">
<div className="drawer">
<div className="drawer-header">
<h3 className="drawer-title">{detailTask.title}</h3>
<button className="modal-close" onClick={() => setDetailTask(null)}>&times;</button>
</div>
<div className="drawer-body">
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<StatusBadge status={detailTask.status} /> <StatusBadge status={detailTask.status} />
<PriorityBadge priority={detailTask.priority} /> <PriorityBadge priority={detailTask.priority} />
@ -242,8 +236,7 @@ export default function Tasks() {
<h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', marginTop: '1rem' }}>چکلیست</h4> <h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', marginTop: '1rem' }}>چکلیست</h4>
<ChecklistSection taskId={detailTask.id} /> <ChecklistSection taskId={detailTask.id} />
</div> </div>
</div> </Modal>
</>
)} )}
</div> </div>
); );

مشاهده پرونده

@ -411,17 +411,22 @@ textarea::placeholder {
.persian-date-input span { flex: 1; color: inherit; } .persian-date-input span { flex: 1; color: inherit; }
.persian-date-clear { color: var(--gray-400); flex-shrink: 0; } .persian-date-clear { color: var(--gray-400); flex-shrink: 0; }
.persian-date-popover { .persian-date-popover {
position: absolute; position: fixed;
top: calc(100% + 6px); inset: auto;
right: 0; width: min(292px, calc(100vw - 16px));
width: 292px; max-height: calc(100dvh - 16px);
margin: 0;
background: var(--surface); background: var(--surface);
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
padding: 0.75rem; padding: 0.75rem;
z-index: 130; z-index: 130;
box-sizing: border-box;
overflow-y: auto;
overscroll-behavior: contain;
} }
.persian-date-popover::backdrop { background: transparent; }
.persian-date-head { .persian-date-head {
display: flex; display: flex;
align-items: center; align-items: center;
@ -534,6 +539,7 @@ tbody tr:hover { background: var(--surface-muted); }
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
isolation: isolate; isolation: isolate;
} }
.modal.modal-has-floating-date { overflow: visible; }
.modal-lg { max-width: 720px; } .modal-lg { max-width: 720px; }
.modal-sm { max-width: 400px; } .modal-sm { max-width: 400px; }
.modal-xl { max-width: 920px; } .modal-xl { max-width: 920px; }
@ -562,6 +568,29 @@ tbody tr:hover { background: var(--surface-muted); }
transform: rotate(90deg); transform: rotate(90deg);
} }
.project-title-link { color: var(--color-heading); font-weight: 600; line-height: 1.55; }
.project-title-link:hover { color: var(--primary); }
.project-card-description {
min-height: 2.6em;
margin-bottom: .75rem;
overflow: hidden;
display: -webkit-box;
color: var(--color-text-secondary);
font-size: .8125rem;
line-height: 1.6;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.modal-body:has(.task-detail-popup) { overflow-y: auto; }
.task-detail-popup { display: grid; min-height: 0; color: var(--color-text-primary); }
.task-detail-popup > p { color: var(--color-text-secondary)!important; }
.task-detail-popup strong,
.task-detail-popup h4 { color: var(--color-heading); }
html[data-theme='dark'] .projects-page .project-title-link,
html[data-theme='dark'] .projects-page .projects-table td:not(:has(.badge)):not(:has(.btn)) { color: #fff; }
html[data-theme='dark'] .projects-page .project-card-description { color: #d7dee8; }
.project-modal-form, .project-modal-form,
.compact-modal-form { display: grid; gap: 0.75rem; } .compact-modal-form { display: grid; gap: 0.75rem; }
.project-modal-form .form-group, .project-modal-form .form-group,
@ -619,6 +648,8 @@ tbody tr:hover { background: var(--surface-muted); }
.avatar-lg { width: 48px; height: 48px; font-size: 1.25rem; } .avatar-lg { width: 48px; height: 48px; font-size: 1.25rem; }
.avatar-xl { width: 64px; height: 64px; font-size: 1.5rem; } .avatar-xl { width: 64px; height: 64px; font-size: 1.5rem; }
.avatar-img { object-fit: cover; padding: 0; } .avatar-img { object-fit: cover; padding: 0; }
.kanban-assignee-avatar { overflow: hidden; }
.kanban-assignee-avatar img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }
.profile-avatar-wrap { .profile-avatar-wrap {
position: relative; position: relative;
width: 88px; width: 88px;
@ -866,6 +897,11 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.settings-page { max-width: 1480px; } .settings-page { max-width: 1480px; }
.settings-header { align-items: flex-start; } .settings-header { align-items: flex-start; }
.settings-subtitle { color: var(--gray-500); font-size: 0.875rem; margin-top: 0.25rem; } .settings-subtitle { color: var(--gray-500); font-size: 0.875rem; margin-top: 0.25rem; }
.settings-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: .75rem; flex-wrap: wrap; }
.settings-mode-switch { display: inline-flex; padding: 4px; background: var(--surface-muted); border: 1px solid var(--color-border); border-radius: 12px; }
.settings-mode-switch button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: .45rem; padding: .5rem .8rem; color: var(--color-text-secondary); border-radius: 9px; transition: var(--transition); }
.settings-mode-switch button:hover { color: var(--color-heading); background: var(--surface); }
.settings-mode-switch button.active { color: var(--text-on-primary); background: var(--primary); box-shadow: var(--shadow); }
.settings-summary { .settings-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -938,7 +974,7 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
color: var(--gray-600); color: var(--gray-600);
@ -1003,6 +1039,7 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
} }
.settings-item-title { display: grid; gap: 0.15rem; min-width: 0; } .settings-item-title { display: grid; gap: 0.15rem; min-width: 0; }
.settings-item-title strong { color: var(--gray-900); font-size: 1rem; } .settings-item-title strong { color: var(--gray-900); font-size: 1rem; }
.settings-item-title p { max-width: 72ch; color: var(--color-text-secondary); font-size: .8125rem; line-height: 1.65; }
.settings-item-title span { color: var(--gray-500); font-size: 0.78rem; overflow-wrap: anywhere; direction: ltr; text-align: right; } .settings-item-title span { color: var(--gray-500); font-size: 0.78rem; overflow-wrap: anywhere; direction: ltr; text-align: right; }
.settings-item-meta { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; justify-content: flex-end; } .settings-item-meta { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; justify-content: flex-end; }
.settings-editor-grid { .settings-editor-grid {
@ -1011,6 +1048,7 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
.settings-editor-grid.guided { grid-template-columns: minmax(0, 1fr); }
.settings-side-fields { .settings-side-fields {
background: var(--surface-muted); background: var(--surface-muted);
border: 1px solid var(--gray-100); border: 1px solid var(--gray-100);
@ -1035,13 +1073,42 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
} }
.settings-toggle button { .settings-toggle button {
min-width: 92px; min-width: 92px;
height: 38px; height: 44px;
padding: 0 0.9rem; padding: 0 0.9rem;
color: var(--gray-600); color: var(--gray-600);
border-left: 1px solid var(--gray-200); border-left: 1px solid var(--gray-200);
} }
.settings-toggle button:last-child { border-left: 0; } .settings-toggle button:last-child { border-left: 0; }
.settings-toggle button.active { background: var(--primary); color: var(--text-on-primary); } .settings-toggle button.active { background: var(--primary); color: var(--text-on-primary); }
.settings-choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: .55rem; }
.settings-choice-grid label { min-height: 44px; display: flex; align-items: center; gap: .55rem; padding: .6rem .75rem; color: var(--color-text-secondary); background: var(--surface-muted); border: 1px solid var(--color-border); border-radius: 10px; cursor: pointer; transition: var(--transition); }
.settings-choice-grid label:hover { color: var(--color-heading); border-color: color-mix(in srgb,var(--primary) 45%,var(--color-border)); }
.settings-choice-grid label.selected { color: var(--primary); background: var(--surface-active); border-color: color-mix(in srgb,var(--primary) 52%,var(--color-border)); }
.settings-choice-grid input { width: 18px; height: 18px; accent-color: var(--primary); }
.settings-choice-grid label span { flex: 1; }
.settings-smtp-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; }
.settings-smtp-grid .form-group { margin: 0; }
.settings-input-with-suffix { display: flex; align-items: center; gap: .65rem; }
.settings-input-with-suffix .form-input { max-width: 180px; }
.settings-input-with-suffix span { color: var(--color-text-secondary); font-size: .8125rem; }
.settings-technical-details { margin-top: .9rem; padding-top: .75rem; border-top: 1px solid var(--color-border); }
.settings-technical-details summary { width: fit-content; display: flex; align-items: center; gap: .4rem; color: var(--color-text-secondary); font-size: .78rem; cursor: pointer; list-style: none; }
.settings-technical-details summary::-webkit-details-marker { display: none; }
.settings-technical-details[open] summary { margin-bottom: .75rem; color: var(--primary); }
.settings-technical-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; padding: .85rem; background: var(--surface-muted); border: 1px solid var(--color-border); border-radius: 10px; }
.settings-technical-grid > .form-group:last-child { grid-column: 1/-1; }
.company-logo-editor { display: grid; grid-template-columns: 78px minmax(0,1fr); align-items: center; gap: 1rem; padding: .85rem; background: var(--surface-muted); border: 1px solid var(--color-border); border-radius: 12px; }
.company-logo-preview { width: 78px; height: 78px; display: grid; place-items: center; overflow: hidden; color: var(--color-text-secondary); background: var(--surface); border: 1px dashed var(--color-border); border-radius: 16px; }
.company-logo-preview img { width: 100%; height: 100%; object-fit: contain; }
.company-logo-copy { min-width: 0; display: grid; gap: .35rem; }
.company-logo-copy strong { color: var(--color-heading); }
.company-logo-copy > span { color: var(--color-text-secondary); font-size: .78rem; line-height: 1.6; }
.company-logo-copy > div { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .25rem; }
.settings-security-panel { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 1rem; padding: 1.25rem; color: var(--color-text-primary); background: var(--surface); border: 1px solid var(--color-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.settings-guidance-icon { width: 52px; height: 52px; display: grid; place-items: center; color: var(--primary); background: var(--surface-active); border-radius: 14px; }
.settings-security-panel strong { display: block; margin-bottom: .3rem; color: var(--color-heading); }
.settings-security-panel p { color: var(--color-text-secondary); font-size: .8125rem; line-height: 1.65; }
.settings-security-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.settings-list-editor, .settings-list-editor,
.settings-object-editor { display: grid; gap: 0.5rem; } .settings-object-editor { display: grid; gap: 0.5rem; }
.settings-list-row, .settings-list-row,
@ -1089,7 +1156,6 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
color: var(--gray-500); color: var(--gray-500);
} }
.settings-empty strong { color: var(--gray-800); } .settings-empty strong { color: var(--gray-800); }
.settings-drawer { width: 520px; }
.settings-unsaved-bar { .settings-unsaved-bar {
position: sticky; bottom: .75rem; z-index: 35; display: flex; align-items: center; justify-content: space-between; gap: 1rem; position: sticky; bottom: .75rem; z-index: 35; display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: .7rem .85rem; margin-top: .85rem; color: var(--gray-800); background: color-mix(in srgb,var(--surface) 94%,transparent); padding: .7rem .85rem; margin-top: .85rem; color: var(--gray-800); background: color-mix(in srgb,var(--surface) 94%,transparent);
@ -1098,17 +1164,6 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.settings-unsaved-bar > div { display: flex; align-items: center; gap: .55rem; } .settings-unsaved-bar > div { display: flex; align-items: center; gap: .55rem; }
.settings-unsaved-bar > div:first-child { display: grid; gap: 0; } .settings-unsaved-bar > div:first-child { display: grid; gap: 0; }
.settings-unsaved-bar span { color: var(--gray-500); font-size: .72rem; } .settings-unsaved-bar span { color: var(--gray-500); font-size: .72rem; }
.settings-drawer .drawer-body {
display: flex;
flex-direction: column;
padding-bottom: 0;
}
.settings-add-form .drawer-footer {
margin: auto -1.5rem 0;
position: sticky;
bottom: 0;
background: var(--surface);
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.settings-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .settings-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@ -1128,6 +1183,17 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.settings-object-row { grid-template-columns: 1fr; } .settings-object-row { grid-template-columns: 1fr; }
.settings-search { width: 100%; } .settings-search { width: 100%; }
.settings-item-meta { justify-content: flex-start; } .settings-item-meta { justify-content: flex-start; }
.settings-header-actions,
.settings-mode-switch,
.settings-security-actions { width: 100%; }
.settings-mode-switch button,
.settings-security-actions .btn { flex: 1; }
.settings-smtp-grid,
.settings-technical-grid,
.settings-security-panel { grid-template-columns: 1fr; }
.settings-technical-grid > .form-group:last-child { grid-column: auto; }
.company-logo-editor { grid-template-columns: 1fr; }
.settings-guidance-icon { width: 44px; height: 44px; }
} }
.access-page { max-width: 1480px; } .access-page { max-width: 1480px; }
@ -1475,6 +1541,7 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.header-search-results small { color: var(--gray-500); } .header-search-results small { color: var(--gray-500); }
.header-actions { display: flex; align-items: center; justify-content: flex-end; gap: .5rem; min-width: 0; } .header-actions { display: flex; align-items: center; justify-content: flex-end; gap: .5rem; min-width: 0; }
.mobile-nav-toggle { display: none; } .mobile-nav-toggle { display: none; }
.primary-nav-toggle { display: inline-flex; }
.mobile-sidebar-overlay { display: none; } .mobile-sidebar-overlay { display: none; }
.sidebar-mobile-close { display: none; } .sidebar-mobile-close { display: none; }
.header-layer { position: relative; } .header-layer { position: relative; }
@ -1751,6 +1818,9 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.workload-row > div > span { height: 5px; overflow: hidden; background: var(--gray-100); border-radius: 99px; }.workload-row i { display: block; height: 100%; background: var(--success); border-radius: inherit; } .workload-row > div > span { height: 5px; overflow: hidden; background: var(--gray-100); border-radius: 99px; }.workload-row i { display: block; height: 100%; background: var(--success); border-radius: inherit; }
.workload-row i.high { background: var(--warning); }.workload-row i.overloaded { background: var(--danger); }.workload-row b { color: var(--gray-900); font-variant-numeric: tabular-nums; } .workload-row i.high { background: var(--warning); }.workload-row i.overloaded { background: var(--danger); }.workload-row b { color: var(--gray-900); font-variant-numeric: tabular-nums; }
.workload-row small { grid-column: 2/-1; color: var(--danger); font-size: .65rem; } .workload-row small { grid-column: 2/-1; color: var(--danger); font-size: .65rem; }
.team-performance-chart { direction: ltr; }
.team-performance-chart .recharts-yAxis .recharts-cartesian-axis-tick-value { fill: var(--color-text-primary); font-family: inherit; }
.team-performance-chart .recharts-cartesian-grid line { stroke: var(--color-border); }
.upcoming-meeting-list > a { min-height: 54px; display: flex; align-items: center; gap: .65rem; padding: .45rem; border-radius: 9px; } .upcoming-meeting-list > a { min-height: 54px; display: flex; align-items: center; gap: .65rem; padding: .45rem; border-radius: 9px; }
.upcoming-meeting-list > a:hover { background: var(--gray-50); }.meeting-date { min-width: 78px; display: grid; padding: .35rem .45rem; color: var(--primary); background: var(--surface-active); border-radius: 8px; text-align: center; } .upcoming-meeting-list > a:hover { background: var(--gray-50); }.meeting-date { min-width: 78px; display: grid; padding: .35rem .45rem; color: var(--primary); background: var(--surface-active); border-radius: 8px; text-align: center; }
.meeting-date b { font-size: .7rem; }.meeting-date small { font-size: .65rem; }.upcoming-meeting-list > a > div { min-width: 0; display: grid; } .meeting-date b { font-size: .7rem; }.meeting-date small { font-size: .65rem; }.upcoming-meeting-list > a > div { min-width: 0; display: grid; }
@ -1802,6 +1872,31 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
.workspace-board article strong { color: var(--gray-900); }.workspace-board article small { color: var(--gray-500); } .workspace-board article strong { color: var(--gray-900); }.workspace-board article small { color: var(--gray-500); }
.workspace-collection { display: grid; gap: .4rem; }.workspace-data-row { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .55rem .65rem; border: 1px solid var(--gray-100); border-radius: 9px; } .workspace-collection { display: grid; gap: .4rem; }.workspace-data-row { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .55rem .65rem; border: 1px solid var(--gray-100); border-radius: 9px; }
.workspace-data-row:hover { background: var(--gray-50); }.workspace-data-row > div { min-width: 0; display: grid; }.workspace-data-row strong { overflow: hidden; color: var(--gray-900); text-overflow: ellipsis; white-space: nowrap; }.workspace-data-row small { color: var(--gray-500); } .workspace-data-row:hover { background: var(--gray-50); }.workspace-data-row > div { min-width: 0; display: grid; }.workspace-data-row strong { overflow: hidden; color: var(--gray-900); text-overflow: ellipsis; white-space: nowrap; }.workspace-data-row small { color: var(--gray-500); }
.meeting-effectiveness-card { gap: 1rem; }
.effectiveness-summary { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: .65rem; }
.effectiveness-summary > div { display: grid; gap: .25rem; padding: .75rem; background: var(--gray-50); border: 1px solid var(--gray-100); border-radius: 10px; }
.effectiveness-summary span { color: var(--gray-500); font-size: .72rem; }
.effectiveness-summary strong { color: var(--gray-900); font-size: .82rem; }
.effectiveness-form { display: grid; gap: 1rem; padding-top: .9rem; border-top: 1px solid var(--gray-100); }
.effectiveness-form > p { color: var(--gray-500); font-size: .82rem; line-height: 1.7; }
.effectiveness-rating-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: .65rem; }
.effectiveness-rating-grid fieldset { min-width: 0; padding: .7rem; border: 1px solid var(--gray-100); border-radius: 10px; }
.effectiveness-rating-grid legend { padding-inline: .3rem; color: var(--gray-700); font-size: .75rem; }
.effectiveness-stars { display: flex; align-items: center; justify-content: center; gap: .15rem; direction: ltr; }
.effectiveness-stars button { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; color: var(--gray-300); background: transparent; border: 0; border-radius: 7px; cursor: pointer; }
.effectiveness-stars button:hover,
.effectiveness-stars button:focus-visible { color: var(--warning); background: var(--gray-50); outline: none; }
.effectiveness-stars button.active { color: var(--warning); }
.effectiveness-stars button.active svg { fill: currentColor; }
.effectiveness-form textarea { resize: vertical; }
@media (max-width: 1100px) {
.effectiveness-summary,
.effectiveness-rating-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
}
@media (max-width: 640px) {
.effectiveness-summary,
.effectiveness-rating-grid { grid-template-columns: 1fr; }
}
.metrics-placeholder { display: grid; grid-template-columns: repeat(3,auto 1fr); align-items: center; gap: .75rem; padding: 1.5rem; }.metrics-placeholder strong { color: var(--primary); font-size: 1.4rem; }.metrics-placeholder span { color: var(--gray-500); } .metrics-placeholder { display: grid; grid-template-columns: repeat(3,auto 1fr); align-items: center; gap: .75rem; padding: 1.5rem; }.metrics-placeholder strong { color: var(--primary); font-size: 1.4rem; }.metrics-placeholder span { color: var(--gray-500); }
.workspace-loading { display: grid; grid-template-columns: repeat(3,1fr); gap: .75rem; }.workspace-loading .skeleton { min-height: 130px; border-radius: 14px; } .workspace-loading { display: grid; grid-template-columns: repeat(3,1fr); gap: .75rem; }.workspace-loading .skeleton { min-height: 130px; border-radius: 14px; }
.meeting-status { min-height: 30px; display: inline-flex; align-items: center; padding: 0 .6rem; border-radius: 999px; color: #dbeafe; background: rgba(59,130,246,.2); font-size: .72rem; } .meeting-status { min-height: 30px; display: inline-flex; align-items: center; padding: 0 .6rem; border-radius: 999px; color: #dbeafe; background: rgba(59,130,246,.2); font-size: .72rem; }

مشاهده پرونده

@ -12,6 +12,11 @@ const faDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
minute: '2-digit', minute: '2-digit',
}); });
const faMonthFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: 'long',
});
export function formatJalaliDate(value, fallback = '—') { export function formatJalaliDate(value, fallback = '—') {
if (!value) return fallback; if (!value) return fallback;
const date = new Date(value); const date = new Date(value);
@ -26,6 +31,14 @@ export function formatJalaliDateTime(value, fallback = '—') {
return faDateTimeFormatter.format(date); return faDateTimeFormatter.format(date);
} }
export function formatJalaliMonth(value, fallback = '—') {
if (!value) return fallback;
const normalized = /^\d{4}-\d{2}$/.test(String(value)) ? `${value}-15T12:00:00` : value;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return fallback;
return faMonthFormatter.format(date);
}
export function isPastDate(value) { export function isPastDate(value) {
if (!value) return false; if (!value) return false;
const date = new Date(value); const date = new Date(value);

67
start-dev.bat normal فایل
مشاهده پرونده

@ -0,0 +1,67 @@
@echo off
setlocal
set "PROJECT_ROOT=%~dp0"
set "BACKEND_DIR=%PROJECT_ROOT%backend"
set "FRONTEND_DIR=%PROJECT_ROOT%frontend"
if not exist "%BACKEND_DIR%\artisan" (
echo [ERROR] Backend directory or artisan file was not found.
pause
exit /b 1
)
if not exist "%FRONTEND_DIR%\package.json" (
echo [ERROR] Frontend directory or package.json was not found.
pause
exit /b 1
)
set "PHP_EXE="
for /f "delims=" %%P in ('where php 2^>nul') do if not defined PHP_EXE set "PHP_EXE=%%P"
if not defined PHP_EXE (
for /d %%D in ("%LOCALAPPDATA%\Microsoft\WinGet\Packages\PHP.PHP.8.4_*") do (
if exist "%%~fD\php.exe" set "PHP_EXE=%%~fD\php.exe"
)
)
if not defined PHP_EXE (
echo [ERROR] PHP is not available in PATH.
echo Install PHP 8.4 with: winget install --id PHP.PHP.8.4 --exact
pause
exit /b 1
)
for %%I in ("%PHP_EXE%") do set "PHP_DIR=%%~dpI"
set "PHP_EXT_DIR=%PHP_DIR%ext"
set "PHPRC=%BACKEND_DIR%"
set "PATH=%PHP_DIR%;%PATH%"
where npm.cmd >nul 2>&1
if errorlevel 1 (
echo [ERROR] npm is not available in PATH.
pause
exit /b 1
)
if not exist "%BACKEND_DIR%\vendor\autoload.php" (
echo [ERROR] Backend dependencies are missing. Run: composer install
pause
exit /b 1
)
if not exist "%FRONTEND_DIR%\node_modules" (
echo [ERROR] Frontend dependencies are missing. Run: npm install
pause
exit /b 1
)
echo Starting backend at http://127.0.0.1:8000 ...
start "PM Backend" /D "%BACKEND_DIR%\public" cmd /k ""%PHP_EXE%" -S 127.0.0.1:8000 ..\vendor\laravel\framework\src\Illuminate\Foundation\resources\server.php"
echo Starting frontend ...
start "PM Frontend" /D "%FRONTEND_DIR%" cmd /k "npm run dev"
echo Backend and frontend were started in separate windows.
endlocal