harden access controls and prepare mysql launch
|
|
@ -0,0 +1,60 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: mbstring, pdo_sqlite, pdo_mysql
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist --no-progress
|
||||
- run: composer validate --strict --no-check-publish
|
||||
- run: vendor/bin/pint --test
|
||||
- run: php artisan test
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx oxlint src
|
||||
- run: npm run build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run test:e2e
|
||||
|
||||
dependency-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: composer audit --locked --working-dir=backend
|
||||
- run: npm audit --prefix frontend --audit-level=high
|
||||
|
|
@ -27,11 +27,23 @@ DB_CONNECTION=sqlite
|
|||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
# Controlled SQLite -> MySQL migration (keep separate from the active connection)
|
||||
MIGRATION_SQLITE_PATH=
|
||||
MIGRATION_MYSQL_HOST=127.0.0.1
|
||||
MIGRATION_MYSQL_PORT=3306
|
||||
MIGRATION_MYSQL_DATABASE=pm_migration
|
||||
MIGRATION_MYSQL_USERNAME=
|
||||
MIGRATION_MYSQL_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
SANCTUM_TOKEN_EXPIRATION=480
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:5173,127.0.0.1,127.0.0.1:8000
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class TransferSqliteToMysql extends Command
|
||||
{
|
||||
protected $signature = 'db:transfer-sqlite-to-mysql
|
||||
{--execute : Perform writes; without this flag the command is a dry run}
|
||||
{--truncate : Empty transferable target tables before importing}
|
||||
{--chunk=250 : Rows processed per batch}';
|
||||
|
||||
protected $description = 'Idempotently transfer application data from SQLite to a separately configured MySQL database';
|
||||
|
||||
private const EXCLUDED_TABLES = [
|
||||
'migrations',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'password_reset_tokens',
|
||||
'sessions',
|
||||
'personal_access_tokens',
|
||||
];
|
||||
|
||||
private const NATURAL_KEYS = [
|
||||
'role_user' => ['role_id', 'user_id'],
|
||||
'permission_role' => ['permission_id', 'role_id'],
|
||||
'project_user' => ['project_id', 'user_id'],
|
||||
'sprint_task' => ['sprint_id', 'task_id'],
|
||||
'sprint_members' => ['sprint_id', 'user_id'],
|
||||
'meeting_user' => ['meeting_id', 'user_id'],
|
||||
'department_user' => ['department_id', 'user_id'],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sourcePath = config('database.connections.migration_sqlite.database');
|
||||
if (! is_string($sourcePath) || ! is_file($sourcePath)) {
|
||||
$this->error('SQLite source file does not exist.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (config('database.connections.migration_mysql.driver') !== 'mysql') {
|
||||
$this->error('The migration target must use the mysql driver.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$source = DB::connection('migration_sqlite');
|
||||
$target = DB::connection('migration_mysql');
|
||||
|
||||
try {
|
||||
$target->select('SELECT 1');
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('MySQL target connection failed: '.$exception->getCode());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tables = $this->transferableTables($source);
|
||||
if ($tables === []) {
|
||||
$this->warn('No transferable tables were found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(['Mode', 'Source', 'Target', 'Tables'], [[
|
||||
$this->option('execute') ? 'EXECUTE' : 'DRY RUN',
|
||||
basename($sourcePath),
|
||||
(string) config('database.connections.migration_mysql.database'),
|
||||
count($tables),
|
||||
]]);
|
||||
|
||||
if (! $this->option('execute')) {
|
||||
foreach ($tables as $table) {
|
||||
$this->line(sprintf('%s: %d rows', $table, $source->table($table)->count()));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$chunkSize = max(10, min((int) $this->option('chunk'), 2000));
|
||||
Schema::connection('migration_mysql')->disableForeignKeyConstraints();
|
||||
|
||||
try {
|
||||
if ($this->option('truncate')) {
|
||||
foreach (array_reverse($tables) as $table) {
|
||||
$target->table($table)->truncate();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$this->transferTable($source, $target, $table, $chunkSize);
|
||||
}
|
||||
} finally {
|
||||
Schema::connection('migration_mysql')->enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
$failed = false;
|
||||
foreach ($tables as $table) {
|
||||
$sourceCount = $source->table($table)->count();
|
||||
$targetCount = $target->table($table)->count();
|
||||
$sourceDigest = $this->tableDigest($source, $table);
|
||||
$targetDigest = $this->tableDigest($target, $table);
|
||||
$match = $sourceCount === $targetCount && hash_equals($sourceDigest, $targetDigest);
|
||||
$failed = $failed || ! $match;
|
||||
$this->line(sprintf(
|
||||
'%s source=%d target=%d content=%s %s',
|
||||
$table,
|
||||
$sourceCount,
|
||||
$targetCount,
|
||||
hash_equals($sourceDigest, $targetDigest) ? 'MATCH' : 'MISMATCH',
|
||||
$match ? 'OK' : 'MISMATCH'
|
||||
));
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
$this->error('Transfer completed with count mismatches.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Transfer and row-count validation completed successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function transferableTables(ConnectionInterface $source): array
|
||||
{
|
||||
$sourceTables = collect($source->select(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
))->pluck('name');
|
||||
$targetTables = collect(Schema::connection('migration_mysql')->getTableListing())
|
||||
->map(fn (string $table) => str_contains($table, '.') ? str($table)->afterLast('.')->toString() : $table);
|
||||
|
||||
$missing = $sourceTables->diff($targetTables)->reject(
|
||||
fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true)
|
||||
);
|
||||
if ($missing->isNotEmpty()) {
|
||||
throw new RuntimeException('Target schema is missing tables: '.$missing->implode(', '));
|
||||
}
|
||||
|
||||
return $sourceTables
|
||||
->intersect($targetTables)
|
||||
->reject(fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function transferTable(
|
||||
ConnectionInterface $source,
|
||||
ConnectionInterface $target,
|
||||
string $table,
|
||||
int $chunkSize
|
||||
): void {
|
||||
$columns = collect(Schema::connection('migration_mysql')->getColumnListing($table));
|
||||
$keys = $this->keyColumns($source, $table);
|
||||
$orderColumn = $keys[0] ?? $columns->first();
|
||||
$processed = 0;
|
||||
|
||||
$source->table($table)
|
||||
->orderBy($orderColumn)
|
||||
->chunk($chunkSize, function ($rows) use ($target, $table, $columns, $keys, &$processed) {
|
||||
$payload = collect($rows)->map(
|
||||
fn ($row) => collect((array) $row)->only($columns)->all()
|
||||
)->all();
|
||||
|
||||
if ($payload === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($keys !== []) {
|
||||
$target->table($table)->upsert($payload, $keys, $columns->diff($keys)->all());
|
||||
} else {
|
||||
foreach ($payload as $row) {
|
||||
$target->table($table)->updateOrInsert($row, $row);
|
||||
}
|
||||
}
|
||||
$processed += count($payload);
|
||||
});
|
||||
|
||||
$this->info(sprintf('%s: %d rows transferred', $table, $processed));
|
||||
}
|
||||
|
||||
private function keyColumns(ConnectionInterface $source, string $table): array
|
||||
{
|
||||
if (isset(self::NATURAL_KEYS[$table])) {
|
||||
return self::NATURAL_KEYS[$table];
|
||||
}
|
||||
|
||||
return collect($source->select("PRAGMA table_info('".str_replace("'", "''", $table)."')"))
|
||||
->filter(fn ($column) => (int) $column->pk > 0)
|
||||
->sortBy(fn ($column) => (int) $column->pk)
|
||||
->pluck('name')
|
||||
->all();
|
||||
}
|
||||
|
||||
private function tableDigest(ConnectionInterface $connection, string $table): string
|
||||
{
|
||||
$columns = Schema::connection($connection->getName())->getColumnListing($table);
|
||||
sort($columns);
|
||||
$order = $this->keyColumns(DB::connection('migration_sqlite'), $table);
|
||||
if ($order === []) {
|
||||
$order = $columns;
|
||||
}
|
||||
|
||||
$query = $connection->table($table)->select($columns);
|
||||
foreach ($order as $column) {
|
||||
$query->orderBy($column);
|
||||
}
|
||||
|
||||
$context = hash_init('sha256');
|
||||
foreach ($query->cursor() as $row) {
|
||||
$normalized = [];
|
||||
foreach ($columns as $column) {
|
||||
$normalized[$column] = $this->normalizeValue($row->{$column});
|
||||
}
|
||||
hash_update($context, json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n");
|
||||
}
|
||||
|
||||
return hash_final($context);
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$string = (string) $value;
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} 00:00:00$/', $string)) {
|
||||
return substr($string, 0, 10);
|
||||
}
|
||||
|
||||
$decoded = json_decode($string, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && (str_starts_with($string, '{') || str_starts_with($string, '['))) {
|
||||
return $this->normalizeJson($decoded);
|
||||
}
|
||||
|
||||
if (preg_match('/^-?\d+\.\d+$/', $string)) {
|
||||
return rtrim(rtrim($string, '0'), '.');
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
private function normalizeJson(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! array_is_list($value)) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
return array_map(fn ($item) => $this->normalizeJson($item), $value);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,18 +3,21 @@
|
|||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Http\Requests\ChangePasswordRequest;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Http\Requests\UpdateProfileRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
|
|
@ -195,10 +198,12 @@ class AuthController extends Controller
|
|||
}
|
||||
|
||||
$user->update(['password' => Hash::make($request->new_password)]);
|
||||
$user->tokens()->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'رمز عبور با موفقیت تغییر یافت',
|
||||
'reauthentication_required' => true,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
|
|
@ -210,9 +215,45 @@ class AuthController extends Controller
|
|||
|
||||
public function forgotPassword(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email']);
|
||||
Password::sendResetLink(['email' => strtolower($data['email'])]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'لینک بازیابی رمز عبور به ایمیل شما ارسال شد',
|
||||
'message' => 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی ارسال میشود.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'token' => 'required|string',
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string|min:12|confirmed',
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
$data,
|
||||
function (User $user, string $password) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
$user->tokens()->delete();
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status !== Password::PASSWORD_RESET) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'توکن بازیابی نامعتبر یا منقضی شده است.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'رمز عبور تغییر کرد. دوباره وارد شوید.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,24 @@ use App\Http\Resources\BacklogItemResource;
|
|||
use App\Http\Resources\TaskResource;
|
||||
use App\Models\BacklogItem;
|
||||
use App\Models\Task;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BacklogController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = BacklogItem::with(['project', 'assignedSprint', 'creator']);
|
||||
if (! $this->resourceAccess->canSeeAll($request->user())) {
|
||||
$query->where(function ($scope) use ($request) {
|
||||
$scope->where('created_by', $request->user()->id)
|
||||
->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id'));
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('project_id')) {
|
||||
$query->where('project_id', $request->project_id);
|
||||
|
|
@ -31,8 +40,10 @@ class BacklogController extends Controller
|
|||
$query->where('priority', $request->priority);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 15);
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$perPage = min(max((int) $request->input('per_page', 15), 1), 100);
|
||||
$sortBy = in_array($request->input('sort_by'), ['id', 'title', 'priority', 'status', 'created_at', 'updated_at'], true)
|
||||
? $request->input('sort_by')
|
||||
: 'created_at';
|
||||
$sortDir = $request->input('sort_dir', 'desc');
|
||||
$items = $query->orderBy($sortBy, $sortDir)->paginate($perPage);
|
||||
|
||||
|
|
@ -77,6 +88,13 @@ class BacklogController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $request->validated();
|
||||
if (! empty($data['project_id'])) {
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
}
|
||||
$data['created_by'] = $request->user()->id;
|
||||
$item = BacklogItem::create($data);
|
||||
$item->load(['project', 'assignedSprint', 'creator']);
|
||||
|
|
@ -109,7 +127,7 @@ class BacklogController extends Controller
|
|||
|
||||
$backlogItem->update($request->only([
|
||||
'title', 'description', 'type', 'priority',
|
||||
'estimated_effort', 'status', 'assigned_sprint_id'
|
||||
'estimated_effort', 'status', 'assigned_sprint_id',
|
||||
]));
|
||||
$backlogItem->load(['project', 'assignedSprint', 'creator']);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CalendarService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CalendarService $calendarService) {}
|
||||
|
||||
public function events(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from' => 'nullable|date',
|
||||
'to' => 'nullable|date|after_or_equal:from',
|
||||
'types' => 'nullable|array',
|
||||
'types.*' => 'string|in:meeting,task,sprint,project,action_item',
|
||||
]);
|
||||
|
||||
$from = Carbon::parse($data['from'] ?? now()->startOfMonth())->startOfDay();
|
||||
$to = Carbon::parse($data['to'] ?? now()->endOfMonth())->endOfDay();
|
||||
|
||||
if ($from->diffInDays($to) > 93) {
|
||||
return response()->json(['success' => false, 'message' => 'بازه تقویم نمیتواند بیشتر از ۹۳ روز باشد.'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->calendarService->events($request->user(), $from, $to, $data['types'] ?? []),
|
||||
'meta' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
|
||||
'message' => 'رویدادهای تقویم',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,13 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\StoreCommentRequest;
|
||||
use App\Http\Resources\CommentResource;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLogService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -17,12 +21,13 @@ class CommentController extends Controller
|
|||
public function __construct(
|
||||
protected ActivityLogService $activityLogService,
|
||||
protected NotificationService $notificationService,
|
||||
protected ResourceAccessService $resourceAccess,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Comment::with(['user', 'files']);
|
||||
$query = $this->resourceAccess->comments($request->user())->with(['user', 'files']);
|
||||
|
||||
if ($request->filled('commentable_type')) {
|
||||
$query->where('commentable_type', $request->commentable_type);
|
||||
|
|
@ -57,6 +62,13 @@ class CommentController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $request->validated();
|
||||
$allowed = match ($data['commentable_type']) {
|
||||
Project::class => $this->resourceAccess->projects($request->user())->whereKey($data['commentable_id'])->exists(),
|
||||
Task::class => $this->resourceAccess->tasks($request->user())->whereKey($data['commentable_id'])->exists(),
|
||||
Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($data['commentable_id'])->exists(),
|
||||
default => false,
|
||||
};
|
||||
abort_unless($allowed, 403, 'شما به محل انتخابشده برای نظر دسترسی ندارید.');
|
||||
$data['user_id'] = $request->user()->id;
|
||||
$comment = Comment::create($data);
|
||||
$comment->load('user');
|
||||
|
|
|
|||
|
|
@ -46,4 +46,22 @@ class DashboardController extends Controller
|
|||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function monitoring(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->dashboardService->getMonitoring($request->user()),
|
||||
'message' => 'مرکز مانیتورینگ',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'خطا در دریافت اطلاعات مانیتورینگ',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class DepartmentController extends Controller
|
|||
private function formatNode($dept, $all): array
|
||||
{
|
||||
$children = $all->where('parent_id', $dept->id)->values();
|
||||
|
||||
return [
|
||||
'id' => $dept->id,
|
||||
'name' => $dept->name,
|
||||
|
|
@ -117,6 +118,7 @@ class DepartmentController extends Controller
|
|||
{
|
||||
try {
|
||||
$department->load(['manager', 'parent', 'children']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $department,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\FileResource;
|
||||
use App\Models\Comment;
|
||||
use App\Models\File;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -14,10 +18,12 @@ use Illuminate\Validation\ValidationException;
|
|||
|
||||
class FileController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = File::with('user');
|
||||
$query = $this->resourceAccess->files($request->user())->with('user');
|
||||
|
||||
if ($request->filled('fileable_type')) {
|
||||
$query->where('fileable_type', $request->fileable_type);
|
||||
|
|
@ -73,6 +79,15 @@ class FileController extends Controller
|
|||
], 422);
|
||||
}
|
||||
|
||||
$allowed = match ($fileableType) {
|
||||
Project::class => $this->resourceAccess->projects($request->user())->whereKey($fileableId)->exists(),
|
||||
Task::class => $this->resourceAccess->tasks($request->user())->whereKey($fileableId)->exists(),
|
||||
Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($fileableId)->exists(),
|
||||
Comment::class => $this->resourceAccess->comments($request->user())->whereKey($fileableId)->exists(),
|
||||
default => false,
|
||||
};
|
||||
abort_unless($allowed, 403, 'شما به محل انتخابشده برای فایل دسترسی ندارید.');
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
$originalName = $uploadedFile->getClientOriginalName();
|
||||
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
||||
|
|
|
|||
|
|
@ -4,25 +4,34 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreMeetingRequest;
|
||||
use App\Http\Resources\MeetingResource;
|
||||
use App\Http\Resources\MeetingActionItemResource;
|
||||
use App\Http\Resources\MeetingResource;
|
||||
use App\Http\Resources\TaskResource;
|
||||
use App\Models\ActionItem;
|
||||
use App\Models\Blocker;
|
||||
use App\Models\Decision;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\MeetingActionItem;
|
||||
use App\Models\MeetingType;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeetingController extends Controller
|
||||
{
|
||||
public function __construct(protected NotificationService $notificationService) {}
|
||||
public function __construct(
|
||||
protected NotificationService $notificationService,
|
||||
protected ResourceAccessService $resourceAccess,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Meeting::with(['project', 'creator', 'participants']);
|
||||
$query = $this->resourceAccess->meetings($request->user())
|
||||
->with(['project', 'sprint', 'type', 'creator', 'participants']);
|
||||
|
||||
if ($request->filled('project_id')) {
|
||||
$query->where('project_id', $request->project_id);
|
||||
|
|
@ -36,6 +45,12 @@ class MeetingController extends Controller
|
|||
if ($request->filled('meeting_type')) {
|
||||
$query->where('meeting_type', $request->meeting_type);
|
||||
}
|
||||
if ($request->filled('sprint_id')) {
|
||||
$query->where('sprint_id', $request->sprint_id);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 15);
|
||||
$sortBy = $request->input('sort_by', 'date');
|
||||
|
|
@ -64,7 +79,11 @@ class MeetingController extends Controller
|
|||
public function show(Meeting $meeting): JsonResponse
|
||||
{
|
||||
try {
|
||||
$meeting->load(['project', 'creator', 'participants', 'actionItems.assignee']);
|
||||
$meeting->load([
|
||||
'project', 'sprint', 'type', 'creator', 'owner', 'facilitator',
|
||||
'participants', 'actionItems.assignee', 'structuredDecisions',
|
||||
'structuredActionItems', 'blockers', 'files',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
|
@ -83,9 +102,17 @@ class MeetingController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $request->validated();
|
||||
if (! empty($data['project_id'])) {
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
}
|
||||
$data['created_by'] = $request->user()->id;
|
||||
$data['owner_id'] ??= $request->user()->id;
|
||||
$meeting = Meeting::create($data);
|
||||
$meeting->load(['project', 'creator']);
|
||||
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
|
@ -103,24 +130,43 @@ class MeetingController extends Controller
|
|||
public function update(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
$data = $request->validate([
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
'date' => 'sometimes|required|date',
|
||||
'start_time' => 'nullable',
|
||||
'end_time' => 'nullable',
|
||||
'meeting_type' => 'sometimes|required|string|max:50',
|
||||
'meeting_type_id' => 'nullable|exists:meeting_types,id',
|
||||
'project_id' => 'nullable|exists:projects,id',
|
||||
'sprint_id' => 'nullable|exists:sprints,id',
|
||||
'status' => 'sometimes|string|in:draft,scheduled,in_progress,completed,cancelled,postponed',
|
||||
'location' => 'nullable|string',
|
||||
'meeting_link' => 'nullable|string',
|
||||
'meeting_link' => 'nullable|url',
|
||||
'objective' => 'nullable|string',
|
||||
'agenda' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'summary' => 'nullable|string',
|
||||
'decisions' => 'nullable|string',
|
||||
'owner_id' => 'nullable|exists:users,id',
|
||||
'facilitator_id' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0|max:10080',
|
||||
'recurrence_rule' => 'nullable|array',
|
||||
]);
|
||||
if (isset($data['project_id'])) {
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
}
|
||||
|
||||
$meeting->update($request->only([
|
||||
'title', 'date', 'start_time', 'end_time', 'meeting_type',
|
||||
'location', 'meeting_link', 'agenda', 'notes', 'decisions'
|
||||
]));
|
||||
$meeting->load(['project', 'creator']);
|
||||
$meeting->update(collect($data)->only([
|
||||
'title', 'project_id', 'sprint_id', 'date', 'start_time', 'end_time',
|
||||
'meeting_type', 'meeting_type_id', 'status', 'location', 'meeting_link',
|
||||
'objective', 'agenda', 'notes', 'summary', 'decisions', 'owner_id',
|
||||
'facilitator_id', 'reminder_minutes', 'recurrence_rule',
|
||||
])->all());
|
||||
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner', 'facilitator']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
|
@ -277,6 +323,11 @@ class MeetingController extends Controller
|
|||
$request->validate([
|
||||
'project_id' => 'required|exists:projects,id',
|
||||
]);
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($request->project_id)->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
|
||||
$task = Task::create([
|
||||
'title' => $actionItem->title,
|
||||
|
|
@ -303,4 +354,131 @@ class MeetingController extends Controller
|
|||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function workspace(Meeting $meeting): JsonResponse
|
||||
{
|
||||
$meeting->load([
|
||||
'project', 'sprint', 'type', 'creator', 'owner', 'facilitator',
|
||||
'participants', 'actionItems.assignee', 'structuredDecisions',
|
||||
'structuredActionItems', 'blockers', 'files',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new MeetingResource($meeting),
|
||||
'message' => 'فضای کاری جلسه',
|
||||
]);
|
||||
}
|
||||
|
||||
public function start(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
if (! in_array($meeting->status, ['draft', 'scheduled', 'postponed'], true)) {
|
||||
return response()->json(['success' => false, 'message' => 'این جلسه قابل شروع نیست'], 422);
|
||||
}
|
||||
|
||||
$meeting->update(['status' => 'in_progress', 'started_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new MeetingResource($meeting->fresh()),
|
||||
'message' => 'جلسه شروع شد',
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'summary' => 'nullable|string',
|
||||
'force' => 'nullable|boolean',
|
||||
]);
|
||||
if ($meeting->status !== 'in_progress') {
|
||||
return response()->json(['success' => false, 'message' => 'فقط جلسه در حال برگزاری قابل تکمیل است'], 422);
|
||||
}
|
||||
if (blank($data['summary'] ?? $meeting->summary) && ! $request->boolean('force')) {
|
||||
return response()->json(['success' => false, 'message' => 'برای تکمیل جلسه، خلاصه را ثبت کنید یا تأیید ویژه بدهید.'], 422);
|
||||
}
|
||||
|
||||
$meeting->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'summary' => $data['summary'] ?? $meeting->summary,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new MeetingResource($meeting->fresh()),
|
||||
'message' => 'جلسه تکمیل شد',
|
||||
]);
|
||||
}
|
||||
|
||||
public function addDecision(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'status' => 'nullable|in:proposed,pending,approved,rejected,reversed,superseded',
|
||||
'owner_id' => 'nullable|exists:users,id',
|
||||
'rationale' => 'nullable|string',
|
||||
'impact' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$decision = Decision::create($data + [
|
||||
'meeting_id' => $meeting->id,
|
||||
'sprint_id' => $meeting->sprint_id,
|
||||
'project_id' => $meeting->project_id,
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $decision, 'message' => 'تصمیم ثبت شد'], 201);
|
||||
}
|
||||
|
||||
public function addStructuredActionItem(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'owner_id' => 'required|exists:users,id',
|
||||
'due_date' => 'required|date',
|
||||
'priority' => 'nullable|in:low,medium,high,urgent',
|
||||
]);
|
||||
|
||||
$item = ActionItem::create($data + [
|
||||
'meeting_id' => $meeting->id,
|
||||
'sprint_id' => $meeting->sprint_id,
|
||||
'project_id' => $meeting->project_id,
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $item, 'message' => 'اقدام ثبت شد'], 201);
|
||||
}
|
||||
|
||||
public function addBlocker(Request $request, Meeting $meeting): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'severity' => 'nullable|in:low,medium,high,critical',
|
||||
'owner_id' => 'nullable|exists:users,id',
|
||||
'task_id' => 'nullable|exists:tasks,id',
|
||||
'due_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$blocker = Blocker::create($data + [
|
||||
'meeting_id' => $meeting->id,
|
||||
'sprint_id' => $meeting->sprint_id,
|
||||
'project_id' => $meeting->project_id,
|
||||
'raised_by' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $blocker, 'message' => 'مانع ثبت شد'], 201);
|
||||
}
|
||||
|
||||
public function types(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => MeetingType::where('is_active', true)->orderBy('name')->get(),
|
||||
'message' => 'انواع جلسه',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ class NotificationController extends Controller
|
|||
{
|
||||
try {
|
||||
$query = $request->user()->notifications();
|
||||
$archived = $request->boolean('archived');
|
||||
$query->when($archived, fn ($q) => $q->whereNotNull('archived_at'))
|
||||
->when(! $archived, fn ($q) => $q->whereNull('archived_at'));
|
||||
if ($request->boolean('unread')) {
|
||||
$query->whereNull('read_at');
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 15);
|
||||
$notifications = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
||||
|
|
@ -27,7 +33,8 @@ class NotificationController extends Controller
|
|||
'last_page' => $notifications->lastPage(),
|
||||
'per_page' => $notifications->perPage(),
|
||||
'total' => $notifications->total(),
|
||||
'unread_count' => $request->user()->notifications()->whereNull('read_at')->count(),
|
||||
'unread_count' => $request->user()->notifications()->whereNull('archived_at')->whereNull('read_at')->count(),
|
||||
'archived_count' => $request->user()->notifications()->whereNotNull('archived_at')->count(),
|
||||
],
|
||||
'message' => 'لیست اعلانها',
|
||||
]);
|
||||
|
|
@ -107,4 +114,55 @@ class NotificationController extends Controller
|
|||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function archive(Request $request, Notification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
return response()->json(['success' => false, 'message' => 'شما اجازه آرشیو این اعلان را ندارید'], 403);
|
||||
}
|
||||
|
||||
$notification->update([
|
||||
'archived_at' => now(),
|
||||
'read_at' => $notification->read_at ?? now(),
|
||||
'is_read' => true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new NotificationResource($notification->fresh()),
|
||||
'message' => 'اعلان آرشیو شد',
|
||||
]);
|
||||
}
|
||||
|
||||
public function restore(Request $request, Notification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
return response()->json(['success' => false, 'message' => 'شما اجازه بازیابی این اعلان را ندارید'], 403);
|
||||
}
|
||||
|
||||
$notification->update(['archived_at' => null]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new NotificationResource($notification->fresh()),
|
||||
'message' => 'اعلان از آرشیو خارج شد',
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, Notification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
return response()->json(['success' => false, 'message' => 'شما اجازه مشاهده این اعلان را ندارید'], 403);
|
||||
}
|
||||
|
||||
if (! $notification->read_at) {
|
||||
$notification->update(['read_at' => now(), 'is_read' => true]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new NotificationResource($notification->fresh()),
|
||||
'message' => 'جزئیات اعلان',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Models\User;
|
|||
use App\Services\ActivityLogService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\ProjectProgressService;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -20,12 +21,14 @@ class ProjectController extends Controller
|
|||
protected ActivityLogService $activityLogService,
|
||||
protected NotificationService $notificationService,
|
||||
protected ProjectProgressService $projectProgressService,
|
||||
protected ResourceAccessService $resourceAccess,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Project::with(['projectManager', 'creator', 'department']);
|
||||
$query = $this->resourceAccess->projects($request->user())
|
||||
->with(['projectManager', 'creator', 'department']);
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@ namespace App\Http\Controllers\Api;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CommentResource;
|
||||
use App\Http\Resources\FileResource;
|
||||
use App\Models\Notification;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Comment;
|
||||
use App\Models\File;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Services\ProjectProgressService;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -22,7 +23,10 @@ use Illuminate\Validation\Rule;
|
|||
|
||||
class PwaController extends Controller
|
||||
{
|
||||
public function __construct(protected ProjectProgressService $projectProgressService) {}
|
||||
public function __construct(
|
||||
protected ProjectProgressService $projectProgressService,
|
||||
protected ResourceAccessService $resourceAccess,
|
||||
) {}
|
||||
|
||||
public function profile(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -784,18 +788,7 @@ class PwaController extends Controller
|
|||
|
||||
private function visibleTasksQuery(User $user)
|
||||
{
|
||||
if ($user->hasPermission('reports.view')) {
|
||||
return Task::query();
|
||||
}
|
||||
|
||||
$manageableProjectIds = $this->manageableProjectIds($user);
|
||||
|
||||
return Task::query()->where(function ($query) use ($user, $manageableProjectIds) {
|
||||
$query->where('assignee_id', $user->id)
|
||||
->orWhere('reporter_id', $user->id)
|
||||
->orWhere('created_by', $user->id)
|
||||
->when($manageableProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->orWhereIn('project_id', $manageableProjectIds));
|
||||
});
|
||||
return $this->resourceAccess->tasks($user);
|
||||
}
|
||||
|
||||
private function abortUnlessTaskVisible(Request $request, Task $task): void
|
||||
|
|
@ -807,15 +800,7 @@ class PwaController extends Controller
|
|||
|
||||
private function visibleProjectsQuery(User $user)
|
||||
{
|
||||
if ($user->hasPermission('reports.view')) {
|
||||
return Project::query();
|
||||
}
|
||||
|
||||
return Project::query()->where(function ($query) use ($user) {
|
||||
$query->where('project_manager_id', $user->id)
|
||||
->orWhere('created_by', $user->id)
|
||||
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id));
|
||||
});
|
||||
return $this->resourceAccess->projects($user);
|
||||
}
|
||||
|
||||
private function abortUnlessProjectVisible(Request $request, Project $project): void
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->delayedTasks($request->only([
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'status'
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'status',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -53,7 +53,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->teamPerformance($request->only([
|
||||
'date_from', 'date_to', 'user_id'
|
||||
'date_from', 'date_to', 'user_id',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -91,7 +91,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->sprintProgress($request->only([
|
||||
'project_id', 'status'
|
||||
'project_id', 'status',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -111,7 +111,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->timeEstimate($request->only([
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'status'
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'status',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -131,7 +131,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->recentActivities($request->only([
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'action'
|
||||
'date_from', 'date_to', 'project_id', 'user_id', 'action',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -151,7 +151,7 @@ class ReportController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->reportService->riskyProjects($request->only([
|
||||
'status', 'project_id'
|
||||
'status', 'project_id',
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreRoleRequest;
|
||||
use App\Http\Requests\UpdateRoleRequest;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Http\Resources\PermissionResource;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@
|
|||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\BacklogItemResource;
|
||||
use App\Http\Resources\MeetingResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TaskResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Http\Resources\MeetingResource;
|
||||
use App\Http\Resources\BacklogItemResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\BacklogItem;
|
||||
use App\Models\User;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
|
@ -25,11 +25,25 @@ class SearchController extends Controller
|
|||
$keyword = $request->q;
|
||||
$limit = $request->input('limit', 5);
|
||||
|
||||
$projects = Project::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$tasks = Task::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$users = User::where('name', 'like', "%{$keyword}%")->orWhere('email', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$meetings = Meeting::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$projects = $this->resourceAccess->projects($request->user())
|
||||
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$tasks = $this->resourceAccess->tasks($request->user())
|
||||
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$meetings = $this->resourceAccess->meetings($request->user())
|
||||
->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
|
||||
$backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%")
|
||||
->where(function ($query) use ($request) {
|
||||
$query->whereNull('project_id')
|
||||
->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id'));
|
||||
})->limit($limit)->get();
|
||||
$canViewTeam = $request->user()->loadMissing('roles.permissions')->roles->contains('name', 'admin')
|
||||
|| $request->user()->hasPermission('team.view');
|
||||
$users = $canViewTeam
|
||||
? User::where(function ($query) use ($keyword) {
|
||||
$query->where('name', 'like', "%{$keyword}%")
|
||||
->orWhere('email', 'like', "%{$keyword}%");
|
||||
})->limit($limit)->get()
|
||||
: collect();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ namespace App\Http\Controllers\Api;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\SprintResource;
|
||||
use App\Http\Resources\TaskResource;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\MeetingType;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -14,12 +18,15 @@ use Illuminate\Validation\ValidationException;
|
|||
|
||||
class SprintController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||||
|
||||
private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Sprint::with(['project', 'tasks', 'members']);
|
||||
$query = $this->resourceAccess->sprints($request->user())
|
||||
->with(['project', 'tasks', 'members']);
|
||||
|
||||
if ($request->filled('project_id')) {
|
||||
$query->where('project_id', $request->project_id);
|
||||
|
|
@ -81,15 +88,48 @@ class SprintController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->validatedSprintData($request);
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
$memberIds = $data['member_ids'] ?? [];
|
||||
$meetingSetup = $data['meeting_setup'] ?? [];
|
||||
unset($data['member_ids']);
|
||||
unset($data['meeting_setup']);
|
||||
|
||||
$data['created_by'] = $request->user()->id;
|
||||
$data['status'] = 'planning';
|
||||
|
||||
$sprint = DB::transaction(function () use ($data, $memberIds) {
|
||||
$sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) {
|
||||
$sprint = Sprint::create($data);
|
||||
$sprint->members()->sync($memberIds);
|
||||
foreach ($meetingSetup as $setup) {
|
||||
$type = MeetingType::where('key', $setup['type_key'])->where('is_active', true)->first();
|
||||
if (! $type) {
|
||||
continue;
|
||||
}
|
||||
Meeting::create([
|
||||
'title' => $setup['title'] ?? $type->default_title ?? $type->name,
|
||||
'project_id' => $sprint->project_id,
|
||||
'sprint_id' => $sprint->id,
|
||||
'meeting_type_id' => $type->id,
|
||||
'meeting_type' => $type->key,
|
||||
'date' => $setup['date'],
|
||||
'start_time' => $setup['start_time'] ?? null,
|
||||
'end_time' => $setup['end_time'] ?? null,
|
||||
'location' => $setup['location'] ?? null,
|
||||
'meeting_link' => $setup['meeting_link'] ?? null,
|
||||
'agenda' => $type->agenda_template,
|
||||
'status' => 'scheduled',
|
||||
'owner_id' => $setup['owner_id'] ?? $request->user()->id,
|
||||
'facilitator_id' => $setup['facilitator_id'] ?? null,
|
||||
'reminder_minutes' => $setup['reminder_minutes'] ?? 30,
|
||||
'recurrence_rule' => $setup['recurrence_rule'] ?? $type->recurrence_rule,
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return $sprint;
|
||||
});
|
||||
|
||||
|
|
@ -111,6 +151,13 @@ class SprintController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $this->validatedSprintData($request, true);
|
||||
if (isset($data['project_id'])) {
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
}
|
||||
$memberIds = $data['member_ids'] ?? null;
|
||||
unset($data['member_ids']);
|
||||
|
||||
|
|
@ -407,6 +454,66 @@ class SprintController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
public function workspace(Sprint $sprint): JsonResponse
|
||||
{
|
||||
$sprint->load([
|
||||
'project', 'tasks.assignee', 'members', 'retrospective',
|
||||
'meetings.type', 'meetings.participants', 'decisions', 'actionItems',
|
||||
'blockers', 'scopeChanges',
|
||||
]);
|
||||
|
||||
$tasks = $sprint->tasks;
|
||||
$total = $tasks->count();
|
||||
$completed = $tasks->where('status', 'done')->count();
|
||||
$blocked = $tasks->where('is_blocked', true)->count() + $sprint->blockers->whereNotIn('status', ['resolved', 'closed'])->count();
|
||||
$overdue = $tasks->filter(fn (Task $task) => $task->due_date?->isPast() && $task->status !== 'done')->count();
|
||||
$progress = $total > 0 ? round(($completed / $total) * 100) : 0;
|
||||
$today = Carbon::today();
|
||||
$duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1);
|
||||
$elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0);
|
||||
$expected = min(100, round(($elapsed / $duration) * 100));
|
||||
$health = 'on_track';
|
||||
if ($sprint->end_date?->isPast() && $progress < 100) {
|
||||
$health = 'off_track';
|
||||
} elseif ($blocked > 1 || $overdue > 2 || $progress + 20 < $expected) {
|
||||
$health = 'at_risk';
|
||||
}
|
||||
$upcomingMeeting = $sprint->meetings
|
||||
->whereNotIn('status', ['completed', 'cancelled'])
|
||||
->filter(fn (Meeting $meeting) => $meeting->date?->gte($today))
|
||||
->sortBy(fn (Meeting $meeting) => $meeting->date?->format('Y-m-d').' '.$meeting->start_time)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'sprint' => new SprintResource($sprint),
|
||||
'health' => [
|
||||
'status' => $health,
|
||||
'progress' => $progress,
|
||||
'expected_progress' => $expected,
|
||||
'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0),
|
||||
'open_blockers' => $blocked,
|
||||
'overdue_tasks' => $overdue,
|
||||
],
|
||||
'progress' => [
|
||||
'total_tasks' => $total,
|
||||
'completed_tasks' => $completed,
|
||||
'in_progress_tasks' => $tasks->where('status', 'in_progress')->count(),
|
||||
'blocked_tasks' => $blocked,
|
||||
'overdue_tasks' => $overdue,
|
||||
],
|
||||
'upcoming_meeting' => $upcomingMeeting,
|
||||
'meetings' => $sprint->meetings->values(),
|
||||
'decisions' => $sprint->decisions,
|
||||
'action_items' => $sprint->actionItems,
|
||||
'blockers' => $sprint->blockers,
|
||||
'scope_changes' => $sprint->scopeChanges,
|
||||
],
|
||||
'message' => 'فضای کاری Sprint',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validatedSprintData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes|required' : 'required';
|
||||
|
|
@ -420,6 +527,18 @@ class SprintController extends Controller
|
|||
'end_date' => "{$required}|date|after_or_equal:start_date",
|
||||
'member_ids' => 'nullable|array',
|
||||
'member_ids.*' => 'exists:users,id',
|
||||
'meeting_setup' => 'nullable|array',
|
||||
'meeting_setup.*.type_key' => 'required|string|exists:meeting_types,key',
|
||||
'meeting_setup.*.title' => 'nullable|string|max:255',
|
||||
'meeting_setup.*.date' => 'required|date',
|
||||
'meeting_setup.*.start_time' => 'nullable|date_format:H:i',
|
||||
'meeting_setup.*.end_time' => 'nullable|date_format:H:i|after:meeting_setup.*.start_time',
|
||||
'meeting_setup.*.location' => 'nullable|string|max:255',
|
||||
'meeting_setup.*.meeting_link' => 'nullable|url',
|
||||
'meeting_setup.*.owner_id' => 'nullable|exists:users,id',
|
||||
'meeting_setup.*.facilitator_id' => 'nullable|exists:users,id',
|
||||
'meeting_setup.*.reminder_minutes' => 'nullable|integer|min:0|max:10080',
|
||||
'meeting_setup.*.recurrence_rule' => 'nullable|array',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Models\Task;
|
|||
use App\Services\ActivityLogService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\ProjectProgressService;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -20,12 +21,15 @@ class TaskController extends Controller
|
|||
protected ActivityLogService $activityLogService,
|
||||
protected NotificationService $notificationService,
|
||||
protected ProjectProgressService $projectProgressService,
|
||||
protected ResourceAccessService $resourceAccess,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = Task::with(['project', 'assignee', 'reporter'])->withCount(['comments', 'files']);
|
||||
$query = $this->resourceAccess->tasks($request->user())
|
||||
->with(['project', 'assignee', 'reporter'])
|
||||
->withCount(['comments', 'files']);
|
||||
|
||||
if ($request->filled('project_id')) {
|
||||
$query->where('project_id', $request->project_id);
|
||||
|
|
@ -92,6 +96,11 @@ class TaskController extends Controller
|
|||
{
|
||||
try {
|
||||
$data = $request->validated();
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
$data['reporter_id'] = $request->user()->id;
|
||||
$data['created_by'] = $request->user()->id;
|
||||
$task = Task::create($data);
|
||||
|
|
@ -142,8 +151,16 @@ class TaskController extends Controller
|
|||
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validated();
|
||||
if (isset($validated['project_id'])) {
|
||||
abort_unless(
|
||||
$this->resourceAccess->projects($request->user())->whereKey($validated['project_id'])->exists(),
|
||||
403,
|
||||
'شما به پروژه انتخابشده دسترسی ندارید.'
|
||||
);
|
||||
}
|
||||
$oldStatus = $task->status;
|
||||
$task->update($request->validated());
|
||||
$task->update($validated);
|
||||
|
||||
if ($task->wasChanged('status')) {
|
||||
$this->activityLogService->log(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\StoreUserRequest;
|
||||
use App\Http\Requests\UpdateUserRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Services\WorkloadService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\BacklogItem;
|
||||
use App\Models\Checklist;
|
||||
use App\Models\Comment;
|
||||
use App\Models\File;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\MeetingActionItem;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Subtask;
|
||||
use App\Models\Task;
|
||||
use App\Services\ResourceAccessService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureResourceAccess
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $access) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$route = $request->route();
|
||||
$checks = [
|
||||
'project' => fn ($model) => $model instanceof Project && $this->access->canAccessProject($user, $model),
|
||||
'task' => fn ($model) => $model instanceof Task && $this->access->canAccessTask($user, $model),
|
||||
'sprint' => fn ($model) => $model instanceof Sprint && $this->access->canAccessSprint($user, $model),
|
||||
'meeting' => fn ($model) => $model instanceof Meeting && $this->access->canAccessMeeting($user, $model),
|
||||
'comment' => fn ($model) => $model instanceof Comment && $this->access->canAccessComment($user, $model),
|
||||
'file' => fn ($model) => $model instanceof File && $this->access->canAccessFile($user, $model),
|
||||
'backlogItem' => fn ($model) => $model instanceof BacklogItem
|
||||
&& ($this->access->canSeeAll($user)
|
||||
|| $model->created_by === $user->id
|
||||
|| ($model->project_id !== null
|
||||
&& $this->access->projects($user)->whereKey($model->project_id)->exists())),
|
||||
'checklist' => fn ($model) => $model instanceof Checklist && $this->access->tasks($user)->whereKey($model->task_id)->exists(),
|
||||
'subtask' => fn ($model) => $model instanceof Subtask && $this->access->tasks($user)->whereKey($model->task_id)->exists(),
|
||||
'notification' => fn ($model) => $model instanceof Notification && $model->user_id === $user->id,
|
||||
];
|
||||
|
||||
foreach ($checks as $parameter => $allowed) {
|
||||
$model = $route?->parameter($parameter);
|
||||
if ($model !== null && ! $allowed($model)) {
|
||||
abort(403, 'شما به این منبع دسترسی ندارید.');
|
||||
}
|
||||
}
|
||||
|
||||
$actionItem = $route?->parameter('actionItem');
|
||||
$meeting = $route?->parameter('meeting');
|
||||
if ($actionItem instanceof MeetingActionItem) {
|
||||
abort_unless(
|
||||
$meeting instanceof Meeting
|
||||
&& $actionItem->meeting_id === $meeting->id
|
||||
&& $this->access->canAccessMeeting($user, $meeting),
|
||||
403,
|
||||
'این اقدام متعلق به جلسه انتخابشده نیست.'
|
||||
);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class ChangePasswordRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'current_password' => 'required',
|
||||
'new_password' => 'required|min:8|confirmed',
|
||||
'new_password' => 'required|string|min:12|confirmed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,23 @@ class StoreMeetingRequest extends FormRequest
|
|||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'project_id' => 'nullable|exists:projects,id',
|
||||
'sprint_id' => 'nullable|exists:sprints,id',
|
||||
'meeting_type_id' => 'nullable|exists:meeting_types,id',
|
||||
'date' => 'required|date',
|
||||
'start_time' => 'nullable',
|
||||
'end_time' => 'nullable',
|
||||
'start_time' => 'nullable|date_format:H:i',
|
||||
'end_time' => 'nullable|date_format:H:i|after:start_time',
|
||||
'meeting_type' => 'required|string|max:50',
|
||||
'status' => 'nullable|string|in:draft,scheduled,in_progress,completed,cancelled,postponed',
|
||||
'objective' => 'nullable|string|max:2000',
|
||||
'agenda' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'summary' => 'nullable|string',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'meeting_link' => 'nullable|url|max:2048',
|
||||
'owner_id' => 'nullable|exists:users,id',
|
||||
'facilitator_id' => 'nullable|exists:users,id',
|
||||
'reminder_minutes' => 'nullable|integer|min:0|max:10080',
|
||||
'recurrence_rule' => 'nullable|array',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class UpdateRoleRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
$roleId = $this->route('role');
|
||||
|
||||
return [
|
||||
'name' => 'sometimes|required|string|max:255|unique:roles,name,'.$roleId,
|
||||
'display_name' => 'sometimes|required|string|max:255',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class UpdateUserRequest extends FormRequest
|
|||
{
|
||||
$userId = $this->route('user');
|
||||
$userId = is_object($userId) ? $userId->id : $userId;
|
||||
|
||||
return [
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'email' => 'sometimes|required|email|unique:users,email,'.$userId,
|
||||
|
|
|
|||
|
|
@ -14,19 +14,38 @@ class MeetingResource extends JsonResource
|
|||
'title' => $this->title,
|
||||
'project_id' => $this->project_id,
|
||||
'project' => new ProjectResource($this->whenLoaded('project')),
|
||||
'sprint_id' => $this->sprint_id,
|
||||
'sprint' => new SprintResource($this->whenLoaded('sprint')),
|
||||
'meeting_type_id' => $this->meeting_type_id,
|
||||
'type' => $this->whenLoaded('type'),
|
||||
'date' => $this->date?->format('Y-m-d'),
|
||||
'start_time' => $this->start_time,
|
||||
'end_time' => $this->end_time,
|
||||
'location' => $this->location,
|
||||
'meeting_link' => $this->meeting_link,
|
||||
'meeting_type' => $this->meeting_type,
|
||||
'status' => $this->status ?? 'scheduled',
|
||||
'objective' => $this->objective,
|
||||
'agenda' => $this->agenda,
|
||||
'notes' => $this->notes,
|
||||
'summary' => $this->summary,
|
||||
'decisions' => $this->decisions,
|
||||
'structured_decisions' => $this->whenLoaded('structuredDecisions'),
|
||||
'structured_action_items' => $this->whenLoaded('structuredActionItems'),
|
||||
'blockers' => $this->whenLoaded('blockers'),
|
||||
'files' => FileResource::collection($this->whenLoaded('files')),
|
||||
'participants' => UserResource::collection($this->whenLoaded('participants')),
|
||||
'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')),
|
||||
'created_by' => $this->created_by,
|
||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
||||
'owner_id' => $this->owner_id,
|
||||
'owner' => new UserResource($this->whenLoaded('owner')),
|
||||
'facilitator_id' => $this->facilitator_id,
|
||||
'facilitator' => new UserResource($this->whenLoaded('facilitator')),
|
||||
'reminder_minutes' => $this->reminder_minutes,
|
||||
'recurrence_rule' => $this->recurrence_rule,
|
||||
'started_at' => $this->started_at,
|
||||
'completed_at' => $this->completed_at,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,20 @@ class NotificationResource extends JsonResource
|
|||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$entity = class_basename((string) $this->notifiable_type);
|
||||
$targetUrl = match ($entity) {
|
||||
'Meeting' => "/meetings/{$this->notifiable_id}/workspace",
|
||||
'Sprint' => "/sprints/{$this->notifiable_id}/workspace",
|
||||
'Task' => "/tasks?preview={$this->notifiable_id}",
|
||||
'Project' => "/projects/{$this->notifiable_id}",
|
||||
'ActionItem' => '/meetings',
|
||||
default => null,
|
||||
};
|
||||
$dataUrl = is_array($this->data) ? ($this->data['url'] ?? null) : null;
|
||||
if (! $targetUrl && is_string($dataUrl) && str_starts_with($dataUrl, '/')) {
|
||||
$targetUrl = $dataUrl;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type,
|
||||
|
|
@ -20,6 +34,16 @@ class NotificationResource extends JsonResource
|
|||
'notifiable_id' => $this->notifiable_id,
|
||||
'is_read' => $this->is_read,
|
||||
'read_at' => $this->read_at,
|
||||
'archived_at' => $this->archived_at,
|
||||
'entity_type' => $entity ? strtolower($entity) : null,
|
||||
'entity_id' => $this->notifiable_id,
|
||||
'target_url' => $targetUrl,
|
||||
'can_navigate' => (bool) $targetUrl,
|
||||
'preview' => [
|
||||
'title' => $this->title ?: 'اعلان',
|
||||
'body' => $this->body ?: ($this->data['message'] ?? null),
|
||||
'type' => $this->type,
|
||||
],
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ActionItem extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = ['due_date' => 'date'];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function sprint(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Sprint::class);
|
||||
}
|
||||
|
||||
public function meeting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Meeting::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ class ActivityLog extends Model
|
|||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'action', 'description', 'subject_type', 'subject_id',
|
||||
'project_id', 'task_id', 'properties'
|
||||
'project_id', 'task_id', 'properties',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class BacklogItem extends Model
|
|||
{
|
||||
protected $fillable = [
|
||||
'title', 'description', 'type', 'project_id', 'priority',
|
||||
'estimated_effort', 'status', 'assigned_sprint_id', 'created_by'
|
||||
'estimated_effort', 'status', 'assigned_sprint_id', 'created_by',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Blocker extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'due_date' => 'date',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Decision extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'effective_date' => 'date',
|
||||
'review_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
|
@ -11,12 +11,17 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|||
class Meeting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'project_id', 'date', 'start_time', 'end_time', 'location',
|
||||
'meeting_link', 'meeting_type', 'agenda', 'notes', 'decisions', 'created_by'
|
||||
'title', 'project_id', 'sprint_id', 'meeting_type_id', 'date', 'start_time',
|
||||
'end_time', 'location', 'meeting_link', 'meeting_type', 'status', 'objective',
|
||||
'agenda', 'notes', 'summary', 'decisions', 'reminder_minutes', 'recurrence_rule',
|
||||
'started_at', 'completed_at', 'created_by', 'owner_id', 'facilitator_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'recurrence_rule' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
|
|
@ -29,6 +34,26 @@ class Meeting extends Model
|
|||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function sprint(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Sprint::class);
|
||||
}
|
||||
|
||||
public function type(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MeetingType::class, 'meeting_type_id');
|
||||
}
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
public function facilitator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'facilitator_id');
|
||||
}
|
||||
|
||||
public function participants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps();
|
||||
|
|
@ -39,6 +64,21 @@ class Meeting extends Model
|
|||
return $this->hasMany(MeetingActionItem::class);
|
||||
}
|
||||
|
||||
public function structuredActionItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(ActionItem::class);
|
||||
}
|
||||
|
||||
public function structuredDecisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Decision::class);
|
||||
}
|
||||
|
||||
public function blockers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Blocker::class);
|
||||
}
|
||||
|
||||
public function comments(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Comment::class, 'commentable');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
class MeetingActionItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id'
|
||||
'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MeetingType extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key', 'name', 'default_title', 'suggested_duration', 'agenda_template',
|
||||
'minutes_template', 'participant_roles', 'recurrence_rule', 'settings', 'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'participant_roles' => 'array',
|
||||
'recurrence_rule' => 'array',
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function meetings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Meeting::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,12 +3,15 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'title', 'body', 'type', 'notifiable_type', 'notifiable_id',
|
||||
'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at'
|
||||
'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at', 'archived_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -17,5 +20,6 @@ class Notification extends Model
|
|||
'read_at' => 'datetime',
|
||||
'remind_at' => 'datetime',
|
||||
'dismissed_at' => 'datetime',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Project extends Model
|
|||
protected $fillable = [
|
||||
'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date',
|
||||
'priority', 'status', 'progress', 'risk_level', 'budget', 'estimated_hours',
|
||||
'actual_hours', 'tags', 'notes', 'is_archived', 'created_by'
|
||||
'actual_hours', 'tags', 'notes', 'is_archived', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,4 +52,29 @@ class Sprint extends Model
|
|||
{
|
||||
return $this->hasMany(BacklogItem::class, 'assigned_sprint_id');
|
||||
}
|
||||
|
||||
public function meetings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Meeting::class);
|
||||
}
|
||||
|
||||
public function decisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Decision::class);
|
||||
}
|
||||
|
||||
public function actionItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(ActionItem::class);
|
||||
}
|
||||
|
||||
public function blockers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Blocker::class);
|
||||
}
|
||||
|
||||
public function scopeChanges(): HasMany
|
||||
{
|
||||
return $this->hasMany(SprintScopeChange::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SprintScopeChange extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'previous_state' => 'array',
|
||||
'new_state' => 'array',
|
||||
'capacity_impact' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ class Task extends Model
|
|||
protected $fillable = [
|
||||
'title', 'description', 'project_id', 'assignee_id', 'reporter_id',
|
||||
'priority', 'status', 'blocker_type', 'blocker_note', 'start_date', 'due_date', 'estimated_time',
|
||||
'actual_time', 'tags', 'sort_order', 'created_by'
|
||||
'actual_time', 'tags', 'sort_order', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class User extends Authenticatable
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ class User extends Authenticatable
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ActionItem;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CalendarService
|
||||
{
|
||||
public function __construct(private readonly ResourceAccessService $access) {}
|
||||
|
||||
public function events(User $user, Carbon $from, Carbon $to, array $types = []): Collection
|
||||
{
|
||||
$enabled = fn (string $type): bool => $types === [] || in_array($type, $types, true);
|
||||
$events = collect();
|
||||
|
||||
if ($enabled('meeting')) {
|
||||
$this->access->meetings($user)->with(['project:id,title', 'sprint:id,title'])
|
||||
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
|
||||
->orderBy('date')
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->each(function (Meeting $meeting) use ($events) {
|
||||
$start = $meeting->date->format('Y-m-d').'T'.($meeting->start_time ?: '00:00:00');
|
||||
$end = $meeting->end_time ? $meeting->date->format('Y-m-d').'T'.$meeting->end_time : null;
|
||||
$events->push([
|
||||
'id' => "meeting-{$meeting->id}",
|
||||
'event_type' => 'meeting',
|
||||
'entity_type' => 'meeting',
|
||||
'entity_id' => $meeting->id,
|
||||
'title' => $meeting->title,
|
||||
'start_at' => $start,
|
||||
'end_at' => $end,
|
||||
'all_day' => ! $meeting->start_time,
|
||||
'status' => $meeting->status ?? 'scheduled',
|
||||
'color_token' => 'info',
|
||||
'project' => $meeting->project?->only(['id', 'title']),
|
||||
'sprint' => $meeting->sprint?->only(['id', 'title']),
|
||||
'preview' => [
|
||||
'زمان' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : 'تمام روز',
|
||||
'مکان' => $meeting->location,
|
||||
'هدف' => $meeting->objective,
|
||||
],
|
||||
'target_url' => "/meetings/{$meeting->id}/workspace",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
if ($enabled('task')) {
|
||||
$this->access->tasks($user)->with('project:id,title')
|
||||
->whereNotNull('due_date')
|
||||
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
|
||||
->where('status', '!=', 'done')
|
||||
->orderBy('due_date')
|
||||
->get()
|
||||
->each(fn (Task $task) => $events->push([
|
||||
'id' => "task-{$task->id}",
|
||||
'event_type' => 'task',
|
||||
'entity_type' => 'task',
|
||||
'entity_id' => $task->id,
|
||||
'title' => $task->title,
|
||||
'start_at' => $task->due_date?->format('Y-m-d'),
|
||||
'end_at' => null,
|
||||
'all_day' => true,
|
||||
'status' => $task->status,
|
||||
'color_token' => $task->priority === 'urgent' ? 'danger' : 'warning',
|
||||
'project' => $task->project?->only(['id', 'title']),
|
||||
'sprint' => null,
|
||||
'preview' => [
|
||||
'وضعیت' => $task->status,
|
||||
'اولویت' => $task->priority,
|
||||
],
|
||||
'target_url' => "/tasks?preview={$task->id}",
|
||||
]));
|
||||
}
|
||||
|
||||
if ($enabled('sprint')) {
|
||||
$this->access->sprints($user)->with('project:id,title')
|
||||
->where(function ($query) use ($from, $to) {
|
||||
$query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()])
|
||||
->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]);
|
||||
})
|
||||
->get()
|
||||
->each(function (Sprint $sprint) use ($events, $from, $to) {
|
||||
foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) {
|
||||
$date = $sprint->{$field};
|
||||
if (! $date || $date->lt($from) || $date->gt($to)) {
|
||||
continue;
|
||||
}
|
||||
$events->push([
|
||||
'id' => "sprint-{$field}-{$sprint->id}",
|
||||
'event_type' => 'sprint',
|
||||
'entity_type' => 'sprint',
|
||||
'entity_id' => $sprint->id,
|
||||
'title' => "{$label} {$sprint->title}",
|
||||
'start_at' => $date->format('Y-m-d'),
|
||||
'end_at' => null,
|
||||
'all_day' => true,
|
||||
'status' => $sprint->status,
|
||||
'color_token' => $field === 'start_date' ? 'success' : 'primary',
|
||||
'project' => $sprint->project?->only(['id', 'title']),
|
||||
'sprint' => $sprint->only(['id', 'title']),
|
||||
'preview' => [
|
||||
'هدف' => $sprint->goal,
|
||||
'ظرفیت' => $sprint->capacity_hours,
|
||||
],
|
||||
'target_url' => "/sprints/{$sprint->id}/workspace",
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($enabled('project')) {
|
||||
$this->access->projects($user)->where(function ($query) use ($from, $to) {
|
||||
$query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()])
|
||||
->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]);
|
||||
})->get()->each(function (Project $project) use ($events, $from, $to) {
|
||||
foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) {
|
||||
$date = $project->{$field};
|
||||
if (! $date || Carbon::parse($date)->lt($from) || Carbon::parse($date)->gt($to)) {
|
||||
continue;
|
||||
}
|
||||
$events->push([
|
||||
'id' => "project-{$field}-{$project->id}",
|
||||
'event_type' => 'project',
|
||||
'entity_type' => 'project',
|
||||
'entity_id' => $project->id,
|
||||
'title' => "{$label} {$project->title}",
|
||||
'start_at' => Carbon::parse($date)->format('Y-m-d'),
|
||||
'end_at' => null,
|
||||
'all_day' => true,
|
||||
'status' => $project->status,
|
||||
'color_token' => 'secondary',
|
||||
'project' => $project->only(['id', 'title']),
|
||||
'sprint' => null,
|
||||
'preview' => ['پیشرفت' => "{$project->progress}%"],
|
||||
'target_url' => "/projects/{$project->id}",
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($enabled('action_item') && class_exists(ActionItem::class)) {
|
||||
ActionItem::with(['project:id,title', 'sprint:id,title'])
|
||||
->when(! $this->access->canSeeAll($user), function ($query) use ($user) {
|
||||
$query->where(function ($scope) use ($user) {
|
||||
$scope->where('owner_id', $user->id)
|
||||
->orWhere('created_by', $user->id)
|
||||
->orWhereIn('project_id', $this->access->projects($user)->select('projects.id'));
|
||||
});
|
||||
})
|
||||
->whereNotNull('due_date')
|
||||
->whereBetween('due_date', [$from->toDateString(), $to->toDateString()])
|
||||
->whereNotIn('status', ['completed', 'cancelled'])
|
||||
->get()
|
||||
->each(fn (ActionItem $item) => $events->push([
|
||||
'id' => "action-item-{$item->id}",
|
||||
'event_type' => 'action_item',
|
||||
'entity_type' => 'action_item',
|
||||
'entity_id' => $item->id,
|
||||
'title' => $item->title,
|
||||
'start_at' => $item->due_date?->format('Y-m-d'),
|
||||
'end_at' => null,
|
||||
'all_day' => true,
|
||||
'status' => $item->status,
|
||||
'color_token' => $item->due_date?->isPast() ? 'danger' : 'warning',
|
||||
'project' => $item->project?->only(['id', 'title']),
|
||||
'sprint' => $item->sprint?->only(['id', 'title']),
|
||||
'preview' => ['اولویت' => $item->priority, 'وضعیت' => $item->status],
|
||||
'target_url' => $item->sprint_id ? "/sprints/{$item->sprint_id}/workspace?tab=action-items" : '/meetings',
|
||||
]));
|
||||
}
|
||||
|
||||
return $events->sortBy('start_at')->values();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\ActionItem;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Blocker;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DashboardService
|
||||
|
|
@ -94,7 +97,7 @@ class DashboardService
|
|||
}
|
||||
|
||||
$todayMeetings = Meeting::whereDate('date', Carbon::today())->count();
|
||||
$teamMembers = \App\Models\User::count();
|
||||
$teamMembers = User::count();
|
||||
|
||||
$projectProgresses = Project::where('is_archived', false)
|
||||
->select(['id', 'title', 'progress', 'status'])
|
||||
|
|
@ -159,4 +162,161 @@ class DashboardService
|
|||
'monthly_tasks' => $monthlyTasks,
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonitoring($user = null): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
$weekEnd = $today->copy()->addDays(7);
|
||||
|
||||
$projects = Project::query()
|
||||
->where('is_archived', false)
|
||||
->withCount([
|
||||
'tasks as total_tasks_count',
|
||||
'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'),
|
||||
'tasks as overdue_tasks_count' => fn ($query) => $query
|
||||
->whereDate('due_date', '<', $today)
|
||||
->whereNotIn('status', ['done', 'canceled']),
|
||||
'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true),
|
||||
])
|
||||
->orderByRaw("CASE WHEN risk_level = 'critical' THEN 0 WHEN risk_level = 'high' THEN 1 ELSE 2 END")
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(function (Project $project) {
|
||||
$health = 'on_track';
|
||||
if ($project->risk_level === 'critical' || $project->overdue_tasks_count >= 5) {
|
||||
$health = 'off_track';
|
||||
} elseif ($project->risk_level === 'high' || $project->overdue_tasks_count > 0 || $project->blocked_tasks_count > 0) {
|
||||
$health = 'at_risk';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'title' => $project->title,
|
||||
'status' => $project->status,
|
||||
'risk_level' => $project->risk_level,
|
||||
'progress' => $project->progress,
|
||||
'health' => $health,
|
||||
'total_tasks' => $project->total_tasks_count,
|
||||
'completed_tasks' => $project->completed_tasks_count,
|
||||
'overdue_tasks' => $project->overdue_tasks_count,
|
||||
'blocked_tasks' => $project->blocked_tasks_count,
|
||||
];
|
||||
});
|
||||
|
||||
$sprints = Sprint::with(['project:id,title'])
|
||||
->withCount([
|
||||
'tasks as total_tasks_count',
|
||||
'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'),
|
||||
'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true),
|
||||
])
|
||||
->where('status', 'active')
|
||||
->orderBy('end_date')
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Sprint $sprint) use ($today) {
|
||||
$progress = $sprint->total_tasks_count > 0
|
||||
? round(($sprint->completed_tasks_count / $sprint->total_tasks_count) * 100)
|
||||
: 0;
|
||||
$elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0);
|
||||
$duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1);
|
||||
$expected = min(100, round(($elapsed / $duration) * 100));
|
||||
$health = $sprint->blocked_tasks_count > 1 || $progress + 20 < $expected ? 'at_risk' : 'on_track';
|
||||
if ($sprint->end_date?->isPast() && $progress < 100) {
|
||||
$health = 'off_track';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $sprint->id,
|
||||
'title' => $sprint->title,
|
||||
'project' => $sprint->project?->title,
|
||||
'progress' => $progress,
|
||||
'expected_progress' => $expected,
|
||||
'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0),
|
||||
'health' => $health,
|
||||
'blocked_tasks' => $sprint->blocked_tasks_count,
|
||||
];
|
||||
});
|
||||
|
||||
$workload = User::query()
|
||||
->where('status', 'active')
|
||||
->withCount([
|
||||
'tasks as open_tasks_count' => fn ($query) => $query->whereNotIn('status', ['done', 'canceled']),
|
||||
'tasks as overdue_tasks_count' => fn ($query) => $query
|
||||
->whereDate('due_date', '<', $today)
|
||||
->whereNotIn('status', ['done', 'canceled']),
|
||||
])
|
||||
->orderByDesc('open_tasks_count')
|
||||
->limit(8)
|
||||
->get(['id', 'name'])
|
||||
->map(fn (User $member) => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'open_tasks' => $member->open_tasks_count,
|
||||
'overdue_tasks' => $member->overdue_tasks_count,
|
||||
'load' => $member->open_tasks_count >= 10 ? 'overloaded' : ($member->open_tasks_count >= 6 ? 'high' : 'balanced'),
|
||||
]);
|
||||
|
||||
$attention = collect();
|
||||
Task::with('project:id,title')
|
||||
->whereNotIn('status', ['done', 'canceled'])
|
||||
->where(function ($query) use ($today, $weekEnd) {
|
||||
$query->where('is_blocked', true)
|
||||
->orWhereDate('due_date', '<', $today)
|
||||
->orWhereBetween('due_date', [$today, $weekEnd]);
|
||||
})
|
||||
->orderBy('due_date')
|
||||
->limit(10)
|
||||
->get()
|
||||
->each(fn (Task $task) => $attention->push([
|
||||
'id' => "task-{$task->id}",
|
||||
'type' => $task->is_blocked ? 'blocker' : ($task->due_date?->isPast() ? 'overdue' : 'deadline'),
|
||||
'title' => $task->title,
|
||||
'context' => $task->project?->title,
|
||||
'date' => $task->due_date?->format('Y-m-d'),
|
||||
'target_url' => "/tasks?preview={$task->id}",
|
||||
]));
|
||||
|
||||
$upcomingMeetings = Meeting::with(['project:id,title', 'sprint:id,title'])
|
||||
->where(function ($query) use ($today) {
|
||||
$query->whereDate('date', '>', $today)
|
||||
->orWhere(function ($sameDay) use ($today) {
|
||||
$sameDay->whereDate('date', $today)->whereTime('start_time', '>=', now()->format('H:i:s'));
|
||||
});
|
||||
})
|
||||
->whereNotIn('status', ['cancelled', 'completed'])
|
||||
->orderBy('date')
|
||||
->orderBy('start_time')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn (Meeting $meeting) => [
|
||||
'id' => $meeting->id,
|
||||
'title' => $meeting->title,
|
||||
'date' => $meeting->date?->format('Y-m-d'),
|
||||
'time' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : null,
|
||||
'project' => $meeting->project?->title,
|
||||
'sprint' => $meeting->sprint?->title,
|
||||
'target_url' => "/meetings/{$meeting->id}/workspace",
|
||||
]);
|
||||
|
||||
return [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'summary' => [
|
||||
'active_projects' => Project::where('is_archived', false)->where('status', '!=', 'done')->count(),
|
||||
'projects_at_risk' => $projects->whereIn('health', ['at_risk', 'off_track'])->count(),
|
||||
'open_tasks' => Task::whereNotIn('status', ['done', 'canceled'])->count(),
|
||||
'overdue_tasks' => Task::whereDate('due_date', '<', $today)->whereNotIn('status', ['done', 'canceled'])->count(),
|
||||
'blocked_tasks' => Task::where('is_blocked', true)->whereNotIn('status', ['done', 'canceled'])->count(),
|
||||
'active_sprints' => Sprint::where('status', 'active')->count(),
|
||||
'meetings_today' => Meeting::whereDate('date', $today)->where('status', '!=', 'cancelled')->count(),
|
||||
'overdue_action_items' => ActionItem::whereDate('due_date', '<', $today)->whereNotIn('status', ['completed', 'cancelled'])->count(),
|
||||
'open_blockers' => Blocker::whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||
],
|
||||
'projects' => $projects,
|
||||
'sprints' => $sprints,
|
||||
'workload' => $workload,
|
||||
'attention' => $attention->values(),
|
||||
'upcoming_meetings' => $upcomingMeetings,
|
||||
'trends' => $this->getChartData()['monthlyTasks'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class ProjectProgressService
|
|||
|
||||
if ($totalTasks === 0) {
|
||||
$project->update(['progress' => 0]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ class ReportService
|
|||
if (! empty($filters['date_to'])) {
|
||||
$query->where($dateField, '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +99,7 @@ class ReportService
|
|||
$completedTasks = $user->tasks->where('status', 'done')->count();
|
||||
$delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count();
|
||||
$inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count();
|
||||
|
||||
return [
|
||||
'name' => $user->name,
|
||||
'completed' => $completedTasks,
|
||||
|
|
@ -152,6 +154,7 @@ class ReportService
|
|||
return $sprints->map(function ($sprint) {
|
||||
$totalTasks = $sprint->tasks->count();
|
||||
$completedTasks = $sprint->tasks->where('status', 'done')->count();
|
||||
|
||||
return [
|
||||
'id' => $sprint->id,
|
||||
'title' => $sprint->title,
|
||||
|
|
@ -185,6 +188,7 @@ class ReportService
|
|||
|
||||
return $tasks->map(function ($task) {
|
||||
$diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0);
|
||||
|
||||
return [
|
||||
'id' => $task->id,
|
||||
'title' => $task->title,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\File;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ResourceAccessService
|
||||
{
|
||||
public function canSeeAll(User $user): bool
|
||||
{
|
||||
$user->loadMissing('roles.permissions');
|
||||
|
||||
return $user->roles->contains('name', 'admin');
|
||||
}
|
||||
|
||||
public function projects(User $user): Builder
|
||||
{
|
||||
$query = Project::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('project_manager_id', $user->id)
|
||||
->orWhere('created_by', $user->id)
|
||||
->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id));
|
||||
});
|
||||
}
|
||||
|
||||
public function tasks(User $user): Builder
|
||||
{
|
||||
$query = Task::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('assignee_id', $user->id)
|
||||
->orWhere('reporter_id', $user->id)
|
||||
->orWhere('created_by', $user->id)
|
||||
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
|
||||
});
|
||||
}
|
||||
|
||||
public function sprints(User $user): Builder
|
||||
{
|
||||
$query = Sprint::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('created_by', $user->id)
|
||||
->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id))
|
||||
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
|
||||
});
|
||||
}
|
||||
|
||||
public function meetings(User $user): Builder
|
||||
{
|
||||
$query = Meeting::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('created_by', $user->id)
|
||||
->orWhere('owner_id', $user->id)
|
||||
->orWhere('facilitator_id', $user->id)
|
||||
->orWhereHas('participants', fn (Builder $participants) => $participants->where('users.id', $user->id))
|
||||
->orWhereIn('project_id', $this->projects($user)->select('projects.id'));
|
||||
});
|
||||
}
|
||||
|
||||
public function comments(User $user): Builder
|
||||
{
|
||||
$query = Comment::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('user_id', $user->id)
|
||||
->orWhere(function (Builder $tasks) use ($user) {
|
||||
$tasks->where('commentable_type', Task::class)
|
||||
->whereIn('commentable_id', $this->tasks($user)->select('tasks.id'));
|
||||
})
|
||||
->orWhere(function (Builder $projects) use ($user) {
|
||||
$projects->where('commentable_type', Project::class)
|
||||
->whereIn('commentable_id', $this->projects($user)->select('projects.id'));
|
||||
})
|
||||
->orWhere(function (Builder $meetings) use ($user) {
|
||||
$meetings->where('commentable_type', Meeting::class)
|
||||
->whereIn('commentable_id', $this->meetings($user)->select('meetings.id'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function files(User $user): Builder
|
||||
{
|
||||
$query = File::query();
|
||||
if ($this->canSeeAll($user)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $scope) use ($user) {
|
||||
$scope->where('user_id', $user->id)
|
||||
->orWhere(function (Builder $projects) use ($user) {
|
||||
$projects->where('fileable_type', Project::class)
|
||||
->whereIn('fileable_id', $this->projects($user)->select('projects.id'));
|
||||
})
|
||||
->orWhere(function (Builder $tasks) use ($user) {
|
||||
$tasks->where('fileable_type', Task::class)
|
||||
->whereIn('fileable_id', $this->tasks($user)->select('tasks.id'));
|
||||
})
|
||||
->orWhere(function (Builder $meetings) use ($user) {
|
||||
$meetings->where('fileable_type', Meeting::class)
|
||||
->whereIn('fileable_id', $this->meetings($user)->select('meetings.id'));
|
||||
})
|
||||
->orWhere(function (Builder $comments) use ($user) {
|
||||
$comments->where('fileable_type', Comment::class)
|
||||
->whereIn('fileable_id', $this->comments($user)->select('comments.id'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function canAccessProject(User $user, Project $project): bool
|
||||
{
|
||||
return $this->projects($user)->whereKey($project->getKey())->exists();
|
||||
}
|
||||
|
||||
public function canAccessTask(User $user, Task $task): bool
|
||||
{
|
||||
return $this->tasks($user)->whereKey($task->getKey())->exists();
|
||||
}
|
||||
|
||||
public function canAccessSprint(User $user, Sprint $sprint): bool
|
||||
{
|
||||
return $this->sprints($user)->whereKey($sprint->getKey())->exists();
|
||||
}
|
||||
|
||||
public function canAccessMeeting(User $user, Meeting $meeting): bool
|
||||
{
|
||||
return $this->meetings($user)->whereKey($meeting->getKey())->exists();
|
||||
}
|
||||
|
||||
public function canAccessComment(User $user, Comment $comment): bool
|
||||
{
|
||||
return $this->comments($user)->whereKey($comment->getKey())->exists();
|
||||
}
|
||||
|
||||
public function canAccessFile(User $user, File $file): bool
|
||||
{
|
||||
return $this->files($user)->whereKey($file->getKey())->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class WorkloadService
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use App\Http\Middleware\EnsureResourceAccess;
|
||||
use App\Http\Middleware\EnsureUserHasPermission;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -19,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
$middleware->append(SecurityHeaders::class);
|
||||
$middleware->alias([
|
||||
'permission' => EnsureUserHasPermission::class,
|
||||
'resource.access' => EnsureResourceAccess::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,28 @@ return [
|
|||
]) : [],
|
||||
],
|
||||
|
||||
'migration_sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => env('MIGRATION_SQLITE_PATH', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => true,
|
||||
],
|
||||
|
||||
'migration_mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => env('MIGRATION_MYSQL_HOST', '127.0.0.1'),
|
||||
'port' => env('MIGRATION_MYSQL_PORT', '3306'),
|
||||
'database' => env('MIGRATION_MYSQL_DATABASE', 'pm_migration'),
|
||||
'username' => env('MIGRATION_MYSQL_USERNAME', 'root'),
|
||||
'password' => env('MIGRATION_MYSQL_PASSWORD', ''),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => 'InnoDB',
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ return [
|
|||
|
||||
'guard' => ['web'],
|
||||
|
||||
'expiration' => null,
|
||||
'expiration' => (int) env('SANCTUM_TOKEN_EXPIRATION', 480),
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('meeting_types', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->string('name');
|
||||
$table->string('default_title')->nullable();
|
||||
$table->unsignedSmallInteger('suggested_duration')->nullable();
|
||||
$table->text('agenda_template')->nullable();
|
||||
$table->text('minutes_template')->nullable();
|
||||
$table->json('participant_roles')->nullable();
|
||||
$table->json('recurrence_rule')->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$now = now();
|
||||
DB::table('meeting_types')->insert([
|
||||
['key' => 'sprint_planning', 'name' => 'برنامهریزی اسپرینت', 'default_title' => 'جلسه برنامهریزی اسپرینت', 'suggested_duration' => 90, 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بکلاگ\nریسکها و وابستگیها", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'default_title' => 'Daily Stand-up', 'suggested_duration' => 15, 'agenda_template' => "کارهای انجامشده\nبرنامه امروز\nموانع و نیاز به کمک", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => json_encode(['frequency' => 'weekdays', 'until' => 'sprint_end']), 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'backlog_refinement', 'name' => 'پالایش بکلاگ', 'default_title' => 'پالایش بکلاگ', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'default_title' => 'Sprint Review', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'default_title' => 'Sprint Retrospective', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'default_title' => 'جلسه موردی', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'default_title' => 'جلسه رفع مانع', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'scope_change', 'name' => 'تغییر محدوده', 'default_title' => 'بررسی تغییر محدوده', 'suggested_duration' => 45, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
Schema::table('meetings', function (Blueprint $table) {
|
||||
$table->foreignId('sprint_id')->nullable()->after('project_id')->constrained()->nullOnDelete();
|
||||
$table->foreignId('meeting_type_id')->nullable()->after('sprint_id')->constrained()->nullOnDelete();
|
||||
$table->foreignId('owner_id')->nullable()->after('created_by')->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('facilitator_id')->nullable()->after('owner_id')->constrained('users')->nullOnDelete();
|
||||
$table->string('status')->default('scheduled')->after('meeting_type');
|
||||
$table->text('objective')->nullable()->after('status');
|
||||
$table->text('summary')->nullable()->after('notes');
|
||||
$table->unsignedSmallInteger('reminder_minutes')->nullable()->after('summary');
|
||||
$table->json('recurrence_rule')->nullable()->after('reminder_minutes');
|
||||
$table->timestamp('started_at')->nullable()->after('recurrence_rule');
|
||||
$table->timestamp('completed_at')->nullable()->after('started_at');
|
||||
$table->index(['sprint_id', 'date']);
|
||||
$table->index(['status', 'date']);
|
||||
});
|
||||
|
||||
Schema::create('meeting_relations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('meeting_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('related_type');
|
||||
$table->unsignedBigInteger('related_id');
|
||||
$table->string('relation_type')->default('related');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->unique(['meeting_id', 'related_type', 'related_id', 'relation_type'], 'meeting_relation_unique');
|
||||
$table->index(['related_type', 'related_id']);
|
||||
});
|
||||
|
||||
Schema::create('decisions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('status')->default('proposed');
|
||||
$table->string('decision_type')->nullable();
|
||||
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('decision_maker_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('rationale')->nullable();
|
||||
$table->text('impact')->nullable();
|
||||
$table->date('effective_date')->nullable();
|
||||
$table->date('review_date')->nullable();
|
||||
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('action_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('status')->default('open');
|
||||
$table->string('priority')->default('medium');
|
||||
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('decision_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('converted_to_task_id')->nullable()->constrained('tasks')->nullOnDelete();
|
||||
$table->date('due_date')->nullable();
|
||||
$table->text('completion_notes')->nullable();
|
||||
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->index(['status', 'due_date']);
|
||||
});
|
||||
|
||||
Schema::create('blockers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('severity')->default('medium');
|
||||
$table->string('status')->default('open');
|
||||
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('raised_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('task_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->date('due_date')->nullable();
|
||||
$table->text('resolution')->nullable();
|
||||
$table->timestamp('resolved_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->index(['status', 'severity']);
|
||||
});
|
||||
|
||||
Schema::create('sprint_scope_changes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sprint_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('task_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('change_type');
|
||||
$table->json('previous_state')->nullable();
|
||||
$table->json('new_state')->nullable();
|
||||
$table->text('reason')->nullable();
|
||||
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->decimal('capacity_impact', 8, 2)->nullable();
|
||||
$table->text('goal_impact')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->timestamp('archived_at')->nullable()->after('dismissed_at')->index();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
if (Schema::hasTable('settings')) {
|
||||
DB::table('settings')->insertOrIgnore([
|
||||
['key' => 'working_days', 'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday']), 'group' => 'calendar', 'type' => 'list', 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'organization_timezone', 'value' => 'Asia/Tehran', 'group' => 'calendar', 'type' => 'string', 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'default_meeting_reminder_minutes', 'value' => '30', 'group' => 'meeting', 'type' => 'number', 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'meeting_effectiveness_enabled', 'value' => 'true', 'group' => 'meeting', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now],
|
||||
['key' => 'sprint_health_override_requires_reason', 'value' => 'true', 'group' => 'sprint', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
$table->dropColumn('archived_at');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('sprint_scope_changes');
|
||||
Schema::dropIfExists('blockers');
|
||||
Schema::dropIfExists('action_items');
|
||||
Schema::dropIfExists('decisions');
|
||||
Schema::dropIfExists('meeting_relations');
|
||||
|
||||
Schema::table('meetings', function (Blueprint $table) {
|
||||
$table->dropForeign(['sprint_id']);
|
||||
$table->dropForeign(['meeting_type_id']);
|
||||
$table->dropForeign(['owner_id']);
|
||||
$table->dropForeign(['facilitator_id']);
|
||||
$table->dropColumn([
|
||||
'sprint_id', 'meeting_type_id', 'owner_id', 'facilitator_id', 'status',
|
||||
'objective', 'summary', 'reminder_minutes', 'recurrence_rule',
|
||||
'started_at', 'completed_at',
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('meeting_types');
|
||||
}
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ namespace Database\Seeders;
|
|||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
|
|
@ -12,8 +12,9 @@ class DatabaseSeeder extends Seeder
|
|||
|
||||
public function run(): void
|
||||
{
|
||||
DB::statement('PRAGMA foreign_keys = OFF');
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
try {
|
||||
$this->call([
|
||||
RolePermissionSeeder::class,
|
||||
UserSeeder::class,
|
||||
|
|
@ -21,13 +22,15 @@ class DatabaseSeeder extends Seeder
|
|||
TaskSeeder::class,
|
||||
SprintSeeder::class,
|
||||
BacklogSeeder::class,
|
||||
MeetingTypeSeeder::class,
|
||||
MeetingSeeder::class,
|
||||
CommentSeeder::class,
|
||||
ActivityLogSeeder::class,
|
||||
NotificationSeeder::class,
|
||||
SettingSeeder::class,
|
||||
]);
|
||||
|
||||
DB::statement('PRAGMA foreign_keys = ON');
|
||||
} finally {
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\MeetingType;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class MeetingTypeSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$types = [
|
||||
['key' => 'sprint_planning', 'name' => 'برنامهریزی اسپرینت', 'suggested_duration' => 90, 'default_title' => 'جلسه برنامهریزی اسپرینت', 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بکلاگ\nریسکها و وابستگیها"],
|
||||
['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'suggested_duration' => 15, 'default_title' => 'Daily Stand-up', 'agenda_template' => "کارهای انجامشده\nبرنامه امروز\nموانع و نیاز به کمک", 'recurrence_rule' => ['frequency' => 'weekdays', 'until' => 'sprint_end']],
|
||||
['key' => 'backlog_refinement', 'name' => 'پالایش بکلاگ', 'suggested_duration' => 60, 'default_title' => 'پالایش بکلاگ'],
|
||||
['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Review'],
|
||||
['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Retrospective'],
|
||||
['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'suggested_duration' => 30, 'default_title' => 'جلسه موردی'],
|
||||
['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'suggested_duration' => 30, 'default_title' => 'جلسه رفع مانع'],
|
||||
['key' => 'scope_change', 'name' => 'تغییر محدوده', 'suggested_duration' => 45, 'default_title' => 'بررسی تغییر محدوده'],
|
||||
];
|
||||
|
||||
foreach ($types as $type) {
|
||||
MeetingType::updateOrCreate(['key' => $type['key']], $type + ['is_active' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -108,10 +108,50 @@ class SettingSeeder extends Seeder
|
|||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'key' => 'working_days',
|
||||
'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday'], JSON_UNESCAPED_UNICODE),
|
||||
'group' => 'calendar',
|
||||
'type' => 'list',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'key' => 'organization_timezone',
|
||||
'value' => 'Asia/Tehran',
|
||||
'group' => 'calendar',
|
||||
'type' => 'string',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'key' => 'default_meeting_reminder_minutes',
|
||||
'value' => '30',
|
||||
'group' => 'meeting',
|
||||
'type' => 'number',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'key' => 'meeting_effectiveness_enabled',
|
||||
'value' => 'true',
|
||||
'group' => 'meeting',
|
||||
'type' => 'boolean',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'key' => 'sprint_health_override_requires_reason',
|
||||
'value' => 'true',
|
||||
'group' => 'sprint',
|
||||
'type' => 'boolean',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
DB::table('settings')->insert($setting);
|
||||
DB::table('settings')->updateOrInsert(['key' => $setting['key']], $setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\DashboardController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\TaskController;
|
||||
use App\Http\Controllers\Api\ChecklistController;
|
||||
use App\Http\Controllers\Api\SubtaskController;
|
||||
use App\Http\Controllers\Api\SprintController;
|
||||
use App\Http\Controllers\Api\BacklogController;
|
||||
use App\Http\Controllers\Api\MeetingController;
|
||||
use App\Http\Controllers\Api\CommentController;
|
||||
use App\Http\Controllers\Api\FileController;
|
||||
use App\Http\Controllers\Api\ActivityLogController;
|
||||
use App\Http\Controllers\Api\NotificationController;
|
||||
use App\Http\Controllers\Api\ReportController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\RoleController;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\BacklogController;
|
||||
use App\Http\Controllers\Api\CalendarController;
|
||||
use App\Http\Controllers\Api\ChecklistController;
|
||||
use App\Http\Controllers\Api\CommentController;
|
||||
use App\Http\Controllers\Api\DashboardController;
|
||||
use App\Http\Controllers\Api\DepartmentController;
|
||||
use App\Http\Controllers\Api\FileController;
|
||||
use App\Http\Controllers\Api\MeetingController;
|
||||
use App\Http\Controllers\Api\NotificationController;
|
||||
use App\Http\Controllers\Api\PermissionController;
|
||||
use App\Http\Controllers\Api\SettingController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\PwaController;
|
||||
use App\Http\Controllers\Api\ReportController;
|
||||
use App\Http\Controllers\Api\RoleController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\SettingController;
|
||||
use App\Http\Controllers\Api\SprintController;
|
||||
use App\Http\Controllers\Api\SubtaskController;
|
||||
use App\Http\Controllers\Api\TaskController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
||||
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('throttle:login');
|
||||
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('throttle:sensitive');
|
||||
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'resource.access'])->group(function () {
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/user', [AuthController::class, 'user']);
|
||||
Route::put('/user/profile', [AuthController::class, 'updateProfile']);
|
||||
|
|
@ -55,6 +57,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||
|
||||
Route::get('/dashboard/summary', [DashboardController::class, 'summary'])->middleware('permission:reports.view');
|
||||
Route::get('/dashboard/charts', [DashboardController::class, 'charts'])->middleware('permission:reports.view');
|
||||
Route::get('/dashboard/monitoring', [DashboardController::class, 'monitoring'])->middleware('permission:reports.view');
|
||||
Route::get('/calendar/events', [CalendarController::class, 'events']);
|
||||
|
||||
Route::get('/users', [UserController::class, 'index'])->middleware(['permission:team.view', 'throttle:sensitive']);
|
||||
Route::post('/users', [UserController::class, 'store'])->middleware(['permission:team.create', 'throttle:sensitive']);
|
||||
|
|
@ -62,11 +66,11 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||
Route::put('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']);
|
||||
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']);
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware(['permission:team.delete', 'throttle:sensitive']);
|
||||
Route::patch('/users/{user}/status', [UserController::class, 'updateStatus']);
|
||||
Route::put('/users/{user}/status', [UserController::class, 'updateStatus']);
|
||||
Route::patch('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']);
|
||||
Route::put('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']);
|
||||
Route::put('/users/{user}/roles', [UserController::class, 'syncRoles'])->middleware(['permission:roles.edit', 'throttle:sensitive']);
|
||||
Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload']);
|
||||
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats']);
|
||||
Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload'])->middleware('permission:team.view');
|
||||
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats'])->middleware('permission:team.view');
|
||||
|
||||
Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view');
|
||||
Route::post('/projects', [ProjectController::class, 'store'])->middleware('permission:projects.create');
|
||||
|
|
@ -92,15 +96,16 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||
Route::get('/my-tasks', [TaskController::class, 'myTasks']);
|
||||
Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']);
|
||||
|
||||
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow();
|
||||
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete']);
|
||||
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow()->middleware('permission:tasks.edit');
|
||||
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete'])->middleware('permission:tasks.edit');
|
||||
|
||||
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow();
|
||||
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus']);
|
||||
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow()->middleware('permission:tasks.edit');
|
||||
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus'])->middleware('permission:tasks.edit');
|
||||
|
||||
Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view');
|
||||
Route::post('/sprints', [SprintController::class, 'store'])->middleware('permission:sprints.create');
|
||||
Route::get('/sprints/{sprint}', [SprintController::class, 'show'])->middleware('permission:sprints.view');
|
||||
Route::get('/sprints/{sprint}/workspace', [SprintController::class, 'workspace'])->middleware('permission:sprints.view');
|
||||
Route::put('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
|
||||
Route::patch('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
|
||||
Route::delete('/sprints/{sprint}', [SprintController::class, 'destroy'])->middleware('permission:sprints.delete');
|
||||
|
|
@ -122,12 +127,19 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||
Route::delete('/backlog-items/{backlogItem}', [BacklogController::class, 'destroy'])->middleware('permission:backlog.delete');
|
||||
Route::post('/backlog-items/{backlogItem}/convert-to-task', [BacklogController::class, 'convertToTask'])->middleware('permission:backlog.edit');
|
||||
|
||||
Route::get('/meeting-types', [MeetingController::class, 'types'])->middleware('permission:meetings.view');
|
||||
Route::get('/meetings', [MeetingController::class, 'index'])->middleware('permission:meetings.view');
|
||||
Route::post('/meetings', [MeetingController::class, 'store'])->middleware('permission:meetings.create');
|
||||
Route::get('/meetings/{meeting}', [MeetingController::class, 'show'])->middleware('permission:meetings.view');
|
||||
Route::get('/meetings/{meeting}/workspace', [MeetingController::class, 'workspace'])->middleware('permission:meetings.view');
|
||||
Route::put('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
|
||||
Route::patch('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
|
||||
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}/complete', [MeetingController::class, 'complete'])->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}/blockers', [MeetingController::class, 'addBlocker'])->middleware('permission:meetings.edit');
|
||||
Route::post('/meetings/{meeting}/participants', [MeetingController::class, 'addParticipant'])->middleware('permission:meetings.edit');
|
||||
Route::delete('/meetings/{meeting}/participants/{user}', [MeetingController::class, 'removeParticipant'])->middleware('permission:meetings.edit');
|
||||
Route::post('/meetings/{meeting}/action-items', [MeetingController::class, 'addActionItem'])->middleware('permission:meetings.edit');
|
||||
|
|
@ -149,6 +161,9 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||
|
||||
Route::get('/notifications', [NotificationController::class, 'index'])->middleware('permission:notifications.view');
|
||||
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->middleware('permission:notifications.view');
|
||||
Route::get('/notifications/{notification}/preview', [NotificationController::class, 'preview'])->middleware('permission:notifications.view');
|
||||
Route::patch('/notifications/{notification}/archive', [NotificationController::class, 'archive'])->middleware('permission:notifications.view');
|
||||
Route::patch('/notifications/{notification}/restore', [NotificationController::class, 'restore'])->middleware('permission:notifications.view');
|
||||
Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->middleware('permission:notifications.view');
|
||||
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view');
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthTest extends TestCase
|
||||
|
|
@ -91,4 +93,41 @@ class AuthTest extends TestCase
|
|||
->getJson('/api/user')
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_password_change_revokes_all_access_tokens(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('old-password'),
|
||||
'status' => 'active',
|
||||
]);
|
||||
$currentToken = $user->createToken('current')->plainTextToken;
|
||||
$user->createToken('other');
|
||||
|
||||
$this->withToken($currentToken)
|
||||
->putJson('/api/user/password', [
|
||||
'current_password' => 'old-password',
|
||||
'new_password' => 'new-secure-password',
|
||||
'new_password_confirmation' => 'new-secure-password',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('reauthentication_required', true);
|
||||
|
||||
$this->assertSame(0, $user->tokens()->count());
|
||||
$this->assertTrue(Hash::check('new-secure-password', $user->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_forgot_password_sends_reset_notification_without_account_disclosure(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$user = User::factory()->create(['email' => 'reset@example.com', 'status' => 'active']);
|
||||
|
||||
$this->postJson('/api/forgot-password', ['email' => $user->email])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
|
||||
$this->postJson('/api/forgot-password', ['email' => 'missing@example.com'])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\MeetingActionItem;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
|
@ -84,4 +88,118 @@ class SecurityHardeningTest extends TestCase
|
|||
->assertForbidden()
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
public function test_project_permission_does_not_expose_unrelated_projects(): void
|
||||
{
|
||||
$owner = User::factory()->create(['status' => 'active']);
|
||||
$viewer = User::factory()->create(['status' => 'active']);
|
||||
$permission = Permission::create([
|
||||
'name' => 'projects.view',
|
||||
'display_name' => 'View projects',
|
||||
'guard_name' => 'web',
|
||||
'module' => 'projects',
|
||||
]);
|
||||
$role = Role::create(['name' => 'project-viewer', 'display_name' => 'Project viewer', 'guard_name' => 'web']);
|
||||
$role->permissions()->attach($permission);
|
||||
$viewer->roles()->attach($role);
|
||||
|
||||
$foreignProject = Project::create([
|
||||
'title' => 'Private project',
|
||||
'project_manager_id' => $owner->id,
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
|
||||
$token = $viewer->createToken('api-token')->plainTextToken;
|
||||
|
||||
$this->withToken($token)
|
||||
->getJson('/api/projects')
|
||||
->assertOk()
|
||||
->assertJsonPath('meta.total', 0);
|
||||
|
||||
$this->withToken($token)
|
||||
->getJson("/api/projects/{$foreignProject->id}")
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_authenticated_user_cannot_change_another_user_status_without_permission(): void
|
||||
{
|
||||
$actor = User::factory()->create(['status' => 'active']);
|
||||
$target = User::factory()->create(['status' => 'active']);
|
||||
$token = $actor->createToken('api-token')->plainTextToken;
|
||||
|
||||
$this->withToken($token)
|
||||
->patchJson("/api/users/{$target->id}/status", ['status' => 'inactive'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame('active', $target->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_user_cannot_download_file_from_unrelated_project(): void
|
||||
{
|
||||
$owner = User::factory()->create(['status' => 'active']);
|
||||
$viewer = User::factory()->create(['status' => 'active']);
|
||||
$permission = Permission::create([
|
||||
'name' => 'files.view',
|
||||
'display_name' => 'View files',
|
||||
'guard_name' => 'web',
|
||||
'module' => 'files',
|
||||
]);
|
||||
$role = Role::create(['name' => 'file-viewer', 'display_name' => 'File viewer', 'guard_name' => 'web']);
|
||||
$role->permissions()->attach($permission);
|
||||
$viewer->roles()->attach($role);
|
||||
$project = Project::create([
|
||||
'title' => 'Private project',
|
||||
'project_manager_id' => $owner->id,
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
$file = File::create([
|
||||
'name' => 'private.pdf',
|
||||
'original_name' => 'private.pdf',
|
||||
'path' => 'files/private.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 10,
|
||||
'fileable_type' => Project::class,
|
||||
'fileable_id' => $project->id,
|
||||
'user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$token = $viewer->createToken('api-token')->plainTextToken;
|
||||
$this->withToken($token)->getJson("/api/files/{$file->id}")->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_meeting_action_item_must_belong_to_meeting_in_route(): void
|
||||
{
|
||||
$user = User::factory()->create(['status' => 'active']);
|
||||
$this->grantAdminRole($user);
|
||||
$project = Project::create([
|
||||
'title' => 'Meeting security',
|
||||
'project_manager_id' => $user->id,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
$first = Meeting::create([
|
||||
'title' => 'First',
|
||||
'project_id' => $project->id,
|
||||
'date' => now()->toDateString(),
|
||||
'meeting_type' => 'other',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
$second = Meeting::create([
|
||||
'title' => 'Second',
|
||||
'project_id' => $project->id,
|
||||
'date' => now()->toDateString(),
|
||||
'meeting_type' => 'other',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
$item = MeetingActionItem::create([
|
||||
'meeting_id' => $second->id,
|
||||
'title' => 'Private action',
|
||||
]);
|
||||
|
||||
$token = $user->createToken('api-token')->plainTextToken;
|
||||
$this->withToken($token)
|
||||
->putJson("/api/meetings/{$first->id}/action-items/{$item->id}", ['title' => 'Tampered'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame('Private action', $item->fresh()->title);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ class SettingTest extends TestCase
|
|||
$this->withToken($token)
|
||||
->getJson('/api/settings')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.value', 'شرکت پیشگام');
|
||||
->assertJsonFragment([
|
||||
'key' => 'company_name',
|
||||
'value' => 'شرکت پیشگام',
|
||||
]);
|
||||
|
||||
$this->withToken($token)
|
||||
->putJson("/api/settings/{$plain->id}", [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Meeting;
|
||||
use App\Models\MeetingType;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sprint;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkspaceFeaturesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function authenticatedAdmin(): array
|
||||
{
|
||||
$user = User::factory()->create(['status' => 'active']);
|
||||
$this->grantAdminRole($user);
|
||||
|
||||
return [$user, $user->createToken('workspace-test')->plainTextToken];
|
||||
}
|
||||
|
||||
private function project(User $user): Project
|
||||
{
|
||||
return Project::create([
|
||||
'title' => 'پروژه تست Workspace',
|
||||
'project_manager_id' => $user->id,
|
||||
'created_by' => $user->id,
|
||||
'start_date' => now()->startOfMonth()->toDateString(),
|
||||
'end_date' => now()->endOfMonth()->toDateString(),
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_calendar_aggregates_real_project_sprint_task_and_meeting_events(): void
|
||||
{
|
||||
[$user, $token] = $this->authenticatedAdmin();
|
||||
$project = $this->project($user);
|
||||
$sprint = Sprint::create([
|
||||
'title' => 'Sprint تقویم',
|
||||
'project_id' => $project->id,
|
||||
'start_date' => now()->startOfWeek()->toDateString(),
|
||||
'end_date' => now()->endOfWeek()->toDateString(),
|
||||
'status' => 'active',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
Task::create([
|
||||
'title' => 'تسک سررسیددار',
|
||||
'project_id' => $project->id,
|
||||
'assignee_id' => $user->id,
|
||||
'reporter_id' => $user->id,
|
||||
'created_by' => $user->id,
|
||||
'due_date' => now()->toDateString(),
|
||||
'status' => 'todo',
|
||||
]);
|
||||
Meeting::create([
|
||||
'title' => 'جلسه تقویم',
|
||||
'project_id' => $project->id,
|
||||
'sprint_id' => $sprint->id,
|
||||
'meeting_type' => 'daily_standup',
|
||||
'status' => 'scheduled',
|
||||
'date' => now()->toDateString(),
|
||||
'start_time' => '09:30',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
$response = $this->withToken($token)->getJson('/api/calendar/events?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString());
|
||||
|
||||
$response->assertOk()->assertJsonPath('success', true);
|
||||
$types = collect($response->json('data'))->pluck('event_type');
|
||||
$this->assertTrue($types->contains('meeting'));
|
||||
$this->assertTrue($types->contains('task'));
|
||||
$this->assertTrue($types->contains('sprint'));
|
||||
$this->assertTrue($types->contains('project'));
|
||||
}
|
||||
|
||||
public function test_notification_can_be_previewed_archived_restored_and_soft_deleted(): void
|
||||
{
|
||||
[$user, $token] = $this->authenticatedAdmin();
|
||||
$notification = Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'system',
|
||||
'title' => 'اعلان تست',
|
||||
'body' => 'جزئیات اعلان',
|
||||
]);
|
||||
|
||||
$this->withToken($token)
|
||||
->getJson("/api/notifications/{$notification->id}/preview")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.preview.body', 'جزئیات اعلان');
|
||||
|
||||
$this->withToken($token)
|
||||
->patchJson("/api/notifications/{$notification->id}/archive")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.archived_at', fn ($value) => filled($value));
|
||||
|
||||
$this->withToken($token)
|
||||
->patchJson("/api/notifications/{$notification->id}/restore")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.archived_at', null);
|
||||
|
||||
$this->withToken($token)->deleteJson("/api/notifications/{$notification->id}")->assertOk();
|
||||
$this->assertSoftDeleted('notifications', ['id' => $notification->id]);
|
||||
}
|
||||
|
||||
public function test_sprint_creation_can_create_configured_meetings_and_workspace(): void
|
||||
{
|
||||
[$user, $token] = $this->authenticatedAdmin();
|
||||
$project = $this->project($user);
|
||||
$this->assertGreaterThan(0, MeetingType::count());
|
||||
|
||||
$response = $this->withToken($token)->postJson('/api/sprints', [
|
||||
'title' => 'Sprint یکپارچه',
|
||||
'project_id' => $project->id,
|
||||
'start_date' => now()->addDay()->toDateString(),
|
||||
'end_date' => now()->addDays(14)->toDateString(),
|
||||
'member_ids' => [$user->id],
|
||||
'meeting_setup' => [[
|
||||
'type_key' => 'sprint_planning',
|
||||
'date' => now()->addDay()->toDateString(),
|
||||
'start_time' => '10:00',
|
||||
]],
|
||||
])->assertCreated();
|
||||
|
||||
$sprintId = $response->json('data.id');
|
||||
$this->assertDatabaseHas('meetings', ['sprint_id' => $sprintId, 'meeting_type' => 'sprint_planning']);
|
||||
|
||||
$this->withToken($token)
|
||||
->getJson("/api/sprints/{$sprintId}/workspace")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.sprint.id', $sprintId)
|
||||
->assertJsonCount(1, 'data.meetings');
|
||||
}
|
||||
|
||||
public function test_meeting_lifecycle_and_structured_outputs_are_available_in_workspace(): void
|
||||
{
|
||||
[$user, $token] = $this->authenticatedAdmin();
|
||||
$project = $this->project($user);
|
||||
$meeting = Meeting::create([
|
||||
'title' => 'جلسه چرخه عمر',
|
||||
'project_id' => $project->id,
|
||||
'meeting_type' => 'adhoc',
|
||||
'status' => 'scheduled',
|
||||
'date' => now()->toDateString(),
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/start")->assertOk()->assertJsonPath('data.status', 'in_progress');
|
||||
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/decisions", ['title' => 'تصمیم قطعی'])->assertCreated();
|
||||
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/structured-action-items", [
|
||||
'title' => 'اقدام بعدی',
|
||||
'owner_id' => $user->id,
|
||||
'due_date' => now()->addDay()->toDateString(),
|
||||
])->assertCreated();
|
||||
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/blockers", ['title' => 'مانع تست'])->assertCreated();
|
||||
$this->withToken($token)->postJson("/api/meetings/{$meeting->id}/complete", ['summary' => 'خلاصه نهایی'])->assertOk()->assertJsonPath('data.status', 'completed');
|
||||
|
||||
$this->withToken($token)
|
||||
->getJson("/api/meetings/{$meeting->id}/workspace")
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data.structured_decisions')
|
||||
->assertJsonCount(1, 'data.structured_action_items')
|
||||
->assertJsonCount(1, 'data.blockers');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Production launch checklist
|
||||
|
||||
## Required environment
|
||||
|
||||
- `APP_ENV=production`, `APP_DEBUG=false`, HTTPS `APP_URL`
|
||||
- a secret generated `APP_KEY`
|
||||
- `LOG_LEVEL=warning` or stricter with centralized retention and alerting
|
||||
- MySQL 8 with TLS where traffic leaves the host
|
||||
- `SESSION_SECURE_COOKIE=true`
|
||||
- explicit trusted proxy, CORS, Sanctum stateful-domain, and cookie-domain values
|
||||
- real SMTP credentials stored in the deployment secret manager
|
||||
- dedicated cache/queue stores and supervised queue workers
|
||||
|
||||
## Release gates
|
||||
|
||||
- Clean reviewed commit and green CI.
|
||||
- Dependency audit has no unresolved high/critical advisory.
|
||||
- Production build artifact is generated from the reviewed commit.
|
||||
- Database backup and restore drill are successful.
|
||||
- Migration, rollback, incident owner, and maintenance window are documented.
|
||||
- Rate limiting, access-control regression tests, file authorization, and password reset pass.
|
||||
- Health check `/up` is monitored externally.
|
||||
|
||||
## Post-deploy checks
|
||||
|
||||
- Login/logout/password reset and forced reauthentication after password change.
|
||||
- Role and record-level authorization with two users from different projects.
|
||||
- Project, task, sprint, meeting, file, notification, and settings workflows.
|
||||
- Queue failures, HTTP 5xx, database latency, disk use, and backup freshness.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# SQLite to MySQL controlled migration
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Work from a dedicated branch and record the source commit.
|
||||
- Put the application in a planned write freeze.
|
||||
- Keep the source SQLite file outside the web root.
|
||||
- Create an immutable copy and verify its SHA-256 hash.
|
||||
- Provision an empty MySQL 8 database using `utf8mb4` and `utf8mb4_unicode_ci`.
|
||||
- Use a dedicated least-privilege migration account. Do not commit its password.
|
||||
|
||||
## Dry run
|
||||
|
||||
Configure only the `MIGRATION_MYSQL_*` variables. Keep `DB_CONNECTION=sqlite`.
|
||||
|
||||
```sh
|
||||
php artisan migrate --database=migration_mysql --force
|
||||
php artisan db:transfer-sqlite-to-mysql
|
||||
```
|
||||
|
||||
The transfer command does not write unless `--execute` is supplied.
|
||||
|
||||
## Transfer
|
||||
|
||||
```sh
|
||||
php artisan db:transfer-sqlite-to-mysql --execute --truncate
|
||||
php artisan db:transfer-sqlite-to-mysql --execute
|
||||
```
|
||||
|
||||
The second execution proves idempotency. Both runs must finish with identical source
|
||||
and target row counts. Sessions, access tokens, cache, queues, reset tokens, and the
|
||||
migration repository are intentionally excluded; users must authenticate again.
|
||||
|
||||
## Validation gates
|
||||
|
||||
- All migrations succeed on an empty MySQL database.
|
||||
- Every transferred table has equal source and target row counts.
|
||||
- Foreign-key validation reports no orphan rows.
|
||||
- Unique business keys have no duplicates.
|
||||
- JSON columns contain valid JSON.
|
||||
- Dates, times, decimals, booleans, Persian text, and emoji round-trip correctly.
|
||||
- Backend, frontend build, and browser tests pass with MySQL as the active test database.
|
||||
- A manual smoke test covers login, project/task CRUD, sprint, meeting, files, notifications, settings, and reports.
|
||||
|
||||
## Cutover
|
||||
|
||||
1. Enable maintenance/write-freeze mode.
|
||||
2. Take and hash a final SQLite backup.
|
||||
3. Re-run the idempotent transfer without `--truncate`.
|
||||
4. Run validation gates.
|
||||
5. Change production secrets to `DB_CONNECTION=mysql`.
|
||||
6. Clear configuration cache, restart application and queue workers, then run health and smoke checks.
|
||||
7. Retain the SQLite source read-only for the defined rollback period.
|
||||
|
||||
## Rollback
|
||||
|
||||
If any validation or smoke check fails:
|
||||
|
||||
1. Stop writes immediately.
|
||||
2. Restore the previous application configuration and `DB_CONNECTION=sqlite`.
|
||||
3. Restore the verified final SQLite copy if the source was touched.
|
||||
4. Restart application and workers.
|
||||
5. Run `/up`, authentication, project, task, and file smoke checks.
|
||||
6. Preserve MySQL unchanged for incident analysis; never merge partial target data back manually.
|
||||
|
|
@ -10,6 +10,8 @@ lerna-debug.log*
|
|||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
test-results
|
||||
playwright-report
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('login password can be shown and hidden', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
const password = page.locator('#login-password');
|
||||
await password.fill('secret-password');
|
||||
await expect(password).toHaveAttribute('type', 'password');
|
||||
await page.getByRole('button', { name: 'نمایش رمز عبور' }).click();
|
||||
await expect(password).toHaveAttribute('type', 'text');
|
||||
await page.getByRole('button', { name: 'مخفی کردن رمز عبور' }).click();
|
||||
await expect(password).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('dashboard command surfaces and settings render without runtime errors', async ({ page }, testInfo) => {
|
||||
const runtimeErrors = [];
|
||||
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
const user = { id: 1, name: 'مدیر تست رابط', email: 'ui@example.test', permissions: ['reports.view', 'settings.view', 'notifications.view'] };
|
||||
await page.addInitScript(({ testUser }) => {
|
||||
localStorage.setItem('token', 'visual-smoke-token');
|
||||
localStorage.setItem('user', JSON.stringify(testUser));
|
||||
}, { testUser: user });
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
if (path === '/api/user') return route.fulfill({ json: { success: true, data: user } });
|
||||
if (path === '/api/dashboard/monitoring') return route.fulfill({ json: { success: true, data: {
|
||||
generated_at: new Date().toISOString(),
|
||||
summary: { active_projects: 4, projects_at_risk: 1, open_tasks: 18, overdue_tasks: 3, blocked_tasks: 2, active_sprints: 2, meetings_today: 3, overdue_action_items: 1, open_blockers: 2 },
|
||||
projects: [{ id: 1, title: 'پروژه نمونه رابط', status: 'in_progress', health: 'at_risk', progress: 62, total_tasks: 14, completed_tasks: 8, overdue_tasks: 2, blocked_tasks: 1 }],
|
||||
sprints: [{ id: 1, title: 'Sprint رابط', project: 'پروژه نمونه رابط', progress: 58, expected_progress: 65, remaining_days: 5, health: 'on_track', blocked_tasks: 0 }],
|
||||
workload: [{ id: 1, name: 'کاربر نمونه', open_tasks: 7, overdue_tasks: 1, load: 'high' }],
|
||||
attention: [],
|
||||
upcoming_meetings: [],
|
||||
trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }],
|
||||
} } });
|
||||
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/settings') return route.fulfill({ json: { success: true, data: [
|
||||
{ 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' },
|
||||
] } });
|
||||
return route.fulfill({ json: { success: true, data: [] } });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible();
|
||||
await expect(page.locator('img.sidebar-brand-mark')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'تقویم رویدادها' }).click();
|
||||
await expect(page.getByRole('region', { name: 'تقویم رویدادها' })).toBeVisible();
|
||||
await expect(page.getByText('برنامه روز')).toBeVisible();
|
||||
await page.mouse.click(900, 760);
|
||||
await expect(page.locator('.calendar-popover')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'اعلانها' }).click();
|
||||
await expect(page.getByLabel('مرکز اعلانها')).toBeVisible();
|
||||
await expect(page.getByText(/برای آرشیو به راست/)).toBeVisible();
|
||||
await page.mouse.click(900, 760);
|
||||
await expect(page.locator('.notification-center')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'ذخیره هویت' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'اطلاعات کاری' }).click();
|
||||
await expect(page.getByRole('button', { name: 'ذخیره اطلاعات کاری' })).toBeVisible();
|
||||
const desktopModalMetrics = await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => {
|
||||
const body = element.querySelector('.modal-body');
|
||||
const styles = getComputedStyle(element);
|
||||
return {
|
||||
overflow: styles.overflowY,
|
||||
bodyOverflow: getComputedStyle(body).overflowY,
|
||||
radius: [styles.borderTopLeftRadius, styles.borderTopRightRadius, styles.borderBottomRightRadius, styles.borderBottomLeftRadius],
|
||||
};
|
||||
});
|
||||
expect(desktopModalMetrics.overflow).toBe('hidden');
|
||||
expect(desktopModalMetrics.bodyOverflow).toBe('hidden');
|
||||
expect(desktopModalMetrics.radius.every((value) => parseFloat(value) > 0)).toBe(true);
|
||||
await page.getByRole('button', { name: 'بستن', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeHidden();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible();
|
||||
await expect(page.getByText('شخصیسازی')).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop'));
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.getByRole('button', { name: 'باز کردن منوی اصلی' }).click();
|
||||
await expect(page.locator('.app-sidebar.mobile-open')).toBeVisible();
|
||||
await expect(page.locator('.app-sidebar.mobile-open').getByText('پروژهها')).toBeVisible();
|
||||
await page.locator('.app-sidebar.mobile-open').getByRole('button', { name: 'بستن منوی اصلی' }).click();
|
||||
await expect(page.locator('.app-sidebar.mobile-open')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible();
|
||||
expect(await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => element.getBoundingClientRect().height <= window.innerHeight)).toBe(true);
|
||||
await page.getByRole('button', { name: 'بستن', exact: true }).click();
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-dashboard.png'), fullPage: true });
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/brand/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="theme-color" content="#061a3a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>مدیریت پروژه | سامانه مدیریت پروژههای سازمانی</title>
|
||||
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" />
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"recharts": "^3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
|
|
@ -434,6 +435,22 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
|
|
@ -1824,6 +1841,53 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.18.1",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"recharts": "^3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4004',
|
||||
locale: 'fa-IR',
|
||||
timezoneId: 'Asia/Tehran',
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
colorScheme: 'light',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
launchOptions: {
|
||||
executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH
|
||||
|| (process.platform === 'win32' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : undefined),
|
||||
},
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'php ../backend/artisan serve --host=127.0.0.1 --port=8000',
|
||||
url: 'http://127.0.0.1:8000',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev -- --host=127.0.0.1',
|
||||
url: 'http://127.0.0.1:4004',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
پس از عرض: | ارتفاع: | اندازه: 51 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 330 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 45 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 873 B |
|
پس از عرض: | ارتفاع: | اندازه: 2.5 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
پس از عرض: | ارتفاع: | اندازه: 1.3 MiB |
|
|
@ -7,13 +7,19 @@
|
|||
"display": "standalone",
|
||||
"start_url": "/pwa",
|
||||
"scope": "/",
|
||||
"theme_color": "#2563eb",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#061a3a",
|
||||
"background_color": "#061a3a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/brand/app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/brand/app-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,61 +3,65 @@ import { Toaster } from 'react-hot-toast';
|
|||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Projects from './pages/Projects';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import Tasks from './pages/Tasks';
|
||||
import Kanban from './pages/Kanban';
|
||||
import Backlog from './pages/Backlog';
|
||||
import Sprints from './pages/Sprints';
|
||||
import Team from './pages/Team';
|
||||
import MeetingList from './pages/MeetingList';
|
||||
import Files from './pages/Files';
|
||||
import Reports from './pages/Reports';
|
||||
import Notifications from './pages/Notifications';
|
||||
import Roles from './pages/Roles';
|
||||
import Settings from './pages/Settings';
|
||||
import Profile from './pages/Profile';
|
||||
import Organization from './pages/Organization';
|
||||
import PwaLayout from './pwa/PwaLayout';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode';
|
||||
import './styles/pwa.css';
|
||||
|
||||
const pageTitles = {
|
||||
'/': 'داشبورد',
|
||||
'/projects': 'پروژهها',
|
||||
'/tasks': 'تسکها',
|
||||
'/kanban': 'برد کاری',
|
||||
'/backlog': 'بکلاگ',
|
||||
'/sprints': 'اسپرینتها',
|
||||
'/team': 'اعضای تیم',
|
||||
'/meetings': 'جلسات',
|
||||
'/files': 'فایلها',
|
||||
'/reports': 'گزارشها',
|
||||
'/notifications': 'اعلانها',
|
||||
'/roles': 'نقشها و دسترسیها',
|
||||
'/organization': 'ساختار سازمانی',
|
||||
'/settings': 'تنظیمات',
|
||||
'/profile': 'پروفایل کاربری',
|
||||
};
|
||||
const Login = lazy(() => import('./pages/Login'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Projects = lazy(() => import('./pages/Projects'));
|
||||
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'));
|
||||
const Tasks = lazy(() => import('./pages/Tasks'));
|
||||
const Kanban = lazy(() => import('./pages/Kanban'));
|
||||
const Backlog = lazy(() => import('./pages/Backlog'));
|
||||
const Sprints = lazy(() => import('./pages/Sprints'));
|
||||
const Team = lazy(() => import('./pages/Team'));
|
||||
const MeetingList = lazy(() => import('./pages/MeetingList'));
|
||||
const Files = lazy(() => import('./pages/Files'));
|
||||
const Reports = lazy(() => import('./pages/Reports'));
|
||||
const Notifications = lazy(() => import('./pages/Notifications'));
|
||||
const Roles = lazy(() => import('./pages/Roles'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const Organization = lazy(() => import('./pages/Organization'));
|
||||
const SprintWorkspace = lazy(() => import('./pages/SprintWorkspace'));
|
||||
const MeetingWorkspace = lazy(() => import('./pages/MeetingWorkspace'));
|
||||
const PwaLayout = lazy(() => import('./pwa/PwaLayout'));
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>در حال بارگذاری...</div>;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
return children;
|
||||
function PageFallback() {
|
||||
return <div className="page-loading" role="status">در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
function AppLayout({ children, title }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') setMobileNavOpen(false);
|
||||
};
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 768) setMobileNavOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleMobileNav = () => {
|
||||
setCollapsed(false);
|
||||
setMobileNavOpen((current) => !current);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||
<div className="main-content" style={{ marginRight: collapsed ? '72px' : 'var(--sidebar-width)', transition: 'margin-right 0.2s ease' }}>
|
||||
<Header title={title} />
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
|
||||
{mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />}
|
||||
<div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} onToggleMobileNav={toggleMobileNav} />
|
||||
<div className="main-inner">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -77,10 +81,12 @@ function AppRoutes() {
|
|||
|
||||
if (!user) {
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +94,7 @@ function AppRoutes() {
|
|||
if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} />
|
||||
<Route path="/pwa/*" element={<PwaLayout />} />
|
||||
|
|
@ -98,8 +105,10 @@ function AppRoutes() {
|
|||
<Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} />
|
||||
<Route path="/backlog" element={<AppLayout title="بکلاگ"><Backlog /></AppLayout>} />
|
||||
<Route path="/sprints" element={<AppLayout title="اسپرینتها"><Sprints /></AppLayout>} />
|
||||
<Route path="/sprints/:id/workspace" element={<AppLayout title="مرکز فرمان Sprint"><SprintWorkspace /></AppLayout>} />
|
||||
<Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} />
|
||||
<Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} />
|
||||
<Route path="/meetings/:id/workspace" element={<AppLayout title="فضای کاری جلسه"><MeetingWorkspace /></AppLayout>} />
|
||||
<Route path="/files" element={<AppLayout title="فایلها"><Files /></AppLayout>} />
|
||||
<Route path="/reports" element={<AppLayout title="گزارشها"><Reports /></AppLayout>} />
|
||||
<Route path="/notifications" element={<AppLayout title="اعلانها"><Notifications /></AppLayout>} />
|
||||
|
|
@ -109,6 +118,7 @@ function AppRoutes() {
|
|||
<Route path="/profile" element={<AppLayout title="پروفایل کاربری"><Profile /></AppLayout>} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Circle, RefreshCw } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import {
|
||||
getJalaliWeekday,
|
||||
jalaliMonthLength,
|
||||
jalaliToIso,
|
||||
todayJalaliParts,
|
||||
} from '../utils/date';
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
|
||||
const weekdays = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||
const typeLabels = { meeting: 'جلسه', task: 'تسک', sprint: 'اسپرینت', project: 'پروژه', action_item: 'اقدام' };
|
||||
|
||||
function moveMonth(view, delta) {
|
||||
let jm = view.jm + delta;
|
||||
let jy = view.jy;
|
||||
if (jm > 12) { jm = 1; jy += 1; }
|
||||
if (jm < 1) { jm = 12; jy -= 1; }
|
||||
return { jy, jm };
|
||||
}
|
||||
|
||||
export default function CalendarPopover({ onSelectEvent }) {
|
||||
const today = useMemo(() => todayJalaliParts(), []);
|
||||
const [view, setView] = useState({ jy: today.jy, jm: today.jm });
|
||||
const [selectedIso, setSelectedIso] = useState(jalaliToIso(today.jy, today.jm, today.jd));
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeTypes, setActiveTypes] = useState(Object.keys(typeLabels));
|
||||
const [agendaPage, setAgendaPage] = useState(0);
|
||||
|
||||
const monthLength = jalaliMonthLength(view.jy, view.jm);
|
||||
const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1);
|
||||
const from = jalaliToIso(view.jy, view.jm, 1);
|
||||
const to = jalaliToIso(view.jy, view.jm, monthLength);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.get('/calendar/events', { params: { from, to } });
|
||||
setEvents(data.data || []);
|
||||
} catch {
|
||||
setError('دریافت رویدادها ناموفق بود.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [from, to]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
const eventsByDate = useMemo(() => events.reduce((result, event) => {
|
||||
const date = String(event.start_at || '').slice(0, 10);
|
||||
if (!result[date]) result[date] = [];
|
||||
result[date].push(event);
|
||||
return result;
|
||||
}, {}), [events]);
|
||||
|
||||
const selectedEvents = (eventsByDate[selectedIso] || []).filter((event) => activeTypes.includes(event.event_type));
|
||||
const agendaPageSize = 2;
|
||||
const agendaPageCount = Math.max(1, Math.ceil(selectedEvents.length / agendaPageSize));
|
||||
const visibleEvents = selectedEvents.slice(agendaPage * agendaPageSize, agendaPage * agendaPageSize + agendaPageSize);
|
||||
const cells = [
|
||||
...Array.from({ length: firstWeekday }, () => null),
|
||||
...Array.from({ length: monthLength }, (_, index) => index + 1),
|
||||
];
|
||||
|
||||
const goToday = () => {
|
||||
setView({ jy: today.jy, jm: today.jm });
|
||||
setSelectedIso(jalaliToIso(today.jy, today.jm, today.jd));
|
||||
};
|
||||
|
||||
const toggleType = (type) => {
|
||||
setActiveTypes((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type]);
|
||||
};
|
||||
|
||||
useEffect(() => { setAgendaPage(0); }, [selectedIso, activeTypes]);
|
||||
|
||||
return (
|
||||
<section className="calendar-popover" aria-label="تقویم رویدادها">
|
||||
<header className="calendar-popover-head">
|
||||
<div>
|
||||
<strong>{monthNames[view.jm - 1]} {view.jy}</strong>
|
||||
<span>{events.length} رویداد</span>
|
||||
</div>
|
||||
<div className="calendar-controls">
|
||||
<button type="button" onClick={() => setView(moveMonth(view, -1))} aria-label="ماه قبل"><ChevronRight size={18} /></button>
|
||||
<button type="button" className="calendar-today" onClick={goToday}>امروز</button>
|
||||
<button type="button" onClick={() => setView(moveMonth(view, 1))} aria-label="ماه بعد"><ChevronLeft size={18} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="calendar-weekdays">
|
||||
{weekdays.map((day) => <span key={day}>{day}</span>)}
|
||||
</div>
|
||||
<div className="calendar-grid">
|
||||
{cells.map((day, index) => {
|
||||
if (!day) return <span key={`blank-${index}`} className="calendar-day blank" />;
|
||||
const iso = jalaliToIso(view.jy, view.jm, day);
|
||||
const dayEvents = (eventsByDate[iso] || []).filter((event) => activeTypes.includes(event.event_type));
|
||||
const isToday = iso === jalaliToIso(today.jy, today.jm, today.jd);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={iso}
|
||||
className={`calendar-day ${selectedIso === iso ? 'selected' : ''} ${isToday ? 'today' : ''}`}
|
||||
onClick={() => setSelectedIso(iso)}
|
||||
aria-label={`${day} ${monthNames[view.jm - 1]}، ${dayEvents.length} رویداد`}
|
||||
>
|
||||
<span>{day}</span>
|
||||
{dayEvents.length > 0 && <i>{dayEvents.slice(0, 3).map((event) => <b key={event.id} className={`tone-${event.color_token}`} />)}</i>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="calendar-filters" aria-label="فیلتر نوع رویداد">
|
||||
{Object.entries(typeLabels).map(([type, label]) => (
|
||||
<button type="button" key={type} className={activeTypes.includes(type) ? 'active' : ''} onClick={() => toggleType(type)}>
|
||||
<Circle size={8} fill="currentColor" />{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calendar-agenda">
|
||||
<div className="calendar-agenda-title">
|
||||
<strong>برنامه روز</strong>
|
||||
<span>{selectedEvents.length} مورد</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="calendar-state"><RefreshCw className="spin" size={18} /> در حال دریافت...</div>
|
||||
) : error ? (
|
||||
<button type="button" className="calendar-state error" onClick={loadEvents}>{error} تلاش دوباره</button>
|
||||
) : selectedEvents.length === 0 ? (
|
||||
<div className="calendar-state"><CalendarDays size={20} /> رویدادی برای این روز وجود ندارد.</div>
|
||||
) : (
|
||||
<>
|
||||
{visibleEvents.map((event) => (
|
||||
<button type="button" className="calendar-event" key={event.id} onClick={() => onSelectEvent(event)}>
|
||||
<i className={`tone-${event.color_token}`} />
|
||||
<span>
|
||||
<strong>{event.title}</strong>
|
||||
<small>{typeLabels[event.event_type]}{event.project?.title ? ` · ${event.project.title}` : ''}</small>
|
||||
</span>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
))}
|
||||
{agendaPageCount > 1 && (
|
||||
<div className="popover-pagination">
|
||||
<button type="button" disabled={agendaPage === 0} onClick={() => setAgendaPage((page) => Math.max(0, page - 1))}><ChevronRight size={16} /> قبلی</button>
|
||||
<span>{agendaPage + 1} از {agendaPageCount}</span>
|
||||
<button type="button" disabled={agendaPage >= agendaPageCount - 1} onClick={() => setAgendaPage((page) => Math.min(agendaPageCount - 1, page + 1))}>بعدی <ChevronLeft size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { ArrowLeft, CalendarDays, ExternalLink, FolderKanban, Info, Timer } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Modal from './Modal';
|
||||
import { formatJalaliDateTime } from '../utils/date';
|
||||
|
||||
const typeLabels = {
|
||||
meeting: 'جلسه',
|
||||
task: 'تسک',
|
||||
sprint: 'اسپرینت',
|
||||
project: 'پروژه',
|
||||
action_item: 'اقدام',
|
||||
notification: 'اعلان',
|
||||
};
|
||||
|
||||
export default function EntityPreviewModal({ item, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
if (!item) return null;
|
||||
|
||||
const title = item.title || item.preview?.title || 'جزئیات';
|
||||
const body = item.body || item.preview?.body || item.message;
|
||||
const entityType = item.entity_type || item.event_type || 'notification';
|
||||
const targetUrl = item.target_url;
|
||||
const previewEntries = item.preview && !item.preview.title
|
||||
? Object.entries(item.preview).filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
: [];
|
||||
|
||||
const goToTarget = () => {
|
||||
if (!targetUrl) return;
|
||||
onClose();
|
||||
navigate(targetUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="مشاهده سریع"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>بستن</button>
|
||||
{targetUrl && (
|
||||
<button type="button" className="btn btn-primary" onClick={goToTarget}>
|
||||
رفتن به بخش مربوطه
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<article className="entity-preview">
|
||||
<div className={`entity-preview-icon tone-${item.color_token || 'primary'}`}>
|
||||
<Info size={24} />
|
||||
</div>
|
||||
<div className="entity-preview-heading">
|
||||
<span className="badge badge-primary">{typeLabels[entityType] || entityType}</span>
|
||||
<h2>{title}</h2>
|
||||
{body && <p>{body}</p>}
|
||||
</div>
|
||||
|
||||
<div className="entity-preview-meta">
|
||||
{(item.start_at || item.created_at) && (
|
||||
<div><CalendarDays size={16} /><span>{formatJalaliDateTime(item.start_at || item.created_at)}</span></div>
|
||||
)}
|
||||
{item.end_at && (
|
||||
<div><Timer size={16} /><span>تا {formatJalaliDateTime(item.end_at)}</span></div>
|
||||
)}
|
||||
{item.project?.title && (
|
||||
<div><FolderKanban size={16} /><span>{item.project.title}</span></div>
|
||||
)}
|
||||
{item.sprint?.title && (
|
||||
<div><ExternalLink size={16} /><span>{item.sprint.title}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewEntries.length > 0 && (
|
||||
<dl className="entity-preview-details">
|
||||
{previewEntries.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</article>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,142 +1,161 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Bell, CalendarDays, LogOut, Menu, Search, Settings } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../services/api';
|
||||
import { Search, Bell, CheckCheck } from 'lucide-react';
|
||||
import ThemeModeToggle from './ThemeModeToggle';
|
||||
import CalendarPopover from './CalendarPopover';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
import EntityPreviewModal from './EntityPreviewModal';
|
||||
import useDismissibleLayer from '../hooks/useDismissibleLayer';
|
||||
import Modal from './Modal';
|
||||
import Profile from '../pages/Profile';
|
||||
|
||||
export default function Header({ title }) {
|
||||
export default function Header({ title, collapsed = false, mobileNavOpen = false, onToggleMobileNav }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [activeLayer, setActiveLayer] = useState(null);
|
||||
const [previewItem, setPreviewItem] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const calendarButtonRef = useRef(null);
|
||||
const calendarPanelRef = useRef(null);
|
||||
const notificationButtonRef = useRef(null);
|
||||
const notificationPanelRef = useRef(null);
|
||||
const profileButtonRef = useRef(null);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const closeLayer = useCallback(() => setActiveLayer(null), []);
|
||||
const layerRefs = activeLayer === 'calendar'
|
||||
? [calendarButtonRef, calendarPanelRef]
|
||||
: [notificationButtonRef, notificationPanelRef];
|
||||
useDismissibleLayer(activeLayer === 'calendar' || activeLayer === 'notifications', layerRefs, closeLayer);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/notifications', { params: { per_page: 8 } });
|
||||
setNotifications(data.data || []);
|
||||
setUnreadCount(data.meta?.unread_count ?? (data.data || []).filter((item) => !item.read_at).length);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const timer = setInterval(fetchNotifications, 60000);
|
||||
return () => clearInterval(timer);
|
||||
const { data } = await api.get('/notifications', { params: { per_page: 1, unread: 1 } });
|
||||
setUnreadCount(data.meta?.unread_count || 0);
|
||||
} catch {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
const q = e.target.value;
|
||||
setSearchQuery(q);
|
||||
if (q.length < 2) { setSearchResults([]); return; }
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
const timer = window.setInterval(fetchUnreadCount, 60000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
const handleSearch = async (event) => {
|
||||
const query = event.target.value;
|
||||
setSearchQuery(query);
|
||||
if (query.length < 2) { setSearchResults([]); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await api.get(`/search?q=${q}`);
|
||||
setSearchResults(res.data.data || []);
|
||||
} catch {} finally { setSearching(false); }
|
||||
};
|
||||
|
||||
const notificationUrl = (notification) => notification.data?.url || (notification.type?.includes('meeting') ? '/meetings' : notification.type?.includes('task') || notification.type === 'mention' ? '/kanban' : '/notifications');
|
||||
|
||||
const markNotificationRead = async (notification) => {
|
||||
if (!notification.read_at) {
|
||||
try {
|
||||
await api.post(`/notifications/${notification.id}/read`);
|
||||
} catch {}
|
||||
const { data } = await api.get('/search', { params: { q: query } });
|
||||
setSearchResults(data.data || []);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
setShowNotifications(false);
|
||||
fetchNotifications();
|
||||
navigate(notificationUrl(notification));
|
||||
};
|
||||
|
||||
const markAllNotificationsRead = async () => {
|
||||
try {
|
||||
await api.post('/notifications/read-all');
|
||||
fetchNotifications();
|
||||
} catch {}
|
||||
};
|
||||
const toggleLayer = (name) => setActiveLayer((current) => current === name ? null : name);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const openPreview = (item) => {
|
||||
closeLayer();
|
||||
setPreviewItem(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<header style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 'var(--sidebar-width)', height: 'var(--header-height)',
|
||||
background: 'var(--surface)', borderBottom: '1px solid var(--gray-200)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '0 1.5rem', zIndex: 50, gap: '1rem',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--gray-900)' }}>{title}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flex: 1, maxWidth: 400, position: 'relative' }}>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
placeholder="جستجوی پروژه، تسک، کاربر..."
|
||||
className="form-input"
|
||||
style={{ paddingRight: '2.5rem' }}
|
||||
/>
|
||||
<Search size={18} style={{ position: 'absolute', right: '0.75rem', color: 'var(--gray-400)' }} />
|
||||
<>
|
||||
<header className="app-header" style={{ right: collapsed ? 72 : 'var(--sidebar-width)' }}>
|
||||
<h1>{title}</h1>
|
||||
<div className="header-search">
|
||||
<input value={searchQuery} onChange={handleSearch} placeholder="جستجوی پروژه، تسک، کاربر..." className="form-input" />
|
||||
<Search size={18} />
|
||||
{searching && <span className="header-searching">...</span>}
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', zIndex: 100, maxHeight: 300, overflow: 'auto', marginTop: 4 }}>
|
||||
{searchResults.map((item, i) => (
|
||||
<Link key={i} to={item.url || '#'} style={{ display: 'block', padding: '0.75rem 1rem', borderBottom: '1px solid var(--gray-100)', fontSize: '0.875rem', color: 'var(--gray-700)' }}>
|
||||
{item.title || item.name}
|
||||
<div className="header-search-results">
|
||||
{searchResults.slice(0, 5).map((item, index) => (
|
||||
<Link key={`${item.url}-${index}`} to={item.url || '#'} onClick={() => { setSearchResults([]); setSearchQuery(''); }}>
|
||||
<strong>{item.title || item.name}</strong>
|
||||
{item.type && <small>{item.type}</small>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`header-icon-button mobile-nav-toggle ${mobileNavOpen ? 'active' : ''}`}
|
||||
onClick={onToggleMobileNav}
|
||||
aria-label={mobileNavOpen ? 'بستن منوی اصلی' : 'باز کردن منوی اصلی'}
|
||||
aria-controls="main-navigation-drawer"
|
||||
aria-expanded={mobileNavOpen}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="header-layer">
|
||||
<button ref={calendarButtonRef} type="button" className={`header-icon-button ${activeLayer === 'calendar' ? 'active' : ''}`} onClick={() => toggleLayer('calendar')} aria-label="تقویم رویدادها" aria-expanded={activeLayer === 'calendar'}>
|
||||
<CalendarDays size={20} />
|
||||
</button>
|
||||
{activeLayer === 'calendar' && <div ref={calendarPanelRef} className="header-popover calendar-layer"><CalendarPopover onSelectEvent={openPreview} /></div>}
|
||||
</div>
|
||||
|
||||
<ThemeModeToggle compact />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="header-bell" onClick={() => { setShowNotifications(!showNotifications); if (!showNotifications) fetchNotifications(); }} title="اعلانها">
|
||||
|
||||
<div className="header-layer">
|
||||
<button ref={notificationButtonRef} type="button" className={`header-icon-button ${activeLayer === 'notifications' ? 'active' : ''}`} onClick={() => toggleLayer('notifications')} aria-label="اعلانها" aria-expanded={activeLayer === 'notifications'}>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && <span className="header-bell-dot" />}
|
||||
{unreadCount > 0 && <span className="header-badge">{unreadCount > 99 ? '۹۹+' : unreadCount}</span>}
|
||||
</button>
|
||||
{showNotifications && (
|
||||
<div className="notification-popover">
|
||||
<div className="notification-popover-head">
|
||||
<strong>اعلانها</strong>
|
||||
{unreadCount > 0 && <button className="btn btn-sm btn-outline" onClick={markAllNotificationsRead}><CheckCheck size={14} /> خواندن همه</button>}
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notification-empty">اعلان جدیدی ندارید</div>
|
||||
) : notifications.map((notification) => (
|
||||
<button key={notification.id} className={`notification-item ${notification.read_at ? '' : 'unread'}`} onClick={() => markNotificationRead(notification)}>
|
||||
<span>{notification.title || notification.message}</span>
|
||||
<small>{notification.read_at ? 'خواندهشده' : 'جدید'}</small>
|
||||
</button>
|
||||
))}
|
||||
<Link className="notification-all-link" to="/notifications" onClick={() => setShowNotifications(false)}>مشاهده همه اعلانها</Link>
|
||||
{activeLayer === 'notifications' && (
|
||||
<div ref={notificationPanelRef} className="header-popover notification-layer">
|
||||
<NotificationCenter onOpenNotification={openPreview} onCountChange={setUnreadCount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div onClick={() => setShowMenu(!showMenu)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt={user?.name || 'avatar'} /> : <div className="avatar avatar-sm">{user?.name?.[0] || '?'}</div>}
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--gray-700)' }}>{user?.name}</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 8, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', minWidth: 180, zIndex: 100 }}>
|
||||
<Link to="/profile" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}>
|
||||
پروفایل کاربری
|
||||
</Link>
|
||||
<Link to="/settings" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}>
|
||||
تنظیمات
|
||||
</Link>
|
||||
<button onClick={handleLogout} style={{ display: 'block', width: '100%', textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--danger)' }}>
|
||||
خروج
|
||||
|
||||
<div className="header-layer">
|
||||
<button ref={profileButtonRef} type="button" className={`header-profile ${activeLayer === 'profile' ? 'active' : ''}`} onClick={() => toggleLayer('profile')} aria-label="باز کردن پروفایل کاربری" aria-haspopup="dialog" aria-expanded={activeLayer === 'profile'}>
|
||||
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt="" /> : <span className="avatar avatar-sm">{user?.name?.[0] || '?'}</span>}
|
||||
<span>{user?.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{activeLayer === 'profile' && (
|
||||
<Modal
|
||||
title="پروفایل کاربری"
|
||||
size="xl"
|
||||
onClose={closeLayer}
|
||||
footer={(
|
||||
<div className="profile-modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => { closeLayer(); navigate('/settings'); }}>
|
||||
<Settings size={17} />
|
||||
تنظیمات
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger profile-logout-button" onClick={handleLogout}>
|
||||
<LogOut size={17} />
|
||||
خروج از حساب
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Profile embedded />
|
||||
</Modal>
|
||||
)}
|
||||
<EntityPreviewModal item={previewItem} onClose={() => setPreviewItem(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,43 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function Modal({ title, children, onClose, footer, size }) {
|
||||
const modalRef = useRef(null);
|
||||
const lastFocusedRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
lastFocusedRef.current = document.activeElement;
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const modal = modalRef.current;
|
||||
const focusable = modal?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
focusable?.focus();
|
||||
const handleEscape = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
const handleTab = (event) => {
|
||||
if (event.key !== 'Tab' || !modal) return;
|
||||
const items = [...modal.querySelectorAll('button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')];
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
lastFocusedRef.current?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className={`modal${size ? ` modal-${size}` : ''}`}>
|
||||
<div ref={modalRef} className={`modal${size ? ` modal-${size}` : ''}`} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">{title}</h3>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="بستن"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Archive, Bell, CheckCheck, ChevronLeft, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import { formatJalaliDateTime } from '../utils/date';
|
||||
import useBidirectionalNotificationSwipe from '../pwa/useBidirectionalNotificationSwipe';
|
||||
|
||||
function NotificationRow({ notification, archived, onOpen, onRemove, onRestore }) {
|
||||
const runAction = async (action) => {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await api.patch(`/notifications/${notification.id}/archive`);
|
||||
toast.success('اعلان آرشیو شد');
|
||||
} else if (action === 'delete') {
|
||||
await api.delete(`/notifications/${notification.id}`);
|
||||
toast.success('اعلان حذف شد');
|
||||
}
|
||||
onRemove(notification.id);
|
||||
} catch {
|
||||
toast.error('انجام عملیات اعلان ناموفق بود');
|
||||
}
|
||||
};
|
||||
|
||||
const swipe = useBidirectionalNotificationSwipe({
|
||||
rightAction: 'archive',
|
||||
leftAction: 'delete',
|
||||
onExitComplete: runAction,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`notification-swipe-row ${swipe.direction || ''} ${swipe.dragging ? 'dragging' : ''} ${swipe.exiting ? 'exiting' : ''}`}
|
||||
style={{
|
||||
'--notification-translate': `${swipe.translateX}px`,
|
||||
'--notification-progress': swipe.progress,
|
||||
}}
|
||||
{...swipe.swipeHandlers}
|
||||
>
|
||||
<div className="notification-swipe-action archive"><Archive size={18} /><span>آرشیو</span></div>
|
||||
<div className="notification-swipe-action delete"><Trash2 size={18} /><span>حذف</span></div>
|
||||
<div className={`notification-center-item ${notification.read_at ? '' : 'unread'}`}>
|
||||
<button type="button" className="notification-center-main" onClick={() => onOpen(notification)}>
|
||||
<span className="notification-center-icon"><Bell size={17} /></span>
|
||||
<span>
|
||||
<strong>{notification.title || notification.message || 'اعلان'}</strong>
|
||||
<small>{notification.body || notification.preview?.body || formatJalaliDateTime(notification.created_at)}</small>
|
||||
</span>
|
||||
</button>
|
||||
<div className="notification-center-actions">
|
||||
{archived ? (
|
||||
<button type="button" onClick={() => onRestore(notification)} title="بازیابی" aria-label="بازیابی اعلان"><RotateCcw size={15} /></button>
|
||||
) : (
|
||||
<button type="button" onClick={() => runAction('archive')} title="آرشیو" aria-label="آرشیو اعلان"><Archive size={15} /></button>
|
||||
)}
|
||||
<button type="button" className="danger" onClick={() => runAction('delete')} title="حذف" aria-label="حذف اعلان"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationCenter({ onOpenNotification, onCountChange }) {
|
||||
const [tab, setTab] = useState('unread');
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [meta, setMeta] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 4;
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { per_page: 20 };
|
||||
if (tab === 'unread') params.unread = 1;
|
||||
if (tab === 'archived') params.archived = 1;
|
||||
const { data } = await api.get('/notifications', { params });
|
||||
setNotifications(data.data || []);
|
||||
setMeta(data.meta || {});
|
||||
onCountChange?.(data.meta?.unread_count || 0);
|
||||
} catch {
|
||||
toast.error('دریافت اعلانها ناموفق بود');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab, onCountChange]);
|
||||
|
||||
useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
|
||||
useEffect(() => { setPage(0); }, [tab]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(notifications.length / pageSize));
|
||||
const visibleNotifications = notifications.slice(page * pageSize, page * pageSize + pageSize);
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await api.post('/notifications/read-all');
|
||||
toast.success('همه اعلانها خوانده شدند');
|
||||
fetchNotifications();
|
||||
} catch {
|
||||
toast.error('بهروزرسانی اعلانها ناموفق بود');
|
||||
}
|
||||
};
|
||||
|
||||
const openNotification = async (notification) => {
|
||||
try {
|
||||
const { data } = await api.get(`/notifications/${notification.id}/preview`);
|
||||
setNotifications((current) => current.map((item) => item.id === notification.id ? { ...item, read_at: new Date().toISOString() } : item));
|
||||
onCountChange?.(Math.max(0, (meta.unread_count || 0) - (notification.read_at ? 0 : 1)));
|
||||
onOpenNotification(data.data);
|
||||
} catch {
|
||||
onOpenNotification(notification);
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (notification) => {
|
||||
try {
|
||||
await api.patch(`/notifications/${notification.id}/restore`);
|
||||
setNotifications((current) => current.filter((item) => item.id !== notification.id));
|
||||
toast.success('اعلان بازیابی شد');
|
||||
} catch {
|
||||
toast.error('بازیابی اعلان ناموفق بود');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="notification-center" aria-label="مرکز اعلانها">
|
||||
<header className="notification-center-head">
|
||||
<div>
|
||||
<strong>اعلانها</strong>
|
||||
<span>{meta.unread_count || 0} خواندهنشده</span>
|
||||
</div>
|
||||
{(meta.unread_count || 0) > 0 && (
|
||||
<button type="button" className="btn btn-sm btn-outline" onClick={markAllRead}><CheckCheck size={15} /> خواندن همه</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="notification-center-tabs" role="tablist">
|
||||
<button type="button" className={tab === 'unread' ? 'active' : ''} onClick={() => setTab('unread')}>جدید</button>
|
||||
<button type="button" className={tab === 'all' ? 'active' : ''} onClick={() => setTab('all')}>همه</button>
|
||||
<button type="button" className={tab === 'archived' ? 'active' : ''} onClick={() => setTab('archived')}>آرشیو</button>
|
||||
</div>
|
||||
<div className="notification-center-list">
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, index) => <div key={index} className="skeleton notification-center-skeleton" />)
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="notification-center-empty"><Bell size={24} /><strong>اعلانی در این بخش نیست</strong></div>
|
||||
) : visibleNotifications.map((notification) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
archived={tab === 'archived'}
|
||||
onOpen={openNotification}
|
||||
onRemove={(id) => setNotifications((current) => current.filter((item) => item.id !== id))}
|
||||
onRestore={restore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!loading && notifications.length > pageSize && (
|
||||
<div className="popover-pagination notification-pagination">
|
||||
<button type="button" disabled={page === 0} onClick={() => setPage((current) => Math.max(0, current - 1))}><ChevronRight size={16} /> قبلی</button>
|
||||
<span>{page + 1} از {pageCount}</span>
|
||||
<button type="button" disabled={page >= pageCount - 1} onClick={() => setPage((current) => Math.min(pageCount - 1, current + 1))}>بعدی <ChevronLeft size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
<footer className="notification-center-hint">برای آرشیو به راست و برای حذف به چپ بکشید؛ دکمهها نیز همیشه در دسترساند.</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { LayoutDashboard, FolderKanban, CheckSquare, Kanban, Inbox, Timer, Users, Calendar, Paperclip, TrendingUp, Bell, Building2, Shield, Settings, PanelRightClose, PanelRightOpen } 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;
|
||||
|
||||
|
|
@ -15,16 +15,15 @@ const menuItems = [
|
|||
{ path: '/meetings', label: 'جلسات', icon: Calendar },
|
||||
{ path: '/files', label: 'فایلها', icon: Paperclip },
|
||||
{ path: '/reports', label: 'گزارشها', icon: TrendingUp },
|
||||
{ path: '/notifications', label: 'اعلانها', icon: Bell },
|
||||
{ path: '/organization', label: 'ساختار سازمانی', icon: Building2 },
|
||||
{ path: '/roles', label: 'نقشها و دسترسیها', icon: Shield },
|
||||
{ path: '/settings', label: 'تنظیمات', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }) {
|
||||
export default function Sidebar({ collapsed, onToggle, mobileOpen = false, onMobileClose }) {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<aside style={{
|
||||
<aside id="main-navigation-drawer" className={`app-sidebar ${mobileOpen ? 'mobile-open' : ''}`} aria-label="منوی اصلی" style={{
|
||||
width: collapsed ? '72px' : 'var(--sidebar-width)',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
|
|
@ -35,16 +34,18 @@ export default function Sidebar({ collapsed, onToggle }) {
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 100,
|
||||
transition: 'width 0.2s ease',
|
||||
transition: 'width 0.2s ease, transform 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<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={{ width: 32, height: 32, borderRadius: 8, background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-on-primary)', fontWeight: 700, fontSize: '1rem', flexShrink: 0 }}>م</div>
|
||||
<img className="sidebar-brand-mark" src="/brand/project-mark.png" alt="" />
|
||||
{!collapsed && <span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--gray-900)', whiteSpace: 'nowrap' }}>مدیریت پروژه</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="sidebar-collapse-toggle"
|
||||
title={collapsed ? 'باز کردن منو' : 'بستن منو'}
|
||||
style={{
|
||||
width: 34,
|
||||
|
|
@ -64,10 +65,13 @@ export default function Sidebar({ collapsed, onToggle }) {
|
|||
>
|
||||
{collapsed ? <PanelRightOpen size={18} /> : <PanelRightClose size={18} />}
|
||||
</button>
|
||||
<button type="button" className="sidebar-mobile-close" onClick={onMobileClose} aria-label="بستن منوی اصلی">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 0' }}>
|
||||
{menuItems.map((item) => (
|
||||
<NavLink key={item.path} to={item.path} end={item.path === '/'} style={({ isActive }) => ({
|
||||
<NavLink key={item.path} to={item.path} end={item.path === '/'} onClick={onMobileClose} style={({ isActive }) => ({
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: collapsed ? '0.75rem 1.5rem' : '0.625rem 1.25rem',
|
||||
margin: '0 0.5rem',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
export default function useDismissibleLayer(open, refs, onDismiss) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
const inside = refs.some((ref) => ref.current?.contains(event.target));
|
||||
if (!inside) onDismiss();
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onDismiss();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, refs, onDismiss]);
|
||||
}
|
||||
|
|
@ -1,167 +1,213 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Activity, AlertTriangle, Ban, CalendarDays, CheckCircle2, Clock3,
|
||||
FolderKanban, RefreshCw, ShieldAlert, TimerReset,
|
||||
} from 'lucide-react';
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import api from '../services/api';
|
||||
import { KPISkeleton } from '../components/LoadingSkeleton';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { FolderKanban, ClipboardList, CheckCircle, Clock, Users, Calendar, Plus, FileText, UserPlus } from 'lucide-react';
|
||||
import { formatJalaliDate } from '../utils/date';
|
||||
import { formatJalaliDate, formatJalaliDateTime } from '../utils/date';
|
||||
|
||||
const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' };
|
||||
const healthClass = { on_track: 'success', at_risk: 'warning', off_track: 'danger' };
|
||||
|
||||
function MetricCard({ label, value, detail, icon: Icon, tone = 'primary' }) {
|
||||
return (
|
||||
<article className={`monitor-metric tone-${tone}`}>
|
||||
<span className="monitor-metric-icon"><Icon size={21} /></span>
|
||||
<div><span>{label}</span><strong>{value ?? 0}</strong><small>{detail}</small></div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, action, children, className = '' }) {
|
||||
return (
|
||||
<section className={`monitor-panel ${className}`}>
|
||||
<header className="monitor-panel-head">
|
||||
<div><h2>{title}</h2>{subtitle && <p>{subtitle}</p>}</div>
|
||||
{action}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [charts, setCharts] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/dashboard/summary'),
|
||||
api.get('/dashboard/charts'),
|
||||
]).then(([s, c]) => {
|
||||
setSummary(s.data.data);
|
||||
setCharts(c.data.data);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
const loadMonitoring = useCallback(async (silent = false) => {
|
||||
if (silent) setRefreshing(true); else setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await api.get('/dashboard/monitoring');
|
||||
setData(response.data.data);
|
||||
} catch {
|
||||
setError('دریافت اطلاعات مانیتورینگ ناموفق بود.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="page-container"><KPISkeleton /></div>;
|
||||
useEffect(() => {
|
||||
loadMonitoring();
|
||||
const timer = window.setInterval(() => loadMonitoring(true), 120000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadMonitoring]);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'پروژههای فعال', value: summary?.activeProjects ?? 0, color: 'var(--primary)', icon: FolderKanban, iconColor: 'var(--primary)' },
|
||||
{ label: 'تسکهای باز', value: summary?.openTasks ?? 0, color: 'var(--warning)', icon: ClipboardList, iconColor: 'var(--warning)' },
|
||||
{ label: 'تسکهای انجامشده', value: summary?.completedTasks ?? 0, color: 'var(--success)', icon: CheckCircle, iconColor: 'var(--success)' },
|
||||
{ label: 'تسکهای عقبافتاده', value: summary?.delayedTasks ?? 0, color: 'var(--danger)', icon: Clock, iconColor: 'var(--danger)' },
|
||||
{ label: 'اعضای تیم', value: summary?.teamMembers ?? 0, color: 'var(--info)', icon: Users, iconColor: 'var(--info)' },
|
||||
{ label: 'جلسات امروز', value: summary?.todayMeetings ?? 0, color: 'var(--secondary)', icon: Calendar, iconColor: 'var(--secondary)' },
|
||||
];
|
||||
const projects = useMemo(() => {
|
||||
if (projectFilter === 'all') return data?.projects || [];
|
||||
return (data?.projects || []).filter((project) => String(project.id) === projectFilter);
|
||||
}, [data, projectFilter]);
|
||||
|
||||
const quickActions = [
|
||||
{ label: 'پروژه جدید', link: '/projects', icon: Plus },
|
||||
{ label: 'تسک جدید', link: '/tasks', icon: FileText },
|
||||
{ label: 'ثبت جلسه', link: '/meetings', icon: Calendar },
|
||||
{ label: 'افزودن عضو', link: '/team', icon: UserPlus },
|
||||
];
|
||||
const monthlyTasks = charts?.monthlyTasks || charts?.monthly_tasks || [];
|
||||
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>;
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="page-container monitor-page">
|
||||
<div className="monitor-error"><ShieldAlert size={34} /><h2>{error}</h2><button className="btn btn-primary" onClick={() => loadMonitoring()}>تلاش دوباره</button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = data?.summary || {};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{quickActions.map((a, i) => (
|
||||
<Link key={i} to={a.link} className="btn btn-outline btn-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.375rem' }}><a.icon size={16} /> {a.label}</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', marginBottom: '1.5rem' }}>
|
||||
{kpis.map((kpi, i) => (
|
||||
<div key={i} className="card" style={{ padding: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div className="page-container monitor-page">
|
||||
<div className="monitor-toolbar">
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-500)', marginBottom: '0.25rem' }}>{kpi.label}</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 700, color: kpi.color }}>{kpi.value}</div>
|
||||
<div className="monitor-live"><i /> مانیتورینگ عملیاتی</div>
|
||||
<p>وضعیت پروژهها، Sprintها و جریان کار در یک نمای قابل اقدام</p>
|
||||
</div>
|
||||
<kpi.icon size={28} strokeWidth={1.5} color={kpi.iconColor} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2" style={{ marginBottom: '1.5rem' }}>
|
||||
{summary?.activeSprint && (
|
||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Sprint فعال من</h3>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--gray-600)', marginTop: 4 }}>
|
||||
{summary.activeSprint.title} · {summary.activeSprint.project}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-500)' }}>
|
||||
{summary.activeSprint.remaining_days} روز باقیمانده · پایان {formatJalaliDate(summary.activeSprint.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar" style={{ marginBottom: '0.75rem' }}>
|
||||
<div className="progress-fill" style={{ width: `${summary.activeSprint.progress || 0}%` }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{(summary.activeSprint.my_tasks || []).slice(0, 4).map((task) => (
|
||||
<span key={task.id} className="badge badge-gray">{task.title}</span>
|
||||
))}
|
||||
{(summary.activeSprint.my_tasks || []).length === 0 && <span style={{ fontSize: '0.8125rem', color: 'var(--gray-400)' }}>تسکی برای شما در این Sprint ثبت نشده است</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>پیشرفت پروژهها</h3>
|
||||
{summary?.projectProgresses?.map((p, i) => (
|
||||
<div key={i} style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.375rem' }}>
|
||||
<span>{p.title}</span>
|
||||
<span style={{ fontWeight: 600 }}>{p.progress}%</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${p.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!summary?.projectProgresses || summary.projectProgresses.length === 0) && (
|
||||
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>پروژهای یافت نشد</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>نزدیکترین ددلاینها</h3>
|
||||
{summary?.upcomingDeadlines?.map((d, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.5rem 0', borderBottom: '1px solid var(--gray-100)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{d.title}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--gray-500)' }}>{d.project}</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.8125rem', color: d.days_left <= 2 ? 'var(--danger)' : 'var(--warning)', fontWeight: 500 }}>
|
||||
{d.days_left >= 0 ? `${d.days_left} روز باقی` : 'گذشته'}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--gray-500)' }}>{formatJalaliDate(d.due_date)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!summary?.upcomingDeadlines || summary.upcomingDeadlines.length === 0) && (
|
||||
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>ددلاینی وجود ندارد</div>
|
||||
)}
|
||||
<div className="monitor-toolbar-actions">
|
||||
<select className="form-select" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} aria-label="فیلتر پروژه">
|
||||
<option value="all">همه پروژهها</option>
|
||||
{(data?.projects || []).map((project) => <option key={project.id} value={project.id}>{project.title}</option>)}
|
||||
</select>
|
||||
<span className="monitor-updated">آخرین بروزرسانی: {formatJalaliDateTime(data?.generated_at)}</span>
|
||||
<button type="button" className="btn btn-outline" onClick={() => loadMonitoring(true)} disabled={refreshing}>
|
||||
<RefreshCw size={16} className={refreshing ? 'spin' : ''} /> بروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>آخرین فعالیتها</h3>
|
||||
{summary?.recentActivities?.map((a, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '0.75rem', padding: '0.625rem 0', borderBottom: '1px solid var(--gray-50)' }}>
|
||||
<div className="avatar avatar-sm">{a.user?.[0] || '?'}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem' }}>{a.description}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--gray-400)' }}>{a.time_ago || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!summary?.recentActivities || summary.recentActivities.length === 0) && (
|
||||
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>فعالیتی وجود ندارد</div>
|
||||
)}
|
||||
<div className="monitor-metrics">
|
||||
<MetricCard label="پروژه فعال" value={summary.active_projects} detail={`${summary.projects_at_risk || 0} مورد نیازمند توجه`} icon={FolderKanban} />
|
||||
<MetricCard label="تسک باز" value={summary.open_tasks} detail={`${summary.overdue_tasks || 0} عقبافتاده`} icon={Activity} tone="info" />
|
||||
<MetricCard label="تسک مسدود" value={summary.blocked_tasks} detail="نیازمند رفع مانع" icon={Ban} tone="danger" />
|
||||
<MetricCard label="Sprint فعال" value={summary.active_sprints} detail="در حال اجرا" icon={TimerReset} tone="success" />
|
||||
<MetricCard label="جلسه امروز" value={summary.meetings_today} detail="برنامه امروز تیم" icon={CalendarDays} tone="secondary" />
|
||||
<MetricCard label="اقدام معوق" value={summary.overdue_action_items} detail={`${summary.open_blockers || 0} مانع باز`} icon={Clock3} tone="warning" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '1rem' }}>آمار ماهانه تسکها</h3>
|
||||
{monthlyTasks.length ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={monthlyTasks}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="created" fill="#343a40" name="ایجاد شده" />
|
||||
<Bar dataKey="completed" fill="#22c55e" name="تکمیل شده" />
|
||||
</BarChart>
|
||||
<div className="monitor-primary-grid">
|
||||
<Panel title="روند جریان کار" subtitle="تسکهای ایجادشده در برابر تکمیلشده در شش ماه اخیر" className="monitor-trend">
|
||||
{(data?.trends || []).length === 0 ? <div className="monitor-empty">داده کافی برای نمایش روند وجود ندارد.</div> : (
|
||||
<>
|
||||
<div className="monitor-chart" role="img" aria-label="نمودار روند ایجاد و تکمیل تسکها">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.trends}>
|
||||
<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="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>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--gray-200)" vertical={false} />
|
||||
<XAxis dataKey="month" 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} />
|
||||
<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="completed" name="تکمیلشده" stroke="var(--success)" strokeWidth={2} fill="url(#completedGradient)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>دادهای موجود نیست</div>
|
||||
)}
|
||||
</div>
|
||||
<table className="sr-only-table">
|
||||
<caption>دادههای روند جریان کار</caption>
|
||||
<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>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="نیازمند توجه" subtitle="مواردی که اقدام سریع میخواهند" className="monitor-attention">
|
||||
{(data?.attention || []).length === 0 ? (
|
||||
<div className="monitor-empty success"><CheckCircle2 size={24} /> مورد بحرانی فعالی وجود ندارد.</div>
|
||||
) : (
|
||||
<div className="attention-list">
|
||||
{data.attention.map((item) => (
|
||||
<Link key={item.id} to={item.target_url || '#'} className={`attention-item ${item.type}`}>
|
||||
<span><AlertTriangle size={17} /></span>
|
||||
<div><strong>{item.title}</strong><small>{item.context || 'بدون پروژه'} · {formatJalaliDate(item.date)}</small></div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title="سلامت پروژهها" subtitle="ترکیب ریسک، پیشرفت، تسکهای معوق و موانع">
|
||||
{projects.length === 0 ? <div className="monitor-empty">پروژهای برای نمایش وجود ندارد.</div> : (
|
||||
<div className="project-health-table">
|
||||
<div className="project-health-head"><span>پروژه</span><span>سلامت</span><span>پیشرفت</span><span>معوق</span><span>مسدود</span></div>
|
||||
{projects.map((project) => (
|
||||
<Link to={`/projects/${project.id}`} className="project-health-row" key={project.id}>
|
||||
<div><strong>{project.title}</strong><small>{project.completed_tasks} از {project.total_tasks} تسک تکمیل شده</small></div>
|
||||
<span className={`health-chip ${healthClass[project.health]}`}>{healthLabels[project.health]}</span>
|
||||
<div className="health-progress"><span><i style={{ width: `${project.progress || 0}%` }} /></span><strong>{project.progress || 0}%</strong></div>
|
||||
<strong className={project.overdue_tasks ? 'text-danger' : ''}>{project.overdue_tasks}</strong>
|
||||
<strong className={project.blocked_tasks ? 'text-danger' : ''}>{project.blocked_tasks}</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="monitor-secondary-grid">
|
||||
<Panel title="سلامت Sprintها" subtitle="پیشرفت واقعی در برابر پیشرفت مورد انتظار">
|
||||
<div className="sprint-health-list">
|
||||
{(data?.sprints || []).length === 0 ? <div className="monitor-empty">Sprint فعالی وجود ندارد.</div> : data.sprints.map((sprint) => (
|
||||
<Link to={`/sprints/${sprint.id}/workspace`} key={sprint.id} className="sprint-health-card">
|
||||
<div><strong>{sprint.title}</strong><small>{sprint.project}</small></div>
|
||||
<span className={`health-chip ${healthClass[sprint.health]}`}>{healthLabels[sprint.health]}</span>
|
||||
<div className="sprint-health-bars">
|
||||
<label>واقعی <span><i style={{ width: `${sprint.progress}%` }} /></span><b>{sprint.progress}%</b></label>
|
||||
<label>انتظار <span><i className="expected" style={{ width: `${sprint.expected_progress}%` }} /></span><b>{sprint.expected_progress}%</b></label>
|
||||
</div>
|
||||
<small>{sprint.remaining_days} روز باقیمانده · {sprint.blocked_tasks} مسدود</small>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="بار کاری تیم" subtitle="تعداد تسکهای باز و عقبافتاده">
|
||||
<div className="workload-list">
|
||||
{(data?.workload || []).length === 0 ? <div className="monitor-empty">اطلاعات بار کاری موجود نیست.</div> : data.workload.map((member) => (
|
||||
<div className="workload-row" key={member.id}>
|
||||
<span className="avatar avatar-sm">{member.name?.[0]}</span>
|
||||
<div><strong>{member.name}</strong><span><i className={member.load} style={{ width: `${Math.min(100, member.open_tasks * 8)}%` }} /></span></div>
|
||||
<b>{member.open_tasks}</b>
|
||||
{member.overdue_tasks > 0 && <small>{member.overdue_tasks} معوق</small>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="جلسات پیشرو" subtitle="نزدیکترین نشستهای برنامهریزیشده">
|
||||
<div className="upcoming-meeting-list">
|
||||
{(data?.upcoming_meetings || []).length === 0 ? <div className="monitor-empty">جلسهای برنامهریزی نشده است.</div> : data.upcoming_meetings.map((meeting) => (
|
||||
<Link to={meeting.target_url} key={meeting.id}>
|
||||
<span className="meeting-date"><b>{formatJalaliDate(meeting.date)}</b><small>{meeting.time || 'تمام روز'}</small></span>
|
||||
<div><strong>{meeting.title}</strong><small>{meeting.sprint || meeting.project || 'جلسه عمومی'}</small></div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export default function Kanban() {
|
|||
const [noteForm, setNoteForm] = useState({ body: '', mentioned_user_id: '' });
|
||||
const [blockerForm, setBlockerForm] = useState({ blocker_type: '', blocker_note: '' });
|
||||
const [savingDetail, setSavingDetail] = useState(false);
|
||||
const [detailStep, setDetailStep] = useState(0);
|
||||
const [commentIndex, setCommentIndex] = useState(0);
|
||||
|
||||
const fetchBoard = () => {
|
||||
setLoading(true);
|
||||
|
|
@ -98,6 +100,8 @@ export default function Kanban() {
|
|||
|
||||
const openTaskDetail = async (task) => {
|
||||
setSelectedTask(task);
|
||||
setDetailStep(0);
|
||||
setCommentIndex(0);
|
||||
setBlockerForm({ blocker_type: task.blocker_type || '', blocker_note: task.blocker_note || '' });
|
||||
setNoteForm({ body: '', mentioned_user_id: '' });
|
||||
setDetailLoading(true);
|
||||
|
|
@ -250,9 +254,28 @@ export default function Kanban() {
|
|||
)}
|
||||
|
||||
{selectedTask && (
|
||||
<Modal title="جزئیات تسک" onClose={() => setSelectedTask(null)} size="xl">
|
||||
<Modal
|
||||
title="جزئیات تسک"
|
||||
onClose={() => setSelectedTask(null)}
|
||||
size="xl"
|
||||
footer={(
|
||||
<div className="modal-step-footer">
|
||||
<button type="button" className="btn btn-secondary" disabled={detailStep === 0} onClick={() => setDetailStep((step) => Math.max(0, step - 1))}>مرحله قبل</button>
|
||||
<span>مرحله {detailStep + 1} از ۳</span>
|
||||
<button type="button" className="btn btn-primary" disabled={detailStep === 2} onClick={() => setDetailStep((step) => Math.min(2, step + 1))}>مرحله بعد</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{detailLoading ? <div className="skeleton" style={{ height: 280, borderRadius: 'var(--radius)' }} /> : (
|
||||
<div className="task-detail-modal">
|
||||
<div className="modal-wizard-steps" role="tablist" aria-label="بخشهای جزئیات تسک">
|
||||
<button type="button" role="tab" aria-selected={detailStep === 0} className={detailStep === 0 ? 'active' : detailStep > 0 ? 'complete' : ''} onClick={() => setDetailStep(0)}><span>۱</span>نمای کلی</button>
|
||||
<button type="button" role="tab" aria-selected={detailStep === 1} className={detailStep === 1 ? 'active' : detailStep > 1 ? 'complete' : ''} onClick={() => setDetailStep(1)}><span>۲</span>بلاکر</button>
|
||||
<button type="button" role="tab" aria-selected={detailStep === 2} className={detailStep === 2 ? 'active' : ''} onClick={() => setDetailStep(2)}><span>۳</span>یادداشتها</button>
|
||||
</div>
|
||||
|
||||
{detailStep === 0 && (
|
||||
<div className="task-detail-wizard-panel">
|
||||
<div className="task-detail-head">
|
||||
<div>
|
||||
<h2>{selectedTask.title}</h2>
|
||||
|
|
@ -263,7 +286,6 @@ export default function Kanban() {
|
|||
<PriorityBadge priority={selectedTask.priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-detail-grid compact">
|
||||
<Info label="پروژه" value={selectedTask.project?.title || 'بدون پروژه'} />
|
||||
<Info label="مسئول" value={selectedTask.assignee?.name || 'بدون مسئول'} />
|
||||
|
|
@ -272,9 +294,11 @@ export default function Kanban() {
|
|||
<Info label="ددلاین" value={selectedTask.due_date ? formatJalaliDate(selectedTask.due_date) : 'بدون ددلاین'} />
|
||||
<Info label="تخمین" value={selectedTask.estimated_time ? `${selectedTask.estimated_time} ساعت` : '—'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-detail-two-col">
|
||||
<div className="card task-detail-section">
|
||||
{detailStep === 1 && (
|
||||
<div className="card task-detail-section task-detail-wizard-panel">
|
||||
<h3>بلاکر</h3>
|
||||
<div className="form-group">
|
||||
<label className="form-label">نوع بلاکر</label>
|
||||
|
|
@ -288,7 +312,10 @@ export default function Kanban() {
|
|||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={saveBlocker} disabled={savingDetail}><Save size={14} /> ذخیره بلاکر</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailStep === 2 && (
|
||||
<div className="task-notes-wizard task-detail-wizard-panel">
|
||||
<div className="card task-detail-section">
|
||||
<h3>نوت و منشن</h3>
|
||||
<form onSubmit={addNote}>
|
||||
|
|
@ -306,11 +333,11 @@ export default function Kanban() {
|
|||
<button className="btn btn-primary" disabled={savingDetail || !noteForm.body.trim()}><MessageSquare size={16} /> ثبت نوت</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-notes">
|
||||
<h3>نوتهای اخیر</h3>
|
||||
{comments.length === 0 ? <div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>هنوز نوتی ثبت نشده است.</div> : comments.map((comment) => (
|
||||
{comments.length === 0 ? <div style={{ color: 'var(--gray-400)', fontSize: '0.875rem' }}>هنوز نوتی ثبت نشده است.</div> : (
|
||||
<>
|
||||
{comments.slice(commentIndex, commentIndex + 1).map((comment) => (
|
||||
<div className="task-note" key={comment.id}>
|
||||
<div className="avatar avatar-sm">{comment.user?.name?.[0] || '?'}</div>
|
||||
<div>
|
||||
|
|
@ -320,7 +347,16 @@ export default function Kanban() {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="schedule-wizard-navigation">
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={commentIndex === 0} onClick={() => setCommentIndex((index) => Math.max(0, index - 1))}>قبلی</button>
|
||||
<span>{commentIndex + 1} از {comments.length}</span>
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={commentIndex >= comments.length - 1} onClick={() => setCommentIndex((index) => Math.min(comments.length - 1, index + 1))}>بعدی</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { useEffect, useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Eye, EyeOff, Moon, Sun } from 'lucide-react';
|
||||
import { defaultRouteForAppMode } from '../utils/appMode';
|
||||
|
||||
export default function Login() {
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
|
||||
const { login } = useAuth();
|
||||
|
|
@ -43,18 +44,38 @@ export default function Login() {
|
|||
</button>
|
||||
<div className="card" style={{ maxWidth: 420, width: '100%', padding: '2.5rem' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: '1.75rem', fontWeight: 700, marginBottom: '1rem' }}>م</div>
|
||||
<img src="/brand/project-mark.png" alt="" style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 18, marginBottom: '1rem', boxShadow: '0 12px 30px rgba(14,165,233,.24)' }} />
|
||||
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--gray-900)' }}>ورود به سامانه مدیریت پروژه</h1>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--gray-500)', marginTop: '0.25rem' }}>برای مدیریت پروژههای خود وارد شوید</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">نام کاربری یا ایمیل</label>
|
||||
<input className="form-input" type="text" value={identifier} onChange={e => setIdentifier(e.target.value)} placeholder="نام کاربری یا ایمیل" dir="auto" autoComplete="username" />
|
||||
<label className="form-label" htmlFor="login-identifier">نام کاربری یا ایمیل</label>
|
||||
<input id="login-identifier" className="form-input" type="text" value={identifier} onChange={e => setIdentifier(e.target.value)} placeholder="نام کاربری یا ایمیل" dir="auto" autoComplete="username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">رمز عبور</label>
|
||||
<input className="form-input" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••••" />
|
||||
<label className="form-label" htmlFor="login-password">رمز عبور</label>
|
||||
<div className="password-input-wrap">
|
||||
<input
|
||||
id="login-password"
|
||||
className="form-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-visibility-toggle"
|
||||
onClick={() => setShowPassword((visible) => !visible)}
|
||||
aria-label={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
|
||||
aria-pressed={showPassword}
|
||||
title={showPassword ? 'مخفی کردن رمز عبور' : 'نمایش رمز عبور'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={19} /> : <Eye size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-lg" style={{ width: '100%', justifyContent: 'center' }} disabled={loading}>
|
||||
{loading ? 'در حال ورود...' : 'ورود'}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import Modal from '../components/Modal';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
|
|
@ -9,6 +8,7 @@ import PersianDateInput from '../components/PersianDateInput';
|
|||
import toast from 'react-hot-toast';
|
||||
import { Calendar, Edit3, Trash2, X, ArrowLeft } from 'lucide-react';
|
||||
import { formatJalaliDate, todayIsoDate } from '../utils/date';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function MeetingList() {
|
||||
const [meetings, setMeetings] = useState([]);
|
||||
|
|
@ -225,6 +225,9 @@ export default function MeetingList() {
|
|||
<button className="modal-close" onClick={() => setSelected(null)}>×</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<Link className="btn btn-primary" style={{ width: '100%', marginBottom: '1rem' }} to={`/meetings/${selected.id}/workspace`} onClick={() => setSelected(null)}>
|
||||
ورود به فضای کاری جلسه <ArrowLeft size={16} />
|
||||
</Link>
|
||||
<div className="card" style={{ padding: '1rem', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', fontSize: '0.875rem' }}>
|
||||
<div><strong>پروژه:</strong> {selected.project?.title || '—'}</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowRight, Ban, CheckCircle2, ClipboardList, FileText, Play, Plus, Save, ShieldAlert, Users } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Modal from '../components/Modal';
|
||||
import { formatJalaliDate } from '../utils/date';
|
||||
|
||||
const stages = [['before', 'قبل از جلسه'], ['during', 'حین جلسه'], ['after', 'بعد از جلسه']];
|
||||
|
||||
export default function MeetingWorkspace() {
|
||||
const { id } = useParams();
|
||||
const [meeting, setMeeting] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [stage, setStage] = useState('before');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ objective: '', agenda: '', notes: '', summary: '' });
|
||||
const [quickForm, setQuickForm] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/meetings/${id}/workspace`);
|
||||
setMeeting(data.data);
|
||||
setForm({ objective: data.data.objective || '', agenda: data.data.agenda || '', notes: data.data.notes || '', summary: data.data.summary || '' });
|
||||
setStage(data.data.status === 'completed' ? 'after' : data.data.status === 'in_progress' ? 'during' : 'before');
|
||||
setError('');
|
||||
} catch {
|
||||
setError('دریافت فضای کاری جلسه ناموفق بود.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const saveNotes = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.patch(`/meetings/${id}`, form);
|
||||
toast.success('اطلاعات جلسه ذخیره شد');
|
||||
await load();
|
||||
} catch {
|
||||
toast.error('ذخیره جلسه ناموفق بود');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startMeeting = async () => {
|
||||
try {
|
||||
await api.post(`/meetings/${id}/start`);
|
||||
toast.success('جلسه شروع شد');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.message || 'شروع جلسه ناموفق بود');
|
||||
}
|
||||
};
|
||||
|
||||
const completeMeeting = async () => {
|
||||
try {
|
||||
await api.post(`/meetings/${id}/complete`, { summary: form.summary });
|
||||
toast.success('جلسه تکمیل شد');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.message || 'تکمیل جلسه ناموفق بود');
|
||||
}
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="page-container workspace-page meeting-workspace">
|
||||
<header className="workspace-hero">
|
||||
<div className="workspace-breadcrumb"><Link to="/meetings"><ArrowRight size={16} /> جلسات</Link><span>/</span><span>{meeting.title}</span></div>
|
||||
<div className="workspace-title-row"><h1>{meeting.title}</h1><span className={`meeting-status status-${meeting.status}`}>{meeting.status}</span></div>
|
||||
<p>{meeting.objective || 'هدف جلسه هنوز ثبت نشده است.'}</p>
|
||||
<div className="workspace-context"><span>{formatJalaliDate(meeting.date)}</span><span>{meeting.start_time?.slice(0, 5) || 'بدون ساعت'} تا {meeting.end_time?.slice(0, 5) || 'نامشخص'}</span><span>{meeting.project?.title || 'جلسه عمومی'}</span>{meeting.sprint?.title && <Link to={`/sprints/${meeting.sprint_id}/workspace`}>{meeting.sprint.title}</Link>}</div>
|
||||
</header>
|
||||
|
||||
<nav className="meeting-stage-tabs">
|
||||
{stages.map(([key, label]) => <button type="button" key={key} className={stage === key ? 'active' : ''} onClick={() => setStage(key)}>{label}</button>)}
|
||||
</nav>
|
||||
|
||||
<div className="meeting-workspace-layout">
|
||||
<main>
|
||||
{stage === 'before' && (
|
||||
<div className="meeting-stage-content">
|
||||
<section className="workspace-card">
|
||||
<header><div><ClipboardList size={18} /><h2>آمادهسازی جلسه</h2></div></header>
|
||||
<label className="form-group"><span className="form-label">هدف جلسه</span><textarea className="form-textarea" value={form.objective} onChange={(event) => setForm({ ...form, objective: event.target.value })} /></label>
|
||||
<label className="form-group"><span className="form-label">دستور جلسه</span><textarea className="form-textarea meeting-agenda-input" value={form.agenda} onChange={(event) => setForm({ ...form, agenda: event.target.value })} /></label>
|
||||
</section>
|
||||
<section className="workspace-card"><header><div><Users size={18} /><h2>شرکتکنندگان</h2></div><span>{meeting.participants?.length || 0} نفر</span></header>
|
||||
<div className="participant-grid">{(meeting.participants || []).map((person) => <div key={person.id}><span className="avatar avatar-sm">{person.name?.[0]}</span><strong>{person.name}</strong></div>)}</div>
|
||||
{(meeting.participants || []).length === 0 && <div className="workspace-empty">شرکتکنندهای اضافه نشده است.</div>}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'during' && (
|
||||
<div className="meeting-stage-content">
|
||||
<section className="workspace-card live-notes-card">
|
||||
<header><div><FileText size={18} /><h2>یادداشت زنده</h2></div><span className="live-indicator"><i /> جلسه در جریان</span></header>
|
||||
<textarea className="form-textarea" value={form.notes} onChange={(event) => setForm({ ...form, notes: event.target.value })} placeholder="نکات، گفتگوها و جمعبندیهای مهم را ثبت کنید..." />
|
||||
</section>
|
||||
<div className="meeting-capture-grid">
|
||||
<button type="button" onClick={() => setQuickForm({ type: 'decision', title: '', description: '' })}><CheckCircle2 size={22} /><strong>ثبت تصمیم</strong><span>نتیجه تصمیمگیری را ثبت کنید</span></button>
|
||||
<button type="button" onClick={() => setQuickForm({ type: 'action', title: '', owner_id: '', due_date: '', priority: 'medium' })}><Plus size={22} /><strong>ثبت اقدام</strong><span>Owner و سررسید مشخص کنید</span></button>
|
||||
<button type="button" onClick={() => setQuickForm({ type: 'blocker', title: '', description: '', severity: 'medium' })}><Ban size={22} /><strong>ثبت مانع</strong><span>مانع را به Sprint بازگردانید</span></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === 'after' && (
|
||||
<div className="meeting-stage-content">
|
||||
<section className="workspace-card">
|
||||
<header><div><FileText size={18} /><h2>خلاصه و صورتجلسه</h2></div></header>
|
||||
<textarea className="form-textarea meeting-summary-input" value={form.summary} onChange={(event) => setForm({ ...form, summary: event.target.value })} placeholder="خلاصه ساختاریافته جلسه..." />
|
||||
</section>
|
||||
<ResultSection title="تصمیمها" items={meeting.structured_decisions} />
|
||||
<ResultSection title="اقدامها" items={meeting.structured_action_items} />
|
||||
<ResultSection title="موانع" items={meeting.blockers} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="meeting-context-panel">
|
||||
<section className="workspace-card"><h3>اطلاعات جلسه</h3>
|
||||
<dl><div><dt>نوع</dt><dd>{meeting.type?.name || meeting.meeting_type}</dd></div><div><dt>Owner</dt><dd>{meeting.owner?.name || meeting.creator?.name || '—'}</dd></div><div><dt>Facilitator</dt><dd>{meeting.facilitator?.name || '—'}</dd></div><div><dt>مکان</dt><dd>{meeting.location || '—'}</dd></div></dl>
|
||||
</section>
|
||||
<section className="workspace-card"><h3>خروجی فعلی</h3><dl><div><dt>تصمیم</dt><dd>{meeting.structured_decisions?.length || 0}</dd></div><div><dt>اقدام</dt><dd>{meeting.structured_action_items?.length || meeting.action_items?.length || 0}</dd></div><div><dt>مانع</dt><dd>{meeting.blockers?.length || 0}</dd></div><div><dt>پیوست</dt><dd>{meeting.files?.length || 0}</dd></div></dl></section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="meeting-sticky-controls">
|
||||
<button type="button" className="btn btn-outline" onClick={saveNotes} disabled={saving}><Save size={16} /> {saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
|
||||
{meeting.status !== 'in_progress' && meeting.status !== 'completed' && <button type="button" className="btn btn-primary" onClick={startMeeting}><Play size={16} /> شروع جلسه</button>}
|
||||
{meeting.status === 'in_progress' && <button type="button" className="btn btn-success" onClick={completeMeeting}><CheckCircle2 size={16} /> پایان جلسه</button>}
|
||||
</div>
|
||||
|
||||
{quickForm && <QuickCapture meetingId={meeting.id} form={quickForm} setForm={setQuickForm} onClose={() => setQuickForm(null)} onSaved={load} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function QuickCapture({ meetingId, form, setForm, onClose, onSaved }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
if (form.type === 'action') api.get('/users', { params: { per_page: 100 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {});
|
||||
}, [form.type]);
|
||||
|
||||
const config = useMemo(() => ({
|
||||
decision: { title: 'ثبت تصمیم', endpoint: 'decisions' },
|
||||
action: { title: 'ثبت اقدام', endpoint: 'structured-action-items' },
|
||||
blocker: { title: 'ثبت مانع', endpoint: 'blockers' },
|
||||
})[form.type], [form.type]);
|
||||
|
||||
const save = async () => {
|
||||
if (!form.title?.trim()) { toast.error('عنوان الزامی است'); return; }
|
||||
if (form.type === 'action' && (!form.owner_id || !form.due_date)) { toast.error('Owner و سررسید الزامی است'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...form }; delete payload.type;
|
||||
await api.post(`/meetings/${meetingId}/${config.endpoint}`, payload);
|
||||
toast.success('ثبت شد');
|
||||
onClose();
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.message || 'ثبت مورد ناموفق بود');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={config.title} onClose={onClose} footer={<><button className="btn btn-secondary" onClick={onClose}>انصراف</button><button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'در حال ثبت...' : 'ثبت'}</button></>}>
|
||||
<div className="compact-modal-form">
|
||||
<label className="form-group"><span className="form-label">عنوان</span><input className="form-input" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></label>
|
||||
{form.type !== 'action' && <label className="form-group"><span className="form-label">توضیحات</span><textarea className="form-textarea" value={form.description || ''} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>}
|
||||
{form.type === 'action' && <><label className="form-group"><span className="form-label">Owner</span><select className="form-select" value={form.owner_id} onChange={(event) => setForm({ ...form, owner_id: event.target.value })}><option value="">انتخاب</option>{users.map((user) => <option key={user.id} value={user.id}>{user.name}</option>)}</select></label><label className="form-group"><span className="form-label">سررسید</span><input type="date" className="form-input" value={form.due_date} onChange={(event) => setForm({ ...form, due_date: event.target.value })} /></label></>}
|
||||
{form.type === 'blocker' && <label className="form-group"><span className="form-label">شدت</span><select className="form-select" value={form.severity} onChange={(event) => setForm({ ...form, severity: event.target.value })}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="critical">بحرانی</option></select></label>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import { useState, useEffect } from 'react';
|
|||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { BriefcaseBusiness, Camera, Check, ChevronLeft, ChevronRight, ShieldCheck, UserRound } from 'lucide-react';
|
||||
|
||||
export default function Profile() {
|
||||
export default function Profile({ embedded = false }) {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [profile, setProfile] = useState({ name: '', email: '', phone: '', job_title: '', department: '', bio: '', avatar_url: '' });
|
||||
const [passwords, setPasswords] = useState({ current_password: '', new_password: '', new_password_confirmation: '' });
|
||||
|
|
@ -13,6 +13,7 @@ export default function Profile() {
|
|||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState('');
|
||||
const [savingAvatar, setSavingAvatar] = useState(false);
|
||||
const [wizardStep, setWizardStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
|
|
@ -85,13 +86,14 @@ export default function Profile() {
|
|||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<div className="page-header">
|
||||
</div>
|
||||
const wizardSteps = [
|
||||
{ label: 'هویت', icon: UserRound },
|
||||
{ label: 'اطلاعات کاری', icon: BriefcaseBusiness },
|
||||
{ label: 'امنیت', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||
const identityHeader = (
|
||||
<div className="profile-identity">
|
||||
<label className={`profile-avatar-wrap ${savingAvatar ? 'uploading' : ''}`} title={savingAvatar ? 'در حال آپلود عکس' : 'تغییر عکس پروفایل'}>
|
||||
{avatarPreview ? (
|
||||
<img className="profile-avatar-img" src={avatarPreview} alt={user?.name || 'avatar'} />
|
||||
|
|
@ -102,15 +104,96 @@ export default function Profile() {
|
|||
)}
|
||||
<input className="profile-avatar-input" type="file" accept="image/png,image/jpeg,image/webp" onChange={handleAvatarChange} disabled={savingAvatar} />
|
||||
</label>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, marginBottom: '0.25rem' }}>{user?.name}</h2>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--gray-500)' }}>{user?.email}</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--gray-400)', marginTop: '0.25rem' }}>
|
||||
{user?.job_title} {user?.job_title && user?.department ? '|' : ''} {user?.department}
|
||||
<div>
|
||||
<h2>{user?.name}</h2>
|
||||
<div>{user?.email}</div>
|
||||
<small>{user?.job_title} {user?.job_title && user?.department ? '|' : ''} {user?.department}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="profile-wizard">
|
||||
<div className="modal-wizard-steps" role="tablist" aria-label="مراحل پروفایل">
|
||||
{wizardSteps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<button
|
||||
key={step.label}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={wizardStep === index}
|
||||
className={wizardStep === index ? 'active' : wizardStep > index ? 'complete' : ''}
|
||||
onClick={() => setWizardStep(index)}
|
||||
>
|
||||
<span>{wizardStep > index ? <Check size={16} /> : <Icon size={16} />}</span>
|
||||
{step.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="profile-wizard-panel" role="tabpanel">
|
||||
{wizardStep === 0 && (
|
||||
<>
|
||||
{identityHeader}
|
||||
<form onSubmit={handleSaveProfile} className="profile-step-form">
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-name">نام</label><input id="profile-name" className="form-input" value={profile.name} onChange={e => setProfile({...profile, name: e.target.value})} /></div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-email">ایمیل</label><input id="profile-email" className="form-input" type="email" value={profile.email} onChange={e => setProfile({...profile, email: e.target.value})} /></div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره هویت'}</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{wizardStep === 1 && (
|
||||
<form onSubmit={handleSaveProfile} className="profile-step-form">
|
||||
<div className="grid grid-3">
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-phone">شماره تماس</label><input id="profile-phone" className="form-input" value={profile.phone} onChange={e => setProfile({...profile, phone: e.target.value})} /></div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-job">سمت</label><input id="profile-job" className="form-input" value={profile.job_title} onChange={e => setProfile({...profile, job_title: e.target.value})} /></div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-department">دپارتمان</label><input id="profile-department" className="form-input" value={profile.department} onChange={e => setProfile({...profile, department: e.target.value})} /></div>
|
||||
</div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="profile-bio">بیوگرافی</label><textarea id="profile-bio" className="form-textarea" value={profile.bio} onChange={e => setProfile({...profile, bio: e.target.value})} /></div>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره اطلاعات کاری'}</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{wizardStep === 2 && (
|
||||
<form onSubmit={handleChangePassword} className="profile-step-form">
|
||||
<div className="grid grid-3">
|
||||
<div className="form-group"><label className="form-label" htmlFor="current-password">رمز عبور فعلی</label><input id="current-password" className="form-input" type="password" autoComplete="current-password" value={passwords.current_password} onChange={e => setPasswords({...passwords, current_password: e.target.value})} /></div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="new-password">رمز عبور جدید</label><input id="new-password" className="form-input" type="password" autoComplete="new-password" value={passwords.new_password} onChange={e => setPasswords({...passwords, new_password: e.target.value})} /></div>
|
||||
<div className="form-group"><label className="form-label" htmlFor="confirm-password">تکرار رمز جدید</label><input id="confirm-password" className="form-input" type="password" autoComplete="new-password" value={passwords.new_password_confirmation} onChange={e => setPasswords({...passwords, new_password_confirmation: e.target.value})} /></div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={savingPass}>{savingPass ? 'در حال تغییر...' : 'تغییر رمز عبور'}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-wizard-navigation">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setWizardStep((step) => Math.max(0, step - 1))} disabled={wizardStep === 0}>
|
||||
<ChevronRight size={17} />
|
||||
قبلی
|
||||
</button>
|
||||
<span>مرحله {wizardStep + 1} از {wizardSteps.length}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setWizardStep((step) => Math.min(wizardSteps.length - 1, step + 1))} disabled={wizardStep === wizardSteps.length - 1}>
|
||||
بعدی
|
||||
<ChevronLeft size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||
<div className="page-header" />
|
||||
|
||||
<div className="card" style={{ padding: '1.5rem', marginBottom: '1.5rem' }}>
|
||||
{identityHeader}
|
||||
|
||||
<form onSubmit={handleSaveProfile}>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group"><label className="form-label">نام</label><input className="form-input" value={profile.name} onChange={e => setProfile({...profile, name: e.target.value})} /></div>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ export default function Roles() {
|
|||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [roleForm, setRoleForm] = useState(emptyRoleForm);
|
||||
const [savingRole, setSavingRole] = useState(false);
|
||||
const [roleWizardStep, setRoleWizardStep] = useState(0);
|
||||
const [roleModuleIndex, setRoleModuleIndex] = useState(0);
|
||||
const [deletingRole, setDeletingRole] = useState(null);
|
||||
const [userRoleDrafts, setUserRoleDrafts] = useState({});
|
||||
const [savingUserId, setSavingUserId] = useState(null);
|
||||
|
|
@ -153,6 +155,16 @@ export default function Roles() {
|
|||
}, {});
|
||||
}, [permissions, permissionSearch]);
|
||||
|
||||
const rolePermissionModules = useMemo(() => {
|
||||
const groups = permissions.reduce((acc, permission) => {
|
||||
const module = permission.module || permission.name?.split('.')[0] || 'other';
|
||||
if (!acc[module]) acc[module] = [];
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.entries(groups).sort(([moduleA], [moduleB]) => getModuleLabel(moduleA).localeCompare(getModuleLabel(moduleB), 'fa'));
|
||||
}, [permissions]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = userSearch.trim().toLowerCase();
|
||||
return users.filter((user) => !q || user.name?.toLowerCase().includes(q) || user.email?.toLowerCase().includes(q));
|
||||
|
|
@ -195,6 +207,8 @@ export default function Roles() {
|
|||
const openCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleForm(emptyRoleForm);
|
||||
setRoleWizardStep(0);
|
||||
setRoleModuleIndex(0);
|
||||
setRoleModal(true);
|
||||
};
|
||||
|
||||
|
|
@ -207,6 +221,8 @@ export default function Roles() {
|
|||
guard_name: role.guard_name || 'web',
|
||||
permissions: rolePermissionIds(role),
|
||||
});
|
||||
setRoleWizardStep(0);
|
||||
setRoleModuleIndex(0);
|
||||
setRoleModal(true);
|
||||
};
|
||||
|
||||
|
|
@ -436,15 +452,28 @@ export default function Roles() {
|
|||
|
||||
{roleModal && (
|
||||
<Modal title={editingRole ? 'ویرایش نقش' : 'نقش جدید'} size="xl" onClose={() => setRoleModal(false)} footer={
|
||||
roleWizardStep === 0 ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setRoleModal(false)}>انصراف</button>
|
||||
<button className="btn btn-primary" onClick={() => setRoleWizardStep(1)}>مرحله بعد: دسترسیها</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setRoleWizardStep(0)}>مرحله قبل</button>
|
||||
<button className="btn btn-primary" onClick={saveRole} disabled={savingRole}>
|
||||
<Save size={16} />
|
||||
{savingRole ? 'در حال ذخیره...' : 'ذخیره نقش'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}>
|
||||
<form className="access-role-form" onSubmit={saveRole}>
|
||||
<div className="modal-wizard-steps modal-wizard-steps-two" role="tablist" aria-label="مراحل تعریف نقش">
|
||||
<button type="button" role="tab" aria-selected={roleWizardStep === 0} className={roleWizardStep === 0 ? 'active' : 'complete'} onClick={() => setRoleWizardStep(0)}><span>{roleWizardStep > 0 ? <Check size={16} /> : '۱'}</span>مشخصات نقش</button>
|
||||
<button type="button" role="tab" aria-selected={roleWizardStep === 1} className={roleWizardStep === 1 ? 'active' : ''} onClick={() => setRoleWizardStep(1)}><span>۲</span>دسترسیها</button>
|
||||
</div>
|
||||
{roleWizardStep === 0 ? (
|
||||
<div className="role-wizard-details">
|
||||
<div className="grid grid-2">
|
||||
<Field label="نام سیستمی">
|
||||
<input className="form-input" value={roleForm.name} onChange={(e) => setRoleForm({ ...roleForm, name: e.target.value })} disabled={!!editingRole} placeholder="نام نقش" />
|
||||
|
|
@ -456,20 +485,17 @@ export default function Roles() {
|
|||
<Field label="توضیحات">
|
||||
<textarea className="form-textarea" value={roleForm.description} onChange={(e) => setRoleForm({ ...roleForm, description: e.target.value })} />
|
||||
</Field>
|
||||
<div className="access-form-permissions">
|
||||
</div>
|
||||
) : (
|
||||
<div className="access-form-permissions role-permission-wizard">
|
||||
<div className="access-section-head tight">
|
||||
<div>
|
||||
<h3>دسترسیهای نقش</h3>
|
||||
<h3>{rolePermissionModules[roleModuleIndex] ? getModuleLabel(rolePermissionModules[roleModuleIndex][0]) : 'دسترسیهای نقش'}</h3>
|
||||
<span>{roleForm.permissions.length} دسترسی انتخاب شده</span>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionMatrix
|
||||
groups={permissions.reduce((acc, permission) => {
|
||||
const module = permission.module || permission.name?.split('.')[0] || 'other';
|
||||
if (!acc[module]) acc[module] = [];
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {})}
|
||||
groups={rolePermissionModules[roleModuleIndex] ? Object.fromEntries([rolePermissionModules[roleModuleIndex]]) : {}}
|
||||
selectedIds={roleForm.permissions}
|
||||
onToggle={toggleRoleFormPermission}
|
||||
onToggleModule={(modulePermissions, checked) => {
|
||||
|
|
@ -480,7 +506,13 @@ export default function Roles() {
|
|||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="schedule-wizard-navigation">
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={roleModuleIndex === 0} onClick={() => setRoleModuleIndex((index) => Math.max(0, index - 1))}>ماژول قبلی</button>
|
||||
<span>{rolePermissionModules.length ? roleModuleIndex + 1 : 0} از {rolePermissionModules.length}</span>
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={roleModuleIndex >= rolePermissionModules.length - 1} onClick={() => setRoleModuleIndex((index) => Math.min(rolePermissionModules.length - 1, index + 1))}>ماژول بعدی</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import {
|
|||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
CalendarDays,
|
||||
TimerReset,
|
||||
MessagesSquare,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import ThemeAccentPicker from '../components/ThemeAccentPicker';
|
||||
|
|
@ -34,6 +37,20 @@ const groupMeta = {
|
|||
app: { label: 'برنامه', icon: SlidersHorizontal },
|
||||
people: { label: 'منابع انسانی', icon: Users },
|
||||
personalization: { label: 'شخصیسازی', icon: SlidersHorizontal },
|
||||
calendar: { label: 'تقویم و زمان کاری', icon: CalendarDays },
|
||||
sprint: { label: 'Sprint و Agile', icon: TimerReset },
|
||||
meeting: { label: 'جلسات و یادآوری', icon: MessagesSquare },
|
||||
};
|
||||
|
||||
const groupDescriptions = {
|
||||
general: 'هویت، اطلاعات تماس و تنظیمات عمومی سازمان',
|
||||
personalization: 'ظاهر سامانه، حالت نمایش و رنگبندی شخصی',
|
||||
notification: 'نوعها، کانالها و سیاست دریافت اعلان',
|
||||
calendar: 'منطقه زمانی، روزهای کاری و رفتار تقویم',
|
||||
sprint: 'قواعد سلامت Sprint و سیاستهای Agile',
|
||||
meeting: 'پیشفرض جلسه، یادآوری و ارزیابی اثربخشی',
|
||||
security: 'کنترلهای امنیتی و سیاستهای دسترسی',
|
||||
mail: 'ارسال ایمیل و زیرساخت SMTP',
|
||||
};
|
||||
|
||||
const typeLabels = {
|
||||
|
|
@ -59,6 +76,11 @@ const keyLabels = {
|
|||
notification_types: 'نوعهای اعلان',
|
||||
smtp_settings: 'تنظیمات SMTP',
|
||||
job_titles: 'عنوانهای شغلی',
|
||||
working_days: 'روزهای کاری سازمان',
|
||||
organization_timezone: 'منطقه زمانی سازمان',
|
||||
default_meeting_reminder_minutes: 'یادآوری پیشفرض جلسه',
|
||||
meeting_effectiveness_enabled: 'ارزیابی اثربخشی جلسه',
|
||||
sprint_health_override_requires_reason: 'الزام دلیل برای تغییر سلامت Sprint',
|
||||
};
|
||||
|
||||
const emptySetting = {
|
||||
|
|
@ -160,6 +182,30 @@ export default function Settings() {
|
|||
missingType: settings.filter((setting) => !setting.type).length,
|
||||
}), [settings, groups.length]);
|
||||
|
||||
const dirtySettings = useMemo(() => settings.filter((setting) => {
|
||||
const original = toDraft(setting);
|
||||
const draft = drafts[setting.id] || original;
|
||||
return JSON.stringify({
|
||||
value: normalizeByType(draft.type, draft.value),
|
||||
group: draft.group,
|
||||
type: draft.type,
|
||||
}) !== JSON.stringify({
|
||||
value: normalizeByType(original.type, original.value),
|
||||
group: original.group,
|
||||
type: original.type,
|
||||
});
|
||||
}), [settings, drafts]);
|
||||
|
||||
useEffect(() => {
|
||||
const warnUnsaved = (event) => {
|
||||
if (dirtySettings.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', warnUnsaved);
|
||||
return () => window.removeEventListener('beforeunload', warnUnsaved);
|
||||
}, [dirtySettings.length]);
|
||||
|
||||
const updateDraft = (id, patch) => {
|
||||
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
||||
};
|
||||
|
|
@ -217,12 +263,35 @@ export default function Settings() {
|
|||
}
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
if (dirtySettings.length === 0) return;
|
||||
setSavingId('all');
|
||||
try {
|
||||
await Promise.all(dirtySettings.map((setting) => {
|
||||
const draft = drafts[setting.id] || toDraft(setting);
|
||||
return api.put(`/settings/${setting.id}`, {
|
||||
value: normalizeByType(draft.type, draft.value),
|
||||
group: draft.group,
|
||||
type: draft.type,
|
||||
});
|
||||
}));
|
||||
await fetchSettings();
|
||||
toast.success(`${dirtySettings.length} تنظیم ذخیره شد`);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.message || 'ذخیره گروهی تنظیمات ناموفق بود');
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeGroupTitle = activeGroup === 'personalization' ? 'شخصیسازی' : getGroupLabel(activeGroup);
|
||||
|
||||
return (
|
||||
<div className="page-container settings-page">
|
||||
<div className="page-header settings-header">
|
||||
<div>
|
||||
<h1>مرکز تنظیمات</h1>
|
||||
<p className="settings-subtitle">تنظیمات شخصی و سیاستهای سازمان را از یک نقطه مدیریت کنید.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setAddOpen(true)}>
|
||||
<Plus size={16} />
|
||||
|
|
@ -242,7 +311,7 @@ export default function Settings() {
|
|||
<button className={`settings-nav-item ${activeGroup === 'personalization' ? 'active' : ''}`} onClick={() => setActiveGroup('personalization')}>
|
||||
<span><SlidersHorizontal size={16} /> شخصیسازی</span>
|
||||
</button>
|
||||
{groups.map(({ key, count }) => {
|
||||
{groups.map(({ key }) => {
|
||||
const Icon = groupMeta[key]?.icon || SlidersHorizontal;
|
||||
return (
|
||||
<button key={key} className={`settings-nav-item ${activeGroup === key ? 'active' : ''}`} onClick={() => setActiveGroup(key)}>
|
||||
|
|
@ -256,7 +325,7 @@ export default function Settings() {
|
|||
<div className="settings-toolbar">
|
||||
<div>
|
||||
<h2>{activeGroupTitle}</h2>
|
||||
<span>{activeGroup === 'personalization' ? 'ظاهر و رنگبندی پنل' : `${visibleSettings.length} مورد`}</span>
|
||||
<span>{groupDescriptions[activeGroup] || `${visibleSettings.length} مورد`}</span>
|
||||
</div>
|
||||
<div className="settings-search">
|
||||
<Search size={16} />
|
||||
|
|
@ -294,6 +363,13 @@ export default function Settings() {
|
|||
</main>
|
||||
</div>
|
||||
|
||||
{dirtySettings.length > 0 && (
|
||||
<div className="settings-unsaved-bar" role="status">
|
||||
<div><strong>{dirtySettings.length} تغییر ذخیرهنشده</strong><span>تغییرات تا زمان ذخیره روی سازمان اعمال نمیشوند.</span></div>
|
||||
<div><button type="button" className="btn btn-secondary" onClick={() => setDrafts(Object.fromEntries(settings.map((setting) => [setting.id, toDraft(setting)])))}>لغو تغییرات</button><button type="button" className="btn btn-primary" onClick={saveAll} disabled={savingId === 'all'}><Save size={16} /> {savingId === 'all' ? 'در حال ذخیره...' : 'ذخیره همه'}</button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addOpen && <div className="drawer-overlay" onClick={() => setAddOpen(false)} />}
|
||||
{addOpen && (
|
||||
<div className="drawer settings-drawer">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowRight, Ban, CalendarDays, CheckCircle2,
|
||||
Clock3, Gauge, ListChecks, MessageSquare, Plus, ShieldAlert, Target, Users,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { formatJalaliDate, formatJalaliDateTime } from '../utils/date';
|
||||
|
||||
const tabs = [
|
||||
['overview', 'نمای کلی'], ['board', 'برد'], ['meetings', 'جلسات'], ['decisions', 'تصمیمها'],
|
||||
['action-items', 'اقدامها'], ['blockers', 'موانع'], ['metrics', 'شاخصها'], ['activity', 'فعالیت'],
|
||||
];
|
||||
const healthLabels = { on_track: 'روی برنامه', at_risk: 'در معرض ریسک', off_track: 'خارج از برنامه' };
|
||||
|
||||
function Empty({ text }) {
|
||||
return <div className="workspace-empty"><ListChecks size={25} /><span>{text}</span></div>;
|
||||
}
|
||||
|
||||
export default function SprintWorkspace() {
|
||||
const { id } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [workspace, setWorkspace] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const activeTab = searchParams.get('tab') || 'overview';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.get(`/sprints/${id}/workspace`);
|
||||
setWorkspace(data.data);
|
||||
} catch {
|
||||
setError('دریافت فضای کاری Sprint ناموفق بود.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const sprint = workspace?.sprint;
|
||||
const health = workspace?.health || {};
|
||||
const progress = workspace?.progress || {};
|
||||
const tasksByStatus = useMemo(() => (sprint?.tasks || []).reduce((result, task) => {
|
||||
if (!result[task.status]) result[task.status] = [];
|
||||
result[task.status].push(task);
|
||||
return result;
|
||||
}, {}), [sprint]);
|
||||
|
||||
if (loading) return <div className="page-container workspace-page"><div className="workspace-loading">{Array.from({ length: 8 }).map((_, index) => <div className="skeleton" key={index} />)}</div></div>;
|
||||
if (error || !sprint) 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>;
|
||||
|
||||
return (
|
||||
<div className="page-container workspace-page">
|
||||
<header className="workspace-hero">
|
||||
<div className="workspace-breadcrumb"><Link to="/sprints"><ArrowRight size={16} /> Sprintها</Link><span>/</span><span>{sprint.title}</span></div>
|
||||
<div className="workspace-hero-main">
|
||||
<div>
|
||||
<div className="workspace-title-row"><h1>{sprint.title}</h1><span className={`health-chip ${health.status === 'on_track' ? 'success' : health.status === 'at_risk' ? 'warning' : 'danger'}`}>{healthLabels[health.status]}</span></div>
|
||||
<p>{sprint.goal || 'برای این Sprint هنوز هدفی ثبت نشده است.'}</p>
|
||||
<div className="workspace-context"><span>{sprint.project?.title}</span><span>{formatJalaliDate(sprint.start_date)} تا {formatJalaliDate(sprint.end_date)}</span><span>{health.remaining_days} روز باقیمانده</span></div>
|
||||
</div>
|
||||
<div className="workspace-progress-ring" style={{ '--progress': `${health.progress || 0}%` }}>
|
||||
<strong>{health.progress || 0}%</strong><span>پیشرفت</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-quick-actions">
|
||||
<Link className="btn btn-primary" to={`/meetings?sprint_id=${sprint.id}`}><Plus size={16} /> جلسه جدید</Link>
|
||||
<Link className="btn btn-outline" to="/tasks"><Plus size={16} /> افزودن تسک</Link>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setSearchParams({ tab: 'blockers' })}><Ban size={16} /> مشاهده موانع</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="workspace-kpis">
|
||||
<article><Target size={19} /><span>کل تسکها</span><strong>{progress.total_tasks || 0}</strong></article>
|
||||
<article><CheckCircle2 size={19} /><span>تکمیلشده</span><strong>{progress.completed_tasks || 0}</strong></article>
|
||||
<article><Activity size={19} /><span>در حال انجام</span><strong>{progress.in_progress_tasks || 0}</strong></article>
|
||||
<article className="danger"><Ban size={19} /><span>مسدود</span><strong>{progress.blocked_tasks || 0}</strong></article>
|
||||
<article className="warning"><Clock3 size={19} /><span>عقبافتاده</span><strong>{progress.overdue_tasks || 0}</strong></article>
|
||||
<article><Users size={19} /><span>اعضای تیم</span><strong>{sprint.members?.length || 0}</strong></article>
|
||||
</div>
|
||||
|
||||
<nav className="workspace-tabs" aria-label="بخشهای Sprint">
|
||||
{tabs.map(([key, label]) => <button key={key} type="button" className={activeTab === key ? 'active' : ''} onClick={() => setSearchParams({ tab: key })}>{label}</button>)}
|
||||
</nav>
|
||||
|
||||
<main className="workspace-content">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="sprint-overview-grid">
|
||||
<section className="workspace-card span-2">
|
||||
<header><div><Gauge size={18} /><h2>سلامت Sprint</h2></div><span className={`health-chip ${health.status === 'on_track' ? 'success' : health.status === 'at_risk' ? 'warning' : 'danger'}`}>{healthLabels[health.status]}</span></header>
|
||||
<div className="health-comparison">
|
||||
<label>پیشرفت واقعی <span><i style={{ width: `${health.progress || 0}%` }} /></span><b>{health.progress || 0}%</b></label>
|
||||
<label>پیشرفت مورد انتظار <span><i className="expected" style={{ width: `${health.expected_progress || 0}%` }} /></span><b>{health.expected_progress || 0}%</b></label>
|
||||
</div>
|
||||
{(health.open_blockers > 0 || health.overdue_tasks > 0) && <div className="workspace-alert"><AlertTriangle size={17} /> {health.open_blockers} مانع باز و {health.overdue_tasks} تسک عقبافتاده نیازمند توجه است.</div>}
|
||||
</section>
|
||||
|
||||
<section className="workspace-card">
|
||||
<header><div><CalendarDays size={18} /><h2>جلسه بعدی</h2></div></header>
|
||||
{workspace.upcoming_meeting ? (
|
||||
<Link className="upcoming-workspace-meeting" to={`/meetings/${workspace.upcoming_meeting.id}/workspace`}>
|
||||
<strong>{workspace.upcoming_meeting.title}</strong>
|
||||
<span>{formatJalaliDate(workspace.upcoming_meeting.date)} · {workspace.upcoming_meeting.start_time?.slice(0, 5) || 'تمام روز'}</span>
|
||||
<small>{workspace.upcoming_meeting.location || 'مکان ثبت نشده'}</small>
|
||||
</Link>
|
||||
) : <Empty text="جلسه آیندهای ثبت نشده است." />}
|
||||
</section>
|
||||
|
||||
<section className="workspace-card">
|
||||
<header><div><Ban size={18} /><h2>موانع باز</h2></div><button onClick={() => setSearchParams({ tab: 'blockers' })}>همه</button></header>
|
||||
{(workspace.blockers || []).filter((item) => !['resolved', 'closed'].includes(item.status)).slice(0, 4).map((item) => <div className="workspace-list-item" key={item.id}><span className={`severity ${item.severity}`} /><div><strong>{item.title}</strong><small>{item.status}</small></div></div>)}
|
||||
{(workspace.blockers || []).length === 0 && <Empty text="مانع بازی وجود ندارد." />}
|
||||
</section>
|
||||
|
||||
<section className="workspace-card">
|
||||
<header><div><MessageSquare size={18} /><h2>تصمیمهای اخیر</h2></div><button onClick={() => setSearchParams({ tab: 'decisions' })}>همه</button></header>
|
||||
{(workspace.decisions || []).slice(0, 4).map((item) => <div className="workspace-list-item" key={item.id}><CheckCircle2 size={16} /><div><strong>{item.title}</strong><small>{item.status}</small></div></div>)}
|
||||
{(workspace.decisions || []).length === 0 && <Empty text="هنوز تصمیمی ثبت نشده است." />}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'board' && (
|
||||
<div className="workspace-board">
|
||||
{[['waiting', 'در انتظار'], ['todo', 'برای انجام'], ['in_progress', 'در حال انجام'], ['review', 'بازبینی'], ['done', 'انجامشده']].map(([status, label]) => (
|
||||
<section key={status}><header><strong>{label}</strong><span>{tasksByStatus[status]?.length || 0}</span></header>
|
||||
{(tasksByStatus[status] || []).map((task) => <article key={task.id}><strong>{task.title}</strong><small>{task.assignee?.name || 'بدون مسئول'}</small></article>)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'meetings' && <WorkspaceCollection items={workspace.meetings} empty="جلسهای به این Sprint متصل نشده است." render={(meeting) => <Link className="workspace-data-row" to={`/meetings/${meeting.id}/workspace`}><div><strong>{meeting.title}</strong><small>{formatJalaliDateTime(`${meeting.date}T${meeting.start_time || '00:00:00'}`)}</small></div><span className="badge badge-info">{meeting.status}</span></Link>} />}
|
||||
{activeTab === 'decisions' && <WorkspaceCollection items={workspace.decisions} empty="تصمیمی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>{item.description || item.rationale || 'بدون توضیح'}</small></div><span className="badge badge-primary">{item.status}</span></div>} />}
|
||||
{activeTab === 'action-items' && <WorkspaceCollection items={workspace.action_items} empty="اقدامی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>سررسید: {formatJalaliDate(item.due_date)}</small></div><span className="badge badge-warning">{item.status}</span></div>} />}
|
||||
{activeTab === 'blockers' && <WorkspaceCollection items={workspace.blockers} empty="مانعی ثبت نشده است." render={(item) => <div className="workspace-data-row"><div><strong>{item.title}</strong><small>{item.description || 'بدون توضیح'}</small></div><span className={`badge ${item.severity === 'critical' ? 'badge-danger' : 'badge-warning'}`}>{item.severity}</span></div>} />}
|
||||
{activeTab === 'metrics' && <div className="workspace-card"><header><div><Gauge size={18} /><h2>شاخصهای Sprint</h2></div></header><div className="metrics-placeholder"><strong>{health.progress || 0}%</strong><span>نرخ تکمیل فعلی</span><strong>{health.expected_progress || 0}%</strong><span>پیشرفت مورد انتظار</span><strong>{workspace.scope_changes?.length || 0}</strong><span>تغییر محدوده</span></div></div>}
|
||||
{activeTab === 'activity' && <Empty text="فعالیتهای Sprint از Audit Log در این بخش نمایش داده خواهند شد." />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceCollection({ items = [], empty, render }) {
|
||||
return <section className="workspace-card workspace-collection">{items.length === 0 ? <Empty text={empty} /> : items.map(render)}</section>;
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import PriorityBadge from '../components/PriorityBadge';
|
|||
import { CardSkeleton } from '../components/LoadingSkeleton';
|
||||
import PersianDateInput from '../components/PersianDateInput';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ArrowRight, BarChart3, CalendarDays, CheckCircle, Edit3, Flag, Play, Plus, RefreshCw, SquareKanban, Timer, Trash2, XCircle } from 'lucide-react';
|
||||
import { ArrowRight, CalendarDays, CheckCircle, Edit3, Play, Plus, Timer, Trash2, XCircle } from 'lucide-react';
|
||||
import { formatJalaliDate, isPastDate } from '../utils/date';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const initialForm = {
|
||||
title: '',
|
||||
|
|
@ -18,6 +19,7 @@ const initialForm = {
|
|||
goal: '',
|
||||
capacity_hours: '',
|
||||
member_ids: [],
|
||||
meeting_setup: [],
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
|
|
@ -47,6 +49,7 @@ export default function Sprints() {
|
|||
const [sprints, setSprints] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [meetingTypes, setMeetingTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({ project_id: '', status: '' });
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
|
@ -57,6 +60,8 @@ export default function Sprints() {
|
|||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sprintFormStep, setSprintFormStep] = useState(0);
|
||||
const [meetingScheduleIndex, setMeetingScheduleIndex] = useState(0);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [ending, setEnding] = useState(null);
|
||||
const [endForm, setEndForm] = useState({ incomplete_action: 'backlog', target_sprint_id: '' });
|
||||
|
|
@ -83,6 +88,7 @@ export default function Sprints() {
|
|||
useEffect(() => {
|
||||
api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {});
|
||||
api.get('/users', { params: { per_page: 200 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {});
|
||||
api.get('/meeting-types').then(({ data }) => setMeetingTypes(data.data || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const plannedSprints = useMemo(() => (
|
||||
|
|
@ -92,6 +98,8 @@ export default function Sprints() {
|
|||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(initialForm);
|
||||
setSprintFormStep(0);
|
||||
setMeetingScheduleIndex(0);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
|
|
@ -105,10 +113,47 @@ export default function Sprints() {
|
|||
goal: sprint.goal || '',
|
||||
capacity_hours: sprint.capacity_hours || '',
|
||||
member_ids: (sprint.members || []).map((member) => member.id),
|
||||
meeting_setup: [],
|
||||
});
|
||||
setSprintFormStep(0);
|
||||
setMeetingScheduleIndex(0);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const suggestedMeetingDate = (typeKey) => {
|
||||
if (!form.start_date || !form.end_date) return form.start_date || form.end_date || '';
|
||||
if (['sprint_review', 'sprint_retrospective'].includes(typeKey)) return form.end_date;
|
||||
if (typeKey === 'backlog_refinement') {
|
||||
const start = new Date(`${form.start_date}T00:00:00`);
|
||||
const end = new Date(`${form.end_date}T00:00:00`);
|
||||
return new Date((start.getTime() + end.getTime()) / 2).toISOString().slice(0, 10);
|
||||
}
|
||||
return form.start_date;
|
||||
};
|
||||
|
||||
const toggleMeetingSetup = (type) => {
|
||||
const exists = form.meeting_setup.some((item) => item.type_key === type.key);
|
||||
if (exists) {
|
||||
setForm({ ...form, meeting_setup: form.meeting_setup.filter((item) => item.type_key !== type.key) });
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
...form,
|
||||
meeting_setup: [...form.meeting_setup, {
|
||||
type_key: type.key,
|
||||
title: type.default_title || type.name,
|
||||
date: suggestedMeetingDate(type.key),
|
||||
start_time: type.key === 'daily_standup' ? '09:30' : '10:00',
|
||||
reminder_minutes: 30,
|
||||
recurrence_rule: type.recurrence_rule || null,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
const updateMeetingSetup = (typeKey, patch) => {
|
||||
setForm({ ...form, meeting_setup: form.meeting_setup.map((item) => item.type_key === typeKey ? { ...item, ...patch } : item) });
|
||||
};
|
||||
|
||||
const saveSprint = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim() || !form.project_id || !form.start_date || !form.end_date) {
|
||||
|
|
@ -306,6 +351,7 @@ export default function Sprints() {
|
|||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<h1 className="page-title" style={{ marginBottom: '0.5rem' }}>{selected.title}</h1>
|
||||
<Link className="btn btn-primary btn-sm" to={`/sprints/${selected.id}/workspace`}>ورود به مرکز فرمان Sprint <ArrowRight size={15} /></Link>
|
||||
<div style={{ color: 'var(--gray-600)', fontSize: '0.875rem', maxWidth: 720 }}>
|
||||
{selected.goal || 'هدف Sprint ثبت نشده است'}
|
||||
</div>
|
||||
|
|
@ -514,13 +560,28 @@ export default function Sprints() {
|
|||
function renderSprintForm() {
|
||||
return (
|
||||
<Modal title={editing ? 'ویرایش Sprint' : 'Sprint جدید'} onClose={() => setShowForm(false)} size="xl" footer={
|
||||
!editing && sprintFormStep < 2 ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setShowForm(false)}>انصراف</button>
|
||||
{sprintFormStep > 0 && <button className="btn btn-secondary" onClick={() => setSprintFormStep((step) => step - 1)}>مرحله قبل</button>}
|
||||
<button className="btn btn-primary" onClick={() => setSprintFormStep((step) => step + 1)}>{sprintFormStep === 0 ? 'مرحله بعد: جلسات' : 'مرحله بعد: زمانبندی'}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!editing && <button className="btn btn-secondary" onClick={() => setSprintFormStep((step) => Math.max(0, step - 1))}>مرحله قبل</button>}
|
||||
<button className="btn btn-primary" onClick={saveSprint} disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
|
||||
</>
|
||||
)
|
||||
}>
|
||||
<form onSubmit={saveSprint} className="compact-modal-form">
|
||||
<div className="compact-modal-grid">
|
||||
{!editing && (
|
||||
<div className="modal-wizard-steps" role="tablist" aria-label="مراحل ساخت Sprint">
|
||||
<button type="button" role="tab" aria-selected={sprintFormStep === 0} className={sprintFormStep === 0 ? 'active' : 'complete'} onClick={() => setSprintFormStep(0)}><span>{sprintFormStep > 0 ? <CheckCircle size={16} /> : '۱'}</span>اطلاعات Sprint</button>
|
||||
<button type="button" role="tab" aria-selected={sprintFormStep === 1} className={sprintFormStep === 1 ? 'active' : sprintFormStep > 1 ? 'complete' : ''} onClick={() => setSprintFormStep(1)}><span>{sprintFormStep > 1 ? <CheckCircle size={16} /> : '۲'}</span>جلسات Sprint</button>
|
||||
<button type="button" role="tab" aria-selected={sprintFormStep === 2} className={sprintFormStep === 2 ? 'active' : ''} onClick={() => setSprintFormStep(2)}><span>۳</span>زمانبندی</button>
|
||||
</div>
|
||||
)}
|
||||
{(editing || sprintFormStep === 0) && <div className="compact-modal-grid modal-wizard-panel">
|
||||
<Field label="عنوان Sprint" required><input className="form-input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></Field>
|
||||
<Field label="پروژه مرتبط" required>
|
||||
<select className="form-select" value={form.project_id} onChange={(e) => setForm({ ...form, project_id: e.target.value })}>
|
||||
|
|
@ -539,7 +600,48 @@ export default function Sprints() {
|
|||
<Field label="هدف Sprint" className="wide">
|
||||
<textarea className="form-textarea" value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="تا پایان این Sprint، نسخه اولیه پنل کاربر آماده تست داخلی شود." />
|
||||
</Field>
|
||||
</div>}
|
||||
{!editing && sprintFormStep === 1 && (
|
||||
<section className="sprint-meeting-setup">
|
||||
<div className="sprint-meeting-setup-head">
|
||||
<div><CalendarDays size={18} /><span><strong>راهاندازی جلسات Sprint</strong><small>جلسات استاندارد را انتخاب کنید؛ زمان پیشنهادی قابل ویرایش است.</small></span></div>
|
||||
{form.meeting_setup.length === 0 && <span className="badge badge-gray">بدون جلسه</span>}
|
||||
</div>
|
||||
<div className="sprint-meeting-type-grid">
|
||||
{meetingTypes.map((type) => {
|
||||
const checked = form.meeting_setup.some((item) => item.type_key === type.key);
|
||||
return <button type="button" key={type.id} className={checked ? 'active' : ''} onClick={() => toggleMeetingSetup(type)}><span>{checked ? <CheckCircle size={16} /> : <Plus size={16} />}</span><div><strong>{type.name}</strong><small>{type.suggested_duration ? `${type.suggested_duration} دقیقه` : 'مدت آزاد'}</small></div></button>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{!editing && sprintFormStep === 2 && (
|
||||
<section className="sprint-meeting-setup sprint-schedule-step">
|
||||
<div className="sprint-meeting-setup-head">
|
||||
<div><CalendarDays size={18} /><span><strong>زمانبندی جلسات</strong><small>هر جلسه را جداگانه تنظیم کنید.</small></span></div>
|
||||
<span className="badge badge-gray">{form.meeting_setup.length} جلسه</span>
|
||||
</div>
|
||||
{form.meeting_setup.length > 0 ? (
|
||||
<>
|
||||
<div className="sprint-meeting-schedule">
|
||||
{form.meeting_setup.slice(Math.min(meetingScheduleIndex, form.meeting_setup.length - 1), Math.min(meetingScheduleIndex, form.meeting_setup.length - 1) + 1).map((item) => (
|
||||
<div key={item.type_key}>
|
||||
<strong>{meetingTypes.find((type) => type.key === item.type_key)?.name || item.title}</strong>
|
||||
<PersianDateInput value={item.date} onChange={(value) => updateMeetingSetup(item.type_key, { date: value })} />
|
||||
<input className="form-input" type="time" value={item.start_time || ''} onChange={(event) => updateMeetingSetup(item.type_key, { start_time: event.target.value })} />
|
||||
<input className="form-input" type="number" min="0" max="10080" value={item.reminder_minutes ?? 30} onChange={(event) => updateMeetingSetup(item.type_key, { reminder_minutes: Number(event.target.value) })} aria-label="یادآوری به دقیقه" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="schedule-wizard-navigation">
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={meetingScheduleIndex === 0} onClick={() => setMeetingScheduleIndex((index) => Math.max(0, index - 1))}>جلسه قبلی</button>
|
||||
<span>{Math.min(meetingScheduleIndex + 1, form.meeting_setup.length)} از {form.meeting_setup.length}</span>
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={meetingScheduleIndex >= form.meeting_setup.length - 1} onClick={() => setMeetingScheduleIndex((index) => Math.min(form.meeting_setup.length - 1, index + 1))}>جلسه بعدی</button>
|
||||
</div>
|
||||
</>
|
||||
) : <div className="wizard-empty-state">جلسهای انتخاب نشده است؛ میتوانید Sprint را بدون جلسه ذخیره کنید.</div>}
|
||||
</section>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -226,7 +226,8 @@ export default function Team() {
|
|||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره'}</button>
|
||||
</>
|
||||
}>
|
||||
<form onSubmit={handleSave}>
|
||||
<form onSubmit={handleSave} className="compact-modal-form">
|
||||
<div className="compact-modal-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">نام</label>
|
||||
<input className="form-input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
|
|
@ -269,6 +270,7 @@ export default function Team() {
|
|||
{roles.map((r) => <option key={r.id} value={r.id}>{r.display_name || r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -38,10 +38,13 @@ export default function PwaLayout() {
|
|||
<PwaNotificationProvider>
|
||||
<div className="pwa-shell" dir="rtl">
|
||||
<header className="pwa-header">
|
||||
<div className="pwa-header-brand">
|
||||
<img src="/brand/project-mark.png" alt="" />
|
||||
<div>
|
||||
<span>اپلیکیشن موبایل</span>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{!isProfilePage && <PwaHeaderActions user={user} />}
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AlertTriangle, Bell, CheckCircle2, Clock3, MessageCircle, TimerReset, Trash2, UserRoundCheck } from 'lucide-react';
|
||||
import { AlertTriangle, Archive, Bell, CheckCircle2, Clock3, MessageCircle, Trash2, UserRoundCheck } from 'lucide-react';
|
||||
import { getPwaNotificationMessage, getPwaNotificationTitle, pwaNotificationTypeLabels } from './pwaNotificationUtils';
|
||||
import { formatPwaNotificationTime } from './PwaNotificationTime';
|
||||
import useBidirectionalNotificationSwipe from './useBidirectionalNotificationSwipe';
|
||||
|
|
@ -16,20 +16,22 @@ const typeIcons = {
|
|||
system: Bell,
|
||||
};
|
||||
|
||||
export default function PwaNotificationCard({ notification, onOpen, onToggleUnread, onRemind, onDismiss }) {
|
||||
export default function PwaNotificationCard({ notification, onOpen, onToggleUnread, onArchive, onDelete }) {
|
||||
const Icon = typeIcons[notification.type] || Bell;
|
||||
const read = notification.is_read || notification.read_at;
|
||||
const { translateX, phase, dragging, exiting, direction, progress, swipeHandlers } = useBidirectionalNotificationSwipe({
|
||||
rightAction: 'archive',
|
||||
leftAction: 'delete',
|
||||
onExitComplete: (action) => {
|
||||
if (action === 'remind') onRemind(notification);
|
||||
if (action === 'dismiss') onDismiss(notification);
|
||||
if (action === 'archive') onArchive(notification);
|
||||
if (action === 'delete') onDelete(notification);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<article className={`pwa-notification-swipe ${phase} ${direction || ''} ${dragging ? 'dragging' : ''} ${exiting ? 'exiting' : ''}`} style={{ '--notification-swipe-progress': progress }}>
|
||||
<div className="pwa-notification-swipe-action remind"><TimerReset size={18} /><span>یادآوری ۳۰ دقیقه دیگر</span></div>
|
||||
<div className="pwa-notification-swipe-action dismiss"><Trash2 size={18} /><span>خروج از لیست</span></div>
|
||||
<div className="pwa-notification-swipe-action archive"><Archive size={18} /><span>آرشیو</span></div>
|
||||
<div className="pwa-notification-swipe-action delete"><Trash2 size={18} /><span>حذف</span></div>
|
||||
<div
|
||||
className={`pwa-notification-card ${read ? 'read' : 'unread'}`}
|
||||
style={{ '--notification-card-translate-x': `${translateX}px` }}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { formatJalaliDateTime } from '../utils/date';
|
|||
import PwaBottomSheet from './PwaBottomSheet';
|
||||
import { getPwaNotificationMessage, getPwaNotificationTitle } from './pwaNotificationUtils';
|
||||
|
||||
export default function PwaNotificationDetailSheet({ notification, onClose }) {
|
||||
export default function PwaNotificationDetailSheet({ notification, onClose, onNavigate }) {
|
||||
return (
|
||||
<PwaBottomSheet open={Boolean(notification)} title="جزئیات اعلان" onClose={onClose}>
|
||||
{notification && (
|
||||
|
|
@ -11,6 +11,7 @@ export default function PwaNotificationDetailSheet({ notification, onClose }) {
|
|||
<h2>{getPwaNotificationTitle(notification)}</h2>
|
||||
<p>{getPwaNotificationMessage(notification)}</p>
|
||||
<small>{formatJalaliDateTime(notification.created_at)}</small>
|
||||
<button type="button" className="pwa-primary-button" onClick={onNavigate}>رفتن به بخش مربوطه</button>
|
||||
</div>
|
||||
)}
|
||||
</PwaBottomSheet>
|
||||
|
|
|
|||
|
|
@ -65,18 +65,7 @@ export default function PwaNotificationsPage() {
|
|||
notify({ tone: 'error', title: 'اعلان', message: 'اعلان خوانده نشد.' });
|
||||
}
|
||||
|
||||
const target = getPwaNotificationTarget(nextNotification);
|
||||
if (target.available && target.path) {
|
||||
navigate(target.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextNotification.type === 'system' || !target.available) {
|
||||
setDetail(nextNotification);
|
||||
if (!target.available && nextNotification.type !== 'system') {
|
||||
notify({ tone: 'error', title: 'اعلان', message: 'مورد مربوط به این اعلان در دسترس نیست.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markUnread = async (notification) => {
|
||||
|
|
@ -94,28 +83,26 @@ export default function PwaNotificationsPage() {
|
|||
setNotifications((current) => current.filter((item) => item.id !== notification.id));
|
||||
};
|
||||
|
||||
const remindNotification = async (notification) => {
|
||||
const archiveNotification = async (notification) => {
|
||||
removeFromList(notification);
|
||||
try {
|
||||
const { data } = await api.patch(`/pwa/notifications/${notification.id}/remind`);
|
||||
setUnread(data.meta?.unread_count || 0);
|
||||
setUnreadCount(data.meta?.unread_count || 0);
|
||||
notify({ tone: 'success', title: 'یادآوری', message: '۳۰ دقیقه دیگر یادآوری میشود.' });
|
||||
await api.patch(`/notifications/${notification.id}/archive`);
|
||||
refreshUnreadCount();
|
||||
notify({ tone: 'success', title: 'اعلان', message: 'اعلان آرشیو شد.' });
|
||||
} catch (err) {
|
||||
notify({ tone: 'error', title: 'یادآوری', message: err.response?.data?.message || 'ثبت یادآوری انجام نشد.' });
|
||||
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'آرشیو اعلان انجام نشد.' });
|
||||
fetchNotifications(true);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissNotification = async (notification) => {
|
||||
const deleteNotification = async (notification) => {
|
||||
removeFromList(notification);
|
||||
try {
|
||||
const { data } = await api.patch(`/pwa/notifications/${notification.id}/dismiss`);
|
||||
setUnread(data.meta?.unread_count || 0);
|
||||
setUnreadCount(data.meta?.unread_count || 0);
|
||||
notify({ tone: 'success', title: 'اعلان', message: 'اعلان از لیست خارج شد.' });
|
||||
await api.delete(`/notifications/${notification.id}`);
|
||||
refreshUnreadCount();
|
||||
notify({ tone: 'success', title: 'اعلان', message: 'اعلان حذف شد.' });
|
||||
} catch (err) {
|
||||
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'خروج اعلان از لیست انجام نشد.' });
|
||||
notify({ tone: 'error', title: 'اعلان', message: err.response?.data?.message || 'حذف اعلان انجام نشد.' });
|
||||
fetchNotifications(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -171,8 +158,8 @@ export default function PwaNotificationsPage() {
|
|||
notification={notification}
|
||||
onOpen={openNotification}
|
||||
onToggleUnread={markUnread}
|
||||
onRemind={remindNotification}
|
||||
onDismiss={dismissNotification}
|
||||
onArchive={archiveNotification}
|
||||
onDelete={deleteNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -183,7 +170,19 @@ export default function PwaNotificationsPage() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
<PwaNotificationDetailSheet notification={detail} onClose={() => setDetail(null)} />
|
||||
<PwaNotificationDetailSheet
|
||||
notification={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onNavigate={() => {
|
||||
const target = getPwaNotificationTarget(detail);
|
||||
if (target.available && target.path) {
|
||||
setDetail(null);
|
||||
navigate(target.path);
|
||||
} else {
|
||||
notify({ tone: 'error', title: 'اعلان', message: 'مورد مربوط به این اعلان در دسترس نیست.' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const labels = {
|
|||
denied: 'توسط مرورگر مسدود شده',
|
||||
};
|
||||
|
||||
export default function PwaPushNotificationStatus({ pushReady = false }) {
|
||||
export default function PwaPushNotificationStatus({ pushReady: _pushReady = false }) {
|
||||
const { notify } = usePwaNotification();
|
||||
const supported = typeof window !== 'undefined' && 'Notification' in window;
|
||||
const [permission, setPermission] = useState(() => supported ? Notification.permission : 'unsupported');
|
||||
|
|
|
|||