feat-project-workflow-updates
مخزن کامیت contained در
والد
4b9621f41f
کامیت
bb30ac6f30
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,22 +117,28 @@ 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="در حال انجام" />
|
||||||
<Bar dataKey="delayed" fill="#ef4444" name="عقب افتاده" />
|
<Bar dataKey="delayed" fill="#ef4444" name="عقب افتاده" />
|
||||||
</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)}>×</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} />
|
||||||
|
|
@ -241,9 +235,8 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
بارگذاری…
مرجع در شماره جدید