Compare commits
41 Commits
new-api
...
bd4789a85c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4789a85c | ||
|
|
01d6584e71 | ||
|
|
2d6766760f | ||
|
|
bb69e7f953 | ||
|
|
d705c7c56d | ||
|
|
768a0aaeb0 | ||
|
|
0fffd2eb3d | ||
|
|
041a3e7bd5 | ||
|
|
af21c1c77e | ||
|
|
5aa2447e23 | ||
|
|
74ead9d0ed | ||
|
|
aad799d29f | ||
|
|
6e68ed45e4 | ||
|
|
067c1d2351 | ||
|
|
0ae3343b17 | ||
|
|
516bc4b26b | ||
|
|
d77605db75 | ||
|
|
a2799a8d1b | ||
|
|
86c68951bb | ||
|
|
cf0f8f0c3f | ||
|
|
209b1f2530 | ||
|
|
a6ae5724f6 | ||
|
|
a173f17cae | ||
|
|
39e85df27a | ||
|
|
2d6ba2a70d | ||
|
|
abdfe190a7 | ||
|
|
032ae6ebf8 | ||
|
|
2474f268d1 | ||
|
|
7f744beec4 | ||
|
|
7a987a33c9 | ||
|
|
7ca903ec3c | ||
|
|
56e57719eb | ||
|
|
680cf9c4fd | ||
|
|
375e3b166c | ||
|
|
2fab528d0a | ||
|
|
677e101181 | ||
|
|
e041b85e74 | ||
|
|
aad67585ef | ||
| 4f94af7fc4 | |||
| ee8d9cdb8f | |||
| 6062d0b3a0 |
83
app/Http/Controllers/Admin/DashboardController.php
Normal file
83
app/Http/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\Suggestion;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\UserSubscriber;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Calculate General Statistics
|
||||
$totalUsers = User::count();
|
||||
|
||||
// 2. Safely Count Free Subscribers using the relationship
|
||||
$freeSubscribers = UserSubscriber::where('expired_at', '>=', now())
|
||||
->whereHas('subscribe', function (Builder $query) {
|
||||
$query->where('is_free', true);
|
||||
})
|
||||
->distinct('user_id')
|
||||
->count('user_id');
|
||||
|
||||
// 3. Get Paid Subscribers & Breakdown by Plan Type
|
||||
$paidBreakdown = UserSubscriber::where('user_subscribers.expired_at', '>=', now())
|
||||
->whereHas('subscribe', function (Builder $query) {
|
||||
$query->where('is_free', false);
|
||||
})
|
||||
->join('subscribe_plans', 'user_subscribers.subscribe_plan_id', '=', 'subscribe_plans.id')
|
||||
->selectRaw('subscribe_plans.name as plan_name, count(DISTINCT user_subscribers.user_id) as total')
|
||||
->groupBy('subscribe_plans.name')
|
||||
->get();
|
||||
|
||||
// Total paid is the sum of the breakdown
|
||||
$paidSubscribers = $paidBreakdown->sum('total');
|
||||
|
||||
// 4. Fetch Recent Data (Limit to 5)
|
||||
$recentSuggestions = Suggestion::with('user')->latest()->take(5)->get();
|
||||
|
||||
// Order tickets by updated_at so newly replied tickets jump to the top
|
||||
$recentTickets = Ticket::with('user')->latest('updated_at')->take(5)->get();
|
||||
|
||||
// 5. Prepare Chart Data (Strictly the last 7 calendar days)
|
||||
$chartDates = [];
|
||||
$newUsersData = [];
|
||||
$paidSubsData = [];
|
||||
$freeSubsData = [];
|
||||
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i);
|
||||
$dbDate = $date->format('Y-m-d');
|
||||
|
||||
// Format X-axis to Jalali for the chart labels
|
||||
$chartDates[] = \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d');
|
||||
|
||||
// Count records for this specific exact date
|
||||
$newUsersData[] = User::whereDate('created_at', $dbDate)->count();
|
||||
|
||||
$paidSubsData[] = UserSubscriber::whereDate('created_at', $dbDate)
|
||||
->whereHas('subscribe', fn($q) => $q->where('is_free', false))->count();
|
||||
|
||||
$freeSubsData[] = UserSubscriber::whereDate('created_at', $dbDate)
|
||||
->whereHas('subscribe', fn($q) => $q->where('is_free', true))->count();
|
||||
}
|
||||
|
||||
return view('admin.dashboard.index', compact(
|
||||
'totalUsers',
|
||||
'paidSubscribers',
|
||||
'freeSubscribers',
|
||||
'paidBreakdown',
|
||||
'recentSuggestions',
|
||||
'recentTickets',
|
||||
'chartDates',
|
||||
'newUsersData',
|
||||
'paidSubsData',
|
||||
'freeSubsData'
|
||||
));
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Admin/QuizCategoryController.php
Normal file
47
app/Http/Controllers/Admin/QuizCategoryController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QuizCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QuizCategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Load categories and count how many quizzes are in each
|
||||
$categories = QuizCategory::withCount('quizzes')->latest()->get();
|
||||
return view('admin.quiz-categories.index', compact('categories'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
QuizCategory::create([
|
||||
'title' => $request->title,
|
||||
// Automatically generate a unique slug for URLs
|
||||
'slug' => Str::slug($request->title) . '-' . time(),
|
||||
'description' => $request->description,
|
||||
'is_active' => $request->has('is_active'),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'دستهبندی با موفقیت ایجاد شد.');
|
||||
}
|
||||
|
||||
public function destroy(QuizCategory $quiz_category)
|
||||
{
|
||||
// Check if the category has quizzes before deleting
|
||||
if ($quiz_category->quizzes()->count() > 0) {
|
||||
return back()->with('error', 'این دستهبندی شامل آزمون است و نمیتواند حذف شود. ابتدا آزمونها را حذف کنید.');
|
||||
}
|
||||
|
||||
$quiz_category->delete();
|
||||
return back()->with('success', 'دستهبندی با موفقیت حذف شد.');
|
||||
}
|
||||
}
|
||||
109
app/Http/Controllers/Admin/QuizController.php
Normal file
109
app/Http/Controllers/Admin/QuizController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\QuizCategory;
|
||||
use App\Models\QuizSubject;
|
||||
use App\Models\Question;
|
||||
use App\Models\QuestionOption;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QuizController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$quizzes = Quiz::with('category')->withCount('questions')->latest()->paginate(15);
|
||||
return view('admin.quizzes.index', compact('quizzes'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$categories = QuizCategory::where('is_active', true)->get();
|
||||
return view('admin.quizzes.create', compact('categories'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 1. Validate only the text and number fields
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:quiz_categories,id',
|
||||
'year' => 'nullable|integer',
|
||||
'type' => 'required|in:multiple_choice,descriptive,mixed',
|
||||
'duration_minutes' => 'required|integer|min:1',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 2. Safely handle checkboxes and generate slug
|
||||
$validated['slug'] = \Illuminate\Support\Str::slug($validated['title']) . '-' . time();
|
||||
$validated['has_negative_score'] = $request->has('has_negative_score');
|
||||
$validated['is_free'] = $request->has('is_free');
|
||||
$validated['is_published'] = $request->has('is_published');
|
||||
|
||||
// 3. Create the quiz
|
||||
$quiz = Quiz::create($validated);
|
||||
|
||||
return redirect()->route('quizzes.questions.index', $quiz->id)
|
||||
->with('success', 'آزمون با موفقیت ایجاد شد. اکنون میتوانید سوالات را اضافه کنید.');
|
||||
}
|
||||
|
||||
public function destroy(Quiz $quiz)
|
||||
{
|
||||
$quiz->delete();
|
||||
return redirect()->route('quizzes.index')->with('success', 'آزمون حذف شد.');
|
||||
}
|
||||
|
||||
// --- Questions Management ---
|
||||
|
||||
public function questions(Quiz $quiz)
|
||||
{
|
||||
$quiz->load(['questions.options', 'questions.subject']);
|
||||
$subjects = QuizSubject::all();
|
||||
return view('admin.quizzes.questions', compact('quiz', 'subjects'));
|
||||
}
|
||||
|
||||
public function storeQuestion(Request $request, Quiz $quiz)
|
||||
{
|
||||
$request->validate([
|
||||
'question_text' => 'required|string',
|
||||
'type' => 'required|in:multiple_choice,descriptive',
|
||||
'subject_id' => 'nullable|exists:quiz_subjects,id',
|
||||
'score' => 'required|numeric|min:0.5',
|
||||
'solution_explanation' => 'nullable|string',
|
||||
// Options validation for multiple-choice questions
|
||||
'options' => 'required_if:type,multiple_choice|array|min:4|max:4',
|
||||
'options.*' => 'required_if:type,multiple_choice|string',
|
||||
'correct_option' => 'required_if:type,multiple_choice|integer|between:0,3',
|
||||
]);
|
||||
|
||||
$question = $quiz->questions()->create([
|
||||
'subject_id' => $request->subject_id,
|
||||
'question_text' => $request->question_text,
|
||||
'type' => $request->type,
|
||||
'score' => $request->score,
|
||||
'solution_explanation' => $request->solution_explanation,
|
||||
'order' => $quiz->questions()->count() + 1,
|
||||
]);
|
||||
|
||||
// Save options if question is multiple choice
|
||||
if ($request->type === 'multiple_choice' && $request->has('options')) {
|
||||
foreach ($request->options as $index => $optionText) {
|
||||
$question->options()->create([
|
||||
'option_text' => $optionText,
|
||||
'is_correct' => ($index == $request->correct_option),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'سوال با موفقیت ثبت شد.');
|
||||
}
|
||||
|
||||
public function destroyQuestion(Question $question)
|
||||
{
|
||||
$question->delete();
|
||||
return back()->with('success', 'سوال حذف شد.');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Admin/QuizSubjectController.php
Normal file
44
app/Http/Controllers/Admin/QuizSubjectController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QuizSubject;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QuizSubjectController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Load subjects and count how many questions belong to each
|
||||
$subjects = QuizSubject::withCount('questions')->latest()->get();
|
||||
return view('admin.quiz-subjects.index', compact('subjects'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
QuizSubject::create([
|
||||
'title' => $request->title,
|
||||
// Generate a unique slug for potential API/URL usage
|
||||
'slug' => Str::slug($request->title) . '-' . time(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'درس با موفقیت اضافه شد.');
|
||||
}
|
||||
|
||||
public function destroy(QuizSubject $quiz_subject)
|
||||
{
|
||||
// Prevent deletion if questions are already assigned to this subject
|
||||
if ($quiz_subject->questions()->count() > 0) {
|
||||
return back()->with('error', 'این درس دارای سوال است و نمیتواند حذف شود. ابتدا سوالات مربوطه را ویرایش یا حذف کنید.');
|
||||
}
|
||||
|
||||
$quiz_subject->delete();
|
||||
return back()->with('success', 'درس با موفقیت حذف شد.');
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Admin/TicketController.php
Normal file
78
app/Http/Controllers/Admin/TicketController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Eager load the user who created the ticket
|
||||
$query = Ticket::query()->with('user');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = $request->q;
|
||||
$query->where(function ($qry) use ($q) {
|
||||
$qry->where('subject', 'like', "%{$q}%")
|
||||
->orWhereHas('user', fn ($u) => $u->where('mobile', 'like', "%{$q}%")
|
||||
->orWhere('name', 'like', "%{$q}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// Optional: Filter by status (open, answered, user_reply, closed)
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
|
||||
// Order by updated_at so tickets with new replies jump to the top
|
||||
$tickets = $query->latest('updated_at')->paginate($perPage)->withQueryString();
|
||||
|
||||
return view('admin.tickets.index', compact('tickets'));
|
||||
}
|
||||
|
||||
public function show(Ticket $ticket)
|
||||
{
|
||||
// Load messages and the user belonging to the ticket
|
||||
$ticket->load(['messages.user', 'user']);
|
||||
|
||||
return view('admin.tickets.show', compact('ticket'));
|
||||
}
|
||||
|
||||
public function reply(Request $request, Ticket $ticket)
|
||||
{
|
||||
$request->validate([
|
||||
'message' => 'required|string',
|
||||
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120',
|
||||
]);
|
||||
|
||||
$attachmentPath = null;
|
||||
if ($request->hasFile('attachment')) {
|
||||
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
|
||||
}
|
||||
|
||||
TicketMessage::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => auth()->id(), // This will be the Admin's ID
|
||||
'message' => $request->message,
|
||||
'attachment_path' => $attachmentPath,
|
||||
]);
|
||||
|
||||
$ticket->update(['status' => 'answered']);
|
||||
|
||||
return redirect()->route('tickets.show', $ticket->id)
|
||||
->with('success', 'پاسخ شما با موفقیت ارسال شد.');
|
||||
}
|
||||
|
||||
public function close(Ticket $ticket)
|
||||
{
|
||||
$ticket->update(['status' => 'closed']);
|
||||
|
||||
return redirect()->route('tickets.index')
|
||||
->with('success', 'تیکت با موفقیت بسته شد.');
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,8 @@ class ArtController extends Controller
|
||||
}
|
||||
|
||||
$art->is_like = $this->isLiked($art->id);
|
||||
$art->note = Note::select('id', 'note', 'color_code', 'created_at')->where('user_id', auth()->user()->id)->where('art_id', $id)->get();
|
||||
$art->note = Note::select('id', 'note', 'color_code', 'created_at')->where('user_id', auth()->id())->where('art_id', $id)->get();
|
||||
$art->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $id)->get();
|
||||
$art->category = $law?->category?->name;
|
||||
$art->route = array_values($this->route(Art::class, $art));
|
||||
|
||||
@@ -109,7 +110,8 @@ class ArtController extends Controller
|
||||
if ($previousArt) {
|
||||
$previousLaw = Law::find($previousArt->law_id);
|
||||
$previousArt->is_like = $this->isLiked($previousArt->id);
|
||||
$previousArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->user()->id)->where('art_id', $previousArt->id)->get();
|
||||
$previousArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->id())->where('art_id', $previousArt->id)->get();
|
||||
$previousArt->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $previousArt->id)->get();
|
||||
$previousArt->category = $previousLaw?->category?->name;
|
||||
$previousArt->route = array_values($this->route(Art::class, $previousArt));
|
||||
}
|
||||
@@ -123,7 +125,8 @@ class ArtController extends Controller
|
||||
if ($nextArt) {
|
||||
$nextLaw = Law::find($nextArt->law_id);
|
||||
$nextArt->is_like = $this->isLiked($nextArt->id);
|
||||
$nextArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->user()->id)->where('art_id', $nextArt->id)->get();
|
||||
$nextArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->id())->where('art_id', $nextArt->id)->get();
|
||||
$nextArt->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $nextArt->id)->get();
|
||||
$nextArt->category = $nextLaw?->category?->name;
|
||||
$nextArt->route = array_values($this->route(Art::class, $nextArt));
|
||||
}
|
||||
|
||||
58
app/Http/Controllers/api/ArtHighlightController.php
Normal file
58
app/Http/Controllers/api/ArtHighlightController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtHighlight;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArtHighlightController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'art_id' => 'required',
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
ArtHighlight::create([
|
||||
'user_id' => auth()->id(),
|
||||
'art_id' => $validated['art_id'],
|
||||
'start_index' => $validated['start_index'],
|
||||
'end_index' => $validated['end_index'],
|
||||
'selected_text' => $validated['selected_text'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$highlight = ArtHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->update($validated);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$highlight = ArtHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use App\Models\BylawNode;
|
||||
use App\Models\BylawCodificationLink;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BylawApiController extends Controller
|
||||
{
|
||||
@@ -80,8 +79,19 @@ class BylawApiController extends Controller
|
||||
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$items = collect($paginator->items())->map(function ($item) {
|
||||
return $this->formatBylaw($item);
|
||||
$docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
|
||||
$nodesByDoc = collect();
|
||||
|
||||
if (!empty($docIds)) {
|
||||
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy(fn($node) => (int)$node->document_id);
|
||||
}
|
||||
|
||||
$items = collect($paginator->items())->map(function ($item) use ($nodesByDoc) {
|
||||
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
|
||||
return $this->formatBylaw($item, $itemNodes);
|
||||
});
|
||||
|
||||
$responseData = [
|
||||
@@ -110,27 +120,37 @@ class BylawApiController extends Controller
|
||||
|
||||
$formatted = $this->formatBylaw($bylaw);
|
||||
|
||||
$formatted['note'] = \App\Models\BylawNote::select('id', 'note', 'color_code', 'created_at')
|
||||
->where('user_id', auth()->user()->id)
|
||||
->where('bylaw_document_id', $bylaw->id)
|
||||
->get();
|
||||
|
||||
$formatted['highlight'] = \App\Models\BylawHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
|
||||
->where('user_id', auth()->id())
|
||||
->where('bylaw_document_id', $bylaw->id)
|
||||
->get();
|
||||
|
||||
|
||||
if (!$formatted['is_locked']) {
|
||||
$nodes = BylawNode::with('type')
|
||||
$eloquentNodes = BylawNode::with('type')
|
||||
->where('document_id', $bylaw->id)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->map(function ($node) {
|
||||
return [
|
||||
'id' => $node->id,
|
||||
'parent_id' => $node->parent_id,
|
||||
'node_type' => optional($node->type)->name,
|
||||
'node_identifier' => $node->node_identifier,
|
||||
'title' => $node->title,
|
||||
'node_content' => $node->node_content,
|
||||
'sort_order' => $node->sort_order,
|
||||
'valid_from' => $node->valid_from,
|
||||
'valid_to' => $node->valid_to,
|
||||
'node_status' => $node->node_status,
|
||||
];
|
||||
});
|
||||
->get();
|
||||
|
||||
$formatted['nodes'] = $nodes->toArray();
|
||||
$formatted['nodes'] = $eloquentNodes->map(function ($node) {
|
||||
return [
|
||||
'id' => $node->id,
|
||||
'parent_id' => $node->parent_id,
|
||||
'node_type' => optional($node->type)->name,
|
||||
'node_identifier' => $node->node_identifier,
|
||||
'title' => $node->title,
|
||||
'node_content' => $node->node_content,
|
||||
'sort_order' => $node->sort_order,
|
||||
'valid_from' => $node->valid_from,
|
||||
'valid_to' => $node->valid_to,
|
||||
'node_status' => $node->node_status,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$links = BylawCodificationLink::where('source_entity_type', 'bylaw_doc')
|
||||
->where('source_entity_id', $bylaw->id)
|
||||
@@ -175,7 +195,8 @@ class BylawApiController extends Controller
|
||||
->orWhereIn('id', function($subQuery) use ($searchTerm) {
|
||||
$subQuery->select('document_id')
|
||||
->from('bylaw_nodes')
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%");
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('title', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -193,8 +214,19 @@ class BylawApiController extends Controller
|
||||
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$items = collect($paginator->items())->map(function ($item) {
|
||||
return $this->formatBylaw($item);
|
||||
$docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
|
||||
$nodesByDoc = collect();
|
||||
|
||||
if (!empty($docIds)) {
|
||||
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy(fn($node) => (int)$node->document_id);
|
||||
}
|
||||
|
||||
$items = collect($paginator->items())->map(function ($item) use ($nodesByDoc) {
|
||||
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
|
||||
return $this->formatBylaw($item, $itemNodes);
|
||||
});
|
||||
|
||||
$responseData = [
|
||||
@@ -224,18 +256,32 @@ class BylawApiController extends Controller
|
||||
->orWhereIn('id', function($subQuery) use ($searchTerm) {
|
||||
$subQuery->select('document_id')
|
||||
->from('bylaw_nodes')
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%");
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('title', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
$items = $query->orderBy('approval_date', 'desc')->take(5)->get()->map(function ($item) {
|
||||
return $this->formatBylaw($item);
|
||||
$items = $query->orderBy('approval_date', 'desc')->take(5)->get();
|
||||
|
||||
$docIds = $items->pluck('id')->map(fn($id) => (int)$id)->toArray();
|
||||
$nodesByDoc = collect();
|
||||
|
||||
if (!empty($docIds)) {
|
||||
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy(fn($node) => (int)$node->document_id);
|
||||
}
|
||||
|
||||
$formattedItems = $items->map(function ($item) use ($nodesByDoc) {
|
||||
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
|
||||
return $this->formatBylaw($item, $itemNodes);
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'items' => $items,
|
||||
'items' => $formattedItems,
|
||||
'count' => $count
|
||||
], 'Success', 'Fast Search Results');
|
||||
}
|
||||
@@ -290,38 +336,66 @@ class BylawApiController extends Controller
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
|
||||
$results = array_values(array_filter([
|
||||
$previousItem ? $this->formatBylaw($previousItem) : null,
|
||||
$this->formatBylaw($current),
|
||||
$nextItem ? $this->formatBylaw($nextItem) : null
|
||||
]));
|
||||
$itemsToFormat = array_filter([$previousItem, $current, $nextItem]);
|
||||
|
||||
$docIds = collect($itemsToFormat)->pluck('id')->map(fn($id) => (int)$id)->toArray();
|
||||
$nodesByDoc = collect();
|
||||
|
||||
if (!empty($docIds)) {
|
||||
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy(fn($node) => (int)$node->document_id);
|
||||
}
|
||||
|
||||
$results = collect($itemsToFormat)->map(function($item) use ($nodesByDoc) {
|
||||
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
|
||||
$formatted = $this->formatBylaw($item, $itemNodes);
|
||||
|
||||
$formatted['note'] = \App\Models\BylawNote::select('id', 'note', 'color_code', 'created_at')
|
||||
->where('user_id', auth()->user()->id)
|
||||
->where('bylaw_document_id', $item->id)
|
||||
->get();
|
||||
|
||||
$formatted['highlight'] = \App\Models\BylawHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
|
||||
->where('user_id', auth()->id())
|
||||
->where('bylaw_document_id', $item->id)
|
||||
->get();
|
||||
|
||||
return $formatted;
|
||||
})->values()->toArray();
|
||||
|
||||
return $this->success($results, 'Success', 'Pager Window Details');
|
||||
}
|
||||
|
||||
private function isLockedForCurrentUser($item): bool
|
||||
{
|
||||
return ! auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
|
||||
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
|
||||
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
return (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
|
||||
private function formatBylaw($item)
|
||||
private function formatBylaw($item, $itemNodes = null)
|
||||
{
|
||||
$isLocked = $this->isLockedForCurrentUser($item);
|
||||
|
||||
$description = $item->description;
|
||||
if ($itemNodes === null) {
|
||||
$itemNodes = BylawNode::where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
|
||||
}
|
||||
|
||||
// Check if description is practically empty (null, empty string, or just empty WYSIWYG HTML like <p></p>)
|
||||
$cleanDesc = trim(strip_tags($description ?? ''));
|
||||
$nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
|
||||
->map(function ($text) {
|
||||
$safeText = trim((string)$text);
|
||||
return mb_convert_encoding($safeText, 'UTF-8', 'UTF-8');
|
||||
})
|
||||
->filter(function ($text) {
|
||||
return $text !== '';
|
||||
})
|
||||
->implode("\n\n");
|
||||
|
||||
// Direct Query Fallback: Fetch node_content manually via DB facade to bypass any Model restrictions
|
||||
if (empty($cleanDesc)) {
|
||||
$nodeContents = DB::table('bylaw_nodes')
|
||||
->where('document_id', $item->id)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->pluck('node_content')
|
||||
->toArray();
|
||||
|
||||
$description = implode("\n\n", array_filter($nodeContents));
|
||||
if (empty($nodeContentAsDescription)) {
|
||||
$nodeContentAsDescription = mb_convert_encoding(trim((string)$item->description), 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -341,8 +415,9 @@ class BylawApiController extends Controller
|
||||
'execution_date' => $item->execution_date,
|
||||
'bylaw_type' => $item->bylaw_type,
|
||||
'global_status' => $item->global_status,
|
||||
'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset('storage/' . $item->pdf_file_path) : null),
|
||||
'description' => $isLocked ? null : $description,
|
||||
'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset(str_replace('storage/storage/', 'storage/', 'storage/' . $item->pdf_file_path)) : null),
|
||||
'description' => $isLocked ? null : $nodeContentAsDescription,
|
||||
'debug_node_count' => collect($itemNodes)->count(),
|
||||
'is_locked' => $isLocked,
|
||||
];
|
||||
}
|
||||
|
||||
58
app/Http/Controllers/api/BylawHighlightController.php
Normal file
58
app/Http/Controllers/api/BylawHighlightController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BylawHighlight;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BylawHighlightController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bylaw_id' => 'required', // Clean request parameter
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
BylawHighlight::create([
|
||||
'user_id' => auth()->id(),
|
||||
'bylaw_document_id' => $validated['bylaw_id'], // Mapped to DB column
|
||||
'start_index' => $validated['start_index'],
|
||||
'end_index' => $validated['end_index'],
|
||||
'selected_text' => $validated['selected_text'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$highlight = BylawHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->update($validated);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$highlight = BylawHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/api/BylawNoteController.php
Normal file
56
app/Http/Controllers/api/BylawNoteController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BylawNote;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BylawNoteController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bylaw_id' => 'required', // Cleaned up for the frontend!
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
BylawNote::query()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'bylaw_document_id' => $validated['bylaw_id'], // Maps perfectly to the DB
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
$note = BylawNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
|
||||
$note->update([
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$note = BylawNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
$note->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ namespace App\Http\Controllers\api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Folder;
|
||||
use App\Models\FolderArt;
|
||||
use App\Models\FolderBylaw;
|
||||
use App\Models\FolderJudicialPrecedent;
|
||||
use App\Models\FolderLegalOpinion;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FolderController extends Controller
|
||||
{
|
||||
@@ -14,12 +18,18 @@ class FolderController extends Controller
|
||||
|
||||
public function index()
|
||||
{
|
||||
$folder = Folder::query()->where('user_id', auth()->id())->get()
|
||||
// Eager load everything to prevent N+1 Database issues
|
||||
$folder = Folder::with(['arts', 'bylaws', 'judicialPrecedents', 'legalOpinions'])
|
||||
->where('user_id', auth()->id())
|
||||
->get()
|
||||
->map(function ($q) {
|
||||
return [
|
||||
'id' => $q->id,
|
||||
'name' => $q->name,
|
||||
'count_of_arts' => $q->arts->count(),
|
||||
'count_of_bylaws' => $q->bylaws->count(),
|
||||
'count_of_judicials' => $q->judicialPrecedents->count(),
|
||||
'count_of_opinions' => $q->legalOpinions->count(),
|
||||
'created_at' => $q->created_at,
|
||||
];
|
||||
});
|
||||
@@ -34,7 +44,7 @@ class FolderController extends Controller
|
||||
]);
|
||||
|
||||
Folder::create([
|
||||
'user_id' => auth()->user()->id,
|
||||
'user_id' => auth()->id(),
|
||||
'name' => $validted['name'],
|
||||
]);
|
||||
|
||||
@@ -44,40 +54,72 @@ class FolderController extends Controller
|
||||
public function assign(Request $request)
|
||||
{
|
||||
$validted = $request->validate([
|
||||
'folder_id' => 'required',
|
||||
'art_id' => 'required',
|
||||
'folder_id' => 'required',
|
||||
'art_id' => 'nullable',
|
||||
'bylaw_document_id' => 'nullable',
|
||||
'judicial_precedent_id' => 'nullable',
|
||||
'legal_opinion_id' => 'nullable',
|
||||
]);
|
||||
|
||||
if (Folder::query()->where('id', $validted['folder_id'])->count() == 0) {
|
||||
return $this->failed([], 'پوشه مورد نظر یافت نشد', 404);
|
||||
// 🚨 SECURITY FIX: Check if the folder exists AND belongs to the logged-in user
|
||||
if (Folder::where('id', $validted['folder_id'])->where('user_id', auth()->id())->count() == 0) {
|
||||
return $this->failed([], 'پوشه مورد نظر یافت نشد یا شما دسترسی ندارید', 404);
|
||||
}
|
||||
|
||||
if (FolderArt::query()->where('folder_id', $validted['folder_id'])->where('art_id', $validted['art_id'])->count() > 0) {
|
||||
return $this->failed([], 'ارت مورد نظر قبلا اضافه شده است', 400);
|
||||
if ($request->filled('art_id')) {
|
||||
if (FolderArt::where('folder_id', $validted['folder_id'])->where('art_id', $validted['art_id'])->exists()) {
|
||||
return $this->failed([], 'ارت مورد نظر قبلا اضافه شده است', 400);
|
||||
}
|
||||
FolderArt::create(['folder_id' => $validted['folder_id'], 'art_id' => $validted['art_id']]);
|
||||
}
|
||||
elseif ($request->filled('bylaw_document_id')) {
|
||||
if (FolderBylaw::where('folder_id', $validted['folder_id'])->where('bylaw_document_id', $validted['bylaw_document_id'])->exists()) {
|
||||
return $this->failed([], 'آییننامه قبلا اضافه شده است', 400);
|
||||
}
|
||||
FolderBylaw::create(['folder_id' => $validted['folder_id'], 'bylaw_document_id' => $validted['bylaw_document_id']]);
|
||||
}
|
||||
elseif ($request->filled('judicial_precedent_id')) {
|
||||
if (FolderJudicialPrecedent::where('folder_id', $validted['folder_id'])->where('judicial_precedent_id', $validted['judicial_precedent_id'])->exists()) {
|
||||
return $this->failed([], 'رأی قبلا اضافه شده است', 400);
|
||||
}
|
||||
FolderJudicialPrecedent::create(['folder_id' => $validted['folder_id'], 'judicial_precedent_id' => $validted['judicial_precedent_id']]);
|
||||
}
|
||||
elseif ($request->filled('legal_opinion_id')) {
|
||||
if (FolderLegalOpinion::where('folder_id', $validted['folder_id'])->where('legal_opinion_id', $validted['legal_opinion_id'])->exists()) {
|
||||
return $this->failed([], 'نظریه قبلا اضافه شده است', 400);
|
||||
}
|
||||
FolderLegalOpinion::create(['folder_id' => $validted['folder_id'], 'legal_opinion_id' => $validted['legal_opinion_id']]);
|
||||
}
|
||||
else {
|
||||
return $this->failed([], 'موردی برای افزودن یافت نشد', 400);
|
||||
}
|
||||
|
||||
FolderArt::create([
|
||||
'folder_id' => $validted['folder_id'],
|
||||
'art_id' => $validted['art_id'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'اضافه شد', 'ارت با موفیت اضافه شد');
|
||||
return $this->success([], 'اضافه شد', 'با موفیت اضافه شد');
|
||||
}
|
||||
|
||||
public function folder($id)
|
||||
{
|
||||
$arts = FolderArt::where('folder_id', $id)
|
||||
->with('arts.chapter', 'arts.part', 'arts.volum', 'arts.law', 'arts.book', 'arts.section', 'arts.gate')
|
||||
->get()
|
||||
->pluck('arts')
|
||||
->flatten();
|
||||
// Added 'arts.law.category' so we can fetch category_name without slowing down the DB
|
||||
$folder = Folder::with([
|
||||
'arts.chapter', 'arts.part', 'arts.volum', 'arts.law.category', 'arts.book', 'arts.section', 'arts.gate',
|
||||
'bylaws.category', 'bylaws.approvalAuthority', 'bylaws.promulgationAuthority',
|
||||
'judicialPrecedents.category',
|
||||
'legalOpinions.category'
|
||||
])->where('user_id', auth()->id())->find($id);
|
||||
|
||||
$arts = $arts->map(function ($art) {
|
||||
if (!$folder) {
|
||||
return $this->failed([], 'پوشه یافت نشد یا شما دسترسی ندارید', 404);
|
||||
}
|
||||
|
||||
$items = collect();
|
||||
|
||||
// 1. Process Arts
|
||||
$arts = $folder->arts->map(function ($art) {
|
||||
$text = $art->text ?? '';
|
||||
$shortText = '';
|
||||
if (strlen($text) > 50) {
|
||||
$shortText = substr($text, 0, 50);
|
||||
$shortText = substr($shortText, 0, strrpos($shortText, ' ')).'...';
|
||||
$shortText = mb_substr($text, 0, 50);
|
||||
$shortText = mb_substr($shortText, 0, mb_strrpos($shortText, ' ')).'...';
|
||||
} else {
|
||||
$shortText = $text;
|
||||
}
|
||||
@@ -89,6 +131,17 @@ class FolderController extends Controller
|
||||
'text' => $isLocked ? null : $shortText,
|
||||
'number' => $art->number,
|
||||
'is_locked' => $isLocked,
|
||||
'type' => 'art',
|
||||
'assigned_at' => $art->pivot->created_at,
|
||||
|
||||
'law_title' => $art->law?->title,
|
||||
|
||||
'law' => $art->law != null ? [
|
||||
'id' => $art->law->id,
|
||||
'title' => $art->law->title,
|
||||
'number' => $art->law->number,
|
||||
] : null,
|
||||
|
||||
'chapter' => $art->chapter != null ? [
|
||||
'id' => $art->chapter->id,
|
||||
'title' => $art->chapter->title,
|
||||
@@ -104,11 +157,6 @@ class FolderController extends Controller
|
||||
'title' => $art->volum->title,
|
||||
'number' => $art->volum->number,
|
||||
] : null,
|
||||
'law' => $art->law != null ? [
|
||||
'id' => $art->law->id,
|
||||
'title' => $art->law->title,
|
||||
'number' => $art->law->number,
|
||||
] : null,
|
||||
'book' => $art->book != null ? [
|
||||
'id' => $art->book->id,
|
||||
'title' => $art->book->title,
|
||||
@@ -126,27 +174,191 @@ class FolderController extends Controller
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
$items = $items->concat($arts);
|
||||
|
||||
return $this->success($arts);
|
||||
// 2. Process Bylaws
|
||||
$bylawIds = $folder->bylaws->pluck('id')->toArray();
|
||||
$allBylawNodes = collect();
|
||||
if (!empty($bylawIds)) {
|
||||
$allBylawNodes = DB::table('bylaw_nodes')
|
||||
->whereIn('document_id', $bylawIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
$bylaws = $folder->bylaws->map(function ($bylaw) use ($allBylawNodes) {
|
||||
$itemNodes = $allBylawNodes->where('document_id', $bylaw->id);
|
||||
$formatted = $this->formatBylaw($bylaw, $itemNodes);
|
||||
$formatted['type'] = 'bylaw';
|
||||
$formatted['assigned_at'] = $bylaw->pivot->created_at;
|
||||
return $formatted;
|
||||
});
|
||||
$items = $items->concat($bylaws);
|
||||
|
||||
// 3. Process Judicial Precedents
|
||||
$judicials = $folder->judicialPrecedents->map(function ($judicial) {
|
||||
$formatted = $this->formatJudicial($judicial);
|
||||
$formatted['type'] = 'judicial_precedent';
|
||||
$formatted['assigned_at'] = $judicial->pivot->created_at;
|
||||
return $formatted;
|
||||
});
|
||||
$items = $items->concat($judicials);
|
||||
|
||||
// 4. Process Legal Opinions
|
||||
$opinions = $folder->legalOpinions->map(function ($opinion) {
|
||||
$formatted = $this->formatOpinion($opinion);
|
||||
$formatted['type'] = 'legal_opinion';
|
||||
$formatted['assigned_at'] = $opinion->pivot->created_at;
|
||||
return $formatted;
|
||||
});
|
||||
$items = $items->concat($opinions);
|
||||
|
||||
// Sort everything together based on when it was added to the folder!
|
||||
$sortedItems = $items->sortByDesc('assigned_at')->values()->toArray();
|
||||
|
||||
return $this->success($sortedItems);
|
||||
}
|
||||
|
||||
public function delete_folder($id)
|
||||
{
|
||||
if (Folder::query()->where('id', $id)->count() == 0) {
|
||||
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد', 404);
|
||||
}
|
||||
Folder::query()->where('id', $id)->delete();
|
||||
$folder = Folder::where('id', $id)->where('user_id', auth()->id())->first();
|
||||
|
||||
if (!$folder) {
|
||||
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد یا شما دسترسی ندارید', 404);
|
||||
}
|
||||
|
||||
$folder->delete();
|
||||
return $this->success([], 'حذف شد', 'پوشه با موفقیت حذف شد');
|
||||
}
|
||||
|
||||
private function checkFolderOwnership($folder_id)
|
||||
{
|
||||
return Folder::where('id', $folder_id)->where('user_id', auth()->id())->exists();
|
||||
}
|
||||
|
||||
public function delete_art($id, $art_id)
|
||||
{
|
||||
if (FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->count() == 0) {
|
||||
return $this->failed([], 'ارت مورد نظر یافت نشد', 404);
|
||||
}
|
||||
FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->delete();
|
||||
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
|
||||
|
||||
return $this->success([], 'حذف شد', 'ارت با موفقیت حذف شد');
|
||||
if (FolderArt::where('folder_id', $id)->where('art_id', $art_id)->count() == 0) {
|
||||
return $this->failed([], 'مورد نظر یافت نشد', 404);
|
||||
}
|
||||
FolderArt::where('folder_id', $id)->where('art_id', $art_id)->delete();
|
||||
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
|
||||
}
|
||||
|
||||
public function delete_bylaw($id, $bylaw_id)
|
||||
{
|
||||
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
|
||||
|
||||
if (FolderBylaw::where('folder_id', $id)->where('bylaw_document_id', $bylaw_id)->count() == 0) {
|
||||
return $this->failed([], 'مورد نظر یافت نشد', 404);
|
||||
}
|
||||
FolderBylaw::where('folder_id', $id)->where('bylaw_document_id', $bylaw_id)->delete();
|
||||
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
|
||||
}
|
||||
|
||||
public function delete_judicial($id, $judicial_id)
|
||||
{
|
||||
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
|
||||
|
||||
if (FolderJudicialPrecedent::where('folder_id', $id)->where('judicial_precedent_id', $judicial_id)->count() == 0) {
|
||||
return $this->failed([], 'مورد نظر یافت نشد', 404);
|
||||
}
|
||||
FolderJudicialPrecedent::where('folder_id', $id)->where('judicial_precedent_id', $judicial_id)->delete();
|
||||
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
|
||||
}
|
||||
|
||||
public function delete_opinion($id, $opinion_id)
|
||||
{
|
||||
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
|
||||
|
||||
if (FolderLegalOpinion::where('folder_id', $id)->where('legal_opinion_id', $opinion_id)->count() == 0) {
|
||||
return $this->failed([], 'مورد نظر یافت نشد', 404);
|
||||
}
|
||||
FolderLegalOpinion::where('folder_id', $id)->where('legal_opinion_id', $opinion_id)->delete();
|
||||
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
* FORMATTING HELPERS
|
||||
* ----------------------------------------------------------------- */
|
||||
|
||||
private function isLockedForCurrentUserGeneric($item): bool
|
||||
{
|
||||
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
|
||||
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
return (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
|
||||
private function formatBylaw($item, $itemNodes = null)
|
||||
{
|
||||
$isLocked = $this->isLockedForCurrentUserGeneric($item);
|
||||
$description = $item->description;
|
||||
|
||||
$cleanDesc = trim(str_replace([' ', '‌', "\r", "\n", "\t", ' '], '', strip_tags($description ?? '')));
|
||||
|
||||
if (strlen($cleanDesc) < 5) {
|
||||
if ($itemNodes === null || (is_object($itemNodes) && $itemNodes->isEmpty())) {
|
||||
$dbNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->count();
|
||||
if ($dbNodes > 0) {
|
||||
$itemNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
|
||||
}
|
||||
}
|
||||
$nodeContents = [];
|
||||
if ($itemNodes) {
|
||||
foreach ($itemNodes as $node) {
|
||||
$content = trim(strip_tags($node->node_content ?? ''));
|
||||
if (!empty($content)) {
|
||||
$nodeContents[] = $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
$description = implode("\n\n", $nodeContents);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'title' => $item->title,
|
||||
'category_name' => optional($item->category)->name,
|
||||
'approval_authority_name' => optional($item->approvalAuthority)->name,
|
||||
'approval_date' => $item->approval_date,
|
||||
'promulgation_number' => $item->promulgation_number,
|
||||
'description' => $isLocked ? null : mb_substr($description, 0, 100) . '...',
|
||||
'is_locked' => $isLocked,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatJudicial($item)
|
||||
{
|
||||
$isLocked = $this->isLockedForCurrentUserGeneric($item);
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'title' => $item->subject,
|
||||
'ruling_number' => $item->ruling_number,
|
||||
'ruling_date' => $item->ruling_date,
|
||||
'text' => $isLocked ? null : mb_substr($item->full_text, 0, 100) . '...',
|
||||
'issuing_authority' => $item->issuing_authority,
|
||||
'category_name' => optional($item->category)->name,
|
||||
'is_locked' => $isLocked,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatOpinion($item)
|
||||
{
|
||||
$isLocked = $this->isLockedForCurrentUserGeneric($item);
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'title' => $item->subject,
|
||||
'opinion_number' => $item->opinion_number,
|
||||
'opinion_date' => $item->opinion_date,
|
||||
'text' => $isLocked ? null : mb_substr($item->full_text, 0, 100) . '...',
|
||||
'issuing_authority' => $item->issuing_authority,
|
||||
'category_name' => optional($item->category)->name,
|
||||
'is_locked' => $isLocked,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ class GlobalSearchApiController extends Controller
|
||||
$arts = Art::with(['law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('text', 'LIKE', "%{$searchTerm}%");
|
||||
->orWhere('text', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('number', 'LIKE', "%{$searchTerm}%");
|
||||
})
|
||||
->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
|
||||
$formatted = $this->formatArt($item, $searchTerm, Art::class);
|
||||
@@ -91,7 +92,7 @@ class GlobalSearchApiController extends Controller
|
||||
// =========================================================
|
||||
// 3. Search Bylaw Documents
|
||||
// =========================================================
|
||||
$bylaws = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
|
||||
$bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
|
||||
->where('is_published', true)
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'LIKE', "%{$searchTerm}%")
|
||||
@@ -101,13 +102,27 @@ class GlobalSearchApiController extends Controller
|
||||
->orWhereIn('id', function($subQuery) use ($searchTerm) {
|
||||
$subQuery->select('document_id')
|
||||
->from('bylaw_nodes')
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%");
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('title', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
})->take($limitPerTable)->get()->map(function($item) {
|
||||
$formatted = $this->formatBylaw($item);
|
||||
$formatted['type'] = 'bylaw';
|
||||
return $formatted;
|
||||
});
|
||||
})->take($limitPerTable)->get();
|
||||
|
||||
// Safely bulk-fetch nodes using DB facade
|
||||
$docIds = $bylawsQuery->pluck('id')->toArray();
|
||||
$allNodes = collect();
|
||||
if (!empty($docIds)) {
|
||||
$allNodes = DB::table('bylaw_nodes')
|
||||
->whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
$bylaws = $bylawsQuery->map(function($item) use ($allNodes) {
|
||||
$itemNodes = $allNodes->where('document_id', $item->id);
|
||||
$formatted = $this->formatBylaw($item, $itemNodes);
|
||||
$formatted['type'] = 'bylaw';
|
||||
return $formatted;
|
||||
});
|
||||
$sortableResults = $sortableResults->concat($bylaws);
|
||||
|
||||
// =========================================================
|
||||
@@ -193,7 +208,7 @@ class GlobalSearchApiController extends Controller
|
||||
'total' => $paginator->total(),
|
||||
];
|
||||
|
||||
return $this->success($responseData, '', '');
|
||||
return $this->success($responseData, 'Success', 'Global Search Results Fetched');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +228,8 @@ class GlobalSearchApiController extends Controller
|
||||
$artQuery = Art::with(['law', 'law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('text', 'LIKE', "%{$searchTerm}%");
|
||||
->orWhere('text', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('number', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
$totalCount += $artQuery->count();
|
||||
$arts = $artQuery->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
|
||||
@@ -250,7 +266,7 @@ class GlobalSearchApiController extends Controller
|
||||
}
|
||||
|
||||
// 3. Bylaw Documents
|
||||
$bylawQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
|
||||
$bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
|
||||
->where('is_published', true)
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'LIKE', "%{$searchTerm}%")
|
||||
@@ -260,12 +276,27 @@ class GlobalSearchApiController extends Controller
|
||||
->orWhereIn('id', function($subQuery) use ($searchTerm) {
|
||||
$subQuery->select('document_id')
|
||||
->from('bylaw_nodes')
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%");
|
||||
->where('node_content', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('title', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
});
|
||||
$totalCount += $bylawQuery->count();
|
||||
$bylaws = $bylawQuery->take($limitPerTable)->get()->map(function($item) {
|
||||
$formatted = $this->formatBylaw($item);
|
||||
|
||||
$totalCount += $bylawsQuery->count();
|
||||
$bylawsRaw = $bylawsQuery->take($limitPerTable)->get();
|
||||
|
||||
// Bulk-fetch nodes safely using DB facade
|
||||
$docIds = $bylawsRaw->pluck('id')->toArray();
|
||||
$allNodes = collect();
|
||||
if (!empty($docIds)) {
|
||||
$allNodes = DB::table('bylaw_nodes')
|
||||
->whereIn('document_id', $docIds)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
$bylaws = $bylawsRaw->map(function($item) use ($allNodes) {
|
||||
$itemNodes = $allNodes->where('document_id', $item->id);
|
||||
$formatted = $this->formatBylaw($item, $itemNodes);
|
||||
$formatted['type'] = 'bylaw';
|
||||
return $formatted;
|
||||
});
|
||||
@@ -306,7 +337,7 @@ class GlobalSearchApiController extends Controller
|
||||
return $this->success([
|
||||
'items' => $results->take(15)->values(),
|
||||
'count' => $totalCount
|
||||
], '', '');
|
||||
], 'Success', 'Global Fast Search Results');
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
@@ -315,24 +346,35 @@ class GlobalSearchApiController extends Controller
|
||||
|
||||
private function isLockedForCurrentUserGeneric($item): bool
|
||||
{
|
||||
return ! auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
|
||||
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
|
||||
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
return (bool) ($item->is_locked ?? false);
|
||||
}
|
||||
|
||||
private function formatBylaw($item)
|
||||
/**
|
||||
* Exact 1:1 match with BylawApiController's encoding approach
|
||||
*/
|
||||
private function formatBylaw($item, $itemNodes = null)
|
||||
{
|
||||
$isLocked = $this->isLockedForCurrentUserGeneric($item);
|
||||
$description = $item->description;
|
||||
|
||||
$cleanDesc = trim(strip_tags($description ?? ''));
|
||||
if ($itemNodes === null) {
|
||||
$itemNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
|
||||
}
|
||||
|
||||
if (empty($cleanDesc)) {
|
||||
$nodeContents = DB::table('bylaw_nodes')
|
||||
->where('document_id', $item->id)
|
||||
->orderBy('sort_order', 'asc')
|
||||
->pluck('node_content')
|
||||
->toArray();
|
||||
$nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
|
||||
->map(function ($text) {
|
||||
$safeText = trim((string)$text);
|
||||
return mb_convert_encoding($safeText, 'UTF-8', 'UTF-8');
|
||||
})
|
||||
->filter(function ($text) {
|
||||
return $text !== '';
|
||||
})
|
||||
->implode("\n\n");
|
||||
|
||||
$description = implode("\n\n", array_filter($nodeContents));
|
||||
if (empty($nodeContentAsDescription)) {
|
||||
$nodeContentAsDescription = mb_convert_encoding(trim((string)$item->description), 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -342,7 +384,7 @@ class GlobalSearchApiController extends Controller
|
||||
'approval_authority_name' => optional($item->approvalAuthority)->name,
|
||||
'approval_date' => $item->approval_date,
|
||||
'promulgation_number' => $item->promulgation_number,
|
||||
'description' => $isLocked ? null : $description,
|
||||
'description' => $isLocked ? null : $nodeContentAsDescription,
|
||||
'is_locked' => $isLocked,
|
||||
];
|
||||
}
|
||||
@@ -379,10 +421,6 @@ class GlobalSearchApiController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact 1:1 match with ArtController::searchModel mapping
|
||||
* All relations (law, chapter, etc) are strictly Strings to prevent App Crashes!
|
||||
*/
|
||||
private function formatArt($item, $searchTerm, $modelClass)
|
||||
{
|
||||
$law = $item->law ?? Law::find($item->law_id);
|
||||
@@ -390,6 +428,7 @@ class GlobalSearchApiController extends Controller
|
||||
|
||||
$text = $item->text ?? '';
|
||||
$searchQuoted = preg_quote($searchTerm, '/');
|
||||
|
||||
if (!empty($searchTerm) && preg_match('/\b(.{0,50})\b('.$searchQuoted.')\b(.{0,50})\b/i', $text, $matches)) {
|
||||
$context = trim($matches[1] ?? '').' '.($matches[2] ?? '').' '.trim($matches[3] ?? '');
|
||||
} else {
|
||||
@@ -404,10 +443,10 @@ class GlobalSearchApiController extends Controller
|
||||
'is_locked' => $isLocked,
|
||||
'route' => array_values($this->route($modelClass, $item)),
|
||||
'category' => optional($law?->category)->name,
|
||||
'law' => $law?->title ?? '', // STRICLY STRING! Prevents Android App Crash!
|
||||
'law' => $law?->title ?? '',
|
||||
'count_art' => $law?->arts?->count() ?? 0,
|
||||
'count_volums' => $law?->volums?->count() ?? 0, // WITH S
|
||||
'count_volum' => $law?->volums?->count() ?? 0, // WITHOUT S (Fallback)
|
||||
'count_volums' => $law?->volums?->count() ?? 0,
|
||||
'count_volum' => $law?->volums?->count() ?? 0,
|
||||
'price' => $law?->price,
|
||||
'image' => $law?->image,
|
||||
];
|
||||
@@ -424,7 +463,7 @@ class GlobalSearchApiController extends Controller
|
||||
'number' => $item->number,
|
||||
'is_locked' => $isLocked,
|
||||
'route' => array_values($this->route($modelClass, $item)),
|
||||
'law' => $law?->title ?? '', // STRICTLY STRING
|
||||
'law' => $law?->title ?? '',
|
||||
];
|
||||
|
||||
if ((new $modelClass)->getTable() == 'laws') {
|
||||
|
||||
@@ -146,38 +146,46 @@ class JudicialPrecedentApiController extends Controller
|
||||
$current = JudicialPrecedent::find($id);
|
||||
|
||||
if (!$current) {
|
||||
return $this->failed([], [
|
||||
'title' => 'خطا',
|
||||
'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'
|
||||
], 404);
|
||||
return $this->failed([], ['title' => 'خطا', 'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$previousItem = JudicialPrecedent::where(function ($query) use ($current) {
|
||||
$query->where('ruling_date', '<', $current->ruling_date)
|
||||
->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('ruling_date', '=', $current->ruling_date)
|
||||
->where('id', '<', $current->id);
|
||||
$subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '<', $current->id);
|
||||
});
|
||||
})
|
||||
->orderBy('ruling_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
})->orderBy('ruling_date', 'desc')->orderBy('id', 'desc')->first();
|
||||
|
||||
$nextItem = JudicialPrecedent::where(function ($query) use ($current) {
|
||||
$query->where('ruling_date', '>', $current->ruling_date)
|
||||
->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('ruling_date', '=', $current->ruling_date)
|
||||
->where('id', '>', $current->id);
|
||||
$subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '>', $current->id);
|
||||
});
|
||||
})
|
||||
->orderBy('ruling_date', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
})->orderBy('ruling_date', 'asc')->orderBy('id', 'asc')->first();
|
||||
|
||||
// Apply format and add Notes & Highlights
|
||||
$formatWithNoteAndHighlight = function($item) {
|
||||
if (!$item) return null;
|
||||
$formatted = $this->formatItem($item);
|
||||
|
||||
$formatted['note'] = \App\Models\JudicialPrecedentNote::select('id', 'note', 'color_code', 'created_at')
|
||||
->where('user_id', auth()->id())
|
||||
->where('judicial_precedent_id', $item->id)
|
||||
->get();
|
||||
|
||||
// NEW: Fetch Highlights
|
||||
$formatted['highlight'] = \App\Models\JudicialPrecedentHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
|
||||
->where('user_id', auth()->id())
|
||||
->where('judicial_precedent_id', $item->id)
|
||||
->get();
|
||||
|
||||
return $formatted;
|
||||
};
|
||||
|
||||
$results = array_values(array_filter([
|
||||
$previousItem ? $this->formatItem($previousItem) : null,
|
||||
$this->formatItem($current),
|
||||
$nextItem ? $this->formatItem($nextItem) : null
|
||||
$previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
|
||||
$formatWithNoteAndHighlight($current),
|
||||
$nextItem ? $formatWithNoteAndHighlight($nextItem) : null
|
||||
]));
|
||||
|
||||
return $this->success($results, 'Success', 'Pager Window Details');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\JudicialPrecedentHighlight;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class JudicialPrecedentHighlightController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'judicial_id' => 'required',
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
JudicialPrecedentHighlight::create([
|
||||
'user_id' => auth()->id(),
|
||||
'judicial_precedent_id' => $validated['judicial_id'],
|
||||
'start_index' => $validated['start_index'],
|
||||
'end_index' => $validated['end_index'],
|
||||
'selected_text' => $validated['selected_text'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$highlight = JudicialPrecedentHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->update($validated);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$highlight = JudicialPrecedentHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/api/JudicialPrecedentNoteController.php
Normal file
56
app/Http/Controllers/api/JudicialPrecedentNoteController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\JudicialPrecedentNote;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class JudicialPrecedentNoteController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'judicial_id' => 'required', // Cleaned up!
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
JudicialPrecedentNote::query()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'judicial_precedent_id' => $validated['judicial_id'], // Maps to DB
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
$note = JudicialPrecedentNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
|
||||
$note->update([
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$note = JudicialPrecedentNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
$note->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
@@ -147,49 +147,54 @@ class LegalOpinionApiController extends Controller
|
||||
$current = LegalOpinion::find($id);
|
||||
|
||||
if (!$current) {
|
||||
return $this->failed([], [
|
||||
'title' => 'خطا',
|
||||
'message' => 'نظریه قانونی مورد نظر یافت نشد.'
|
||||
], 404);
|
||||
return $this->failed([], ['title' => 'خطا', 'message' => 'نظریه قانونی مورد نظر یافت نشد.'], 404);
|
||||
}
|
||||
|
||||
$previousItem = LegalOpinion::where(function ($query) use ($current) {
|
||||
if ($current->opinion_date) {
|
||||
$query->where('opinion_date', '<', $current->opinion_date)
|
||||
->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('opinion_date', '=', $current->opinion_date)
|
||||
->where('id', '<', $current->id);
|
||||
});
|
||||
$query->where('opinion_date', '<', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '<', $current->id);
|
||||
});
|
||||
} else {
|
||||
$query->whereNull('opinion_date')->where('id', '<', $current->id);
|
||||
}
|
||||
})
|
||||
->orderBy('opinion_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->first();
|
||||
})->orderBy('opinion_date', 'desc')->orderBy('id', 'desc')->first();
|
||||
|
||||
$nextItem = LegalOpinion::where(function ($query) use ($current) {
|
||||
if ($current->opinion_date) {
|
||||
$query->where('opinion_date', '>', $current->opinion_date)
|
||||
->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('opinion_date', '=', $current->opinion_date)
|
||||
->where('id', '>', $current->id);
|
||||
});
|
||||
$query->where('opinion_date', '>', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '>', $current->id);
|
||||
});
|
||||
} else {
|
||||
$query->whereNotNull('opinion_date')
|
||||
->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->whereNull('opinion_date')->where('id', '>', $current->id);
|
||||
});
|
||||
$query->whereNotNull('opinion_date')->orWhere(function ($subQuery) use ($current) {
|
||||
$subQuery->whereNull('opinion_date')->where('id', '>', $current->id);
|
||||
});
|
||||
}
|
||||
})
|
||||
->orderBy('opinion_date', 'asc')
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
})->orderBy('opinion_date', 'asc')->orderBy('id', 'asc')->first();
|
||||
|
||||
// Apply format and add Notes & Highlights
|
||||
$formatWithNoteAndHighlight = function($item) {
|
||||
if (!$item) return null;
|
||||
$formatted = $this->formatItem($item);
|
||||
|
||||
$formatted['note'] = \App\Models\LegalOpinionNote::select('id', 'note', 'color_code', 'created_at')
|
||||
->where('user_id', auth()->id())
|
||||
->where('legal_opinion_id', $item->id)
|
||||
->get();
|
||||
|
||||
// NEW: Fetch Highlights
|
||||
$formatted['highlight'] = \App\Models\LegalOpinionHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
|
||||
->where('user_id', auth()->id())
|
||||
->where('legal_opinion_id', $item->id)
|
||||
->get();
|
||||
|
||||
return $formatted;
|
||||
};
|
||||
|
||||
$results = array_values(array_filter([
|
||||
$previousItem ? $this->formatItem($previousItem) : null,
|
||||
$this->formatItem($current),
|
||||
$nextItem ? $this->formatItem($nextItem) : null
|
||||
$previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
|
||||
$formatWithNoteAndHighlight($current),
|
||||
$nextItem ? $formatWithNoteAndHighlight($nextItem) : null
|
||||
]));
|
||||
|
||||
return $this->success($results, 'Success', 'Pager Window Details');
|
||||
|
||||
58
app/Http/Controllers/api/LegalOpinionHighlightController.php
Normal file
58
app/Http/Controllers/api/LegalOpinionHighlightController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalOpinionHighlight;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LegalOpinionHighlightController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'opinion_id' => 'required',
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
LegalOpinionHighlight::create([
|
||||
'user_id' => auth()->id(),
|
||||
'legal_opinion_id' => $validated['opinion_id'],
|
||||
'start_index' => $validated['start_index'],
|
||||
'end_index' => $validated['end_index'],
|
||||
'selected_text' => $validated['selected_text'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'start_index' => 'required|integer',
|
||||
'end_index' => 'required|integer',
|
||||
'selected_text' => 'nullable|string',
|
||||
'color_code' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$highlight = LegalOpinionHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->update($validated);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$highlight = LegalOpinionHighlight::where('user_id', auth()->id())->findOrFail($id);
|
||||
$highlight->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/api/LegalOpinionNoteController.php
Normal file
56
app/Http/Controllers/api/LegalOpinionNoteController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalOpinionNote;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LegalOpinionNoteController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'opinion_id' => 'required', // Cleaned up!
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
LegalOpinionNote::query()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'legal_opinion_id' => $validated['opinion_id'], // Maps to DB
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
|
||||
}
|
||||
|
||||
public function update($id, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note' => 'required',
|
||||
'color_code' => 'nullable',
|
||||
]);
|
||||
|
||||
$note = LegalOpinionNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
|
||||
$note->update([
|
||||
'note' => $validated['note'],
|
||||
'color_code' => $validated['color_code'],
|
||||
]);
|
||||
|
||||
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$note = LegalOpinionNote::where('user_id', auth()->id())->findOrFail($id);
|
||||
$note->delete();
|
||||
|
||||
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
|
||||
}
|
||||
}
|
||||
200
app/Http/Controllers/api/PvPQuizController.php
Normal file
200
app/Http/Controllers/api/PvPQuizController.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Question;
|
||||
use App\Models\PvpMatch;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PvPQuizController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
/**
|
||||
* STEP 1: Join the Matchmaking Queue
|
||||
*/
|
||||
public function findMatch(Request $request)
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
// 1. Check if anyone else is waiting in the cache queue
|
||||
$waitingPlayer = Cache::get('pvp_waiting_player');
|
||||
|
||||
// Ensure we don't match the user with themselves
|
||||
if ($waitingPlayer && $waitingPlayer != $userId) {
|
||||
|
||||
// MATCH FOUND! Remove the waiting player from the queue
|
||||
Cache::forget('pvp_waiting_player');
|
||||
|
||||
// Create the Game Room
|
||||
$match = PvpMatch::create([
|
||||
'status' => 'in_progress',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// Add both players to the match
|
||||
$match->players()->createMany([
|
||||
['user_id' => $waitingPlayer, 'score' => 0],
|
||||
['user_id' => $userId, 'score' => 0],
|
||||
]);
|
||||
|
||||
// Select 5 random multiple-choice questions
|
||||
$questions = Question::where('type', 'multiple_choice')
|
||||
->inRandomOrder()
|
||||
->limit(5)
|
||||
->pluck('id');
|
||||
|
||||
// Attach questions to the match
|
||||
$match->questions()->attach($questions);
|
||||
|
||||
// TODO (Next Step): Fire a WebSocket Event here to tell Player 1 the match started!
|
||||
|
||||
return $this->success([
|
||||
'match_id' => $match->id,
|
||||
'status' => 'matched',
|
||||
'opponent_id' => $waitingPlayer,
|
||||
'questions' => $match->questions()->with('options')->get()->makeHidden(['solution_explanation'])->each(function($q) {
|
||||
$q->options->each->makeHidden(['is_correct']);
|
||||
})
|
||||
], 'موفق', 'حریف پیدا شد! بازی شروع میشود.');
|
||||
}
|
||||
|
||||
// NO MATCH FOUND YET. Put this user in the waiting queue for 30 seconds.
|
||||
Cache::put('pvp_waiting_player', $userId, 30);
|
||||
|
||||
return $this->success([
|
||||
'status' => 'waiting'
|
||||
], 'در حال جستجو', 'در حال یافتن حریف...');
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 2: Submit Answer for a single question (Fastest Finger Logic)
|
||||
*/
|
||||
public function submitAnswer(Request $request, PvpMatch $match)
|
||||
{
|
||||
$request->validate([
|
||||
'question_id' => 'required|exists:questions,id',
|
||||
'selected_option_id' => 'required|exists:question_options,id',
|
||||
'time_taken_seconds' => 'required|numeric|min:0.1|max:15', // Max 15 seconds to answer
|
||||
]);
|
||||
|
||||
$player = $match->players()->where('user_id', auth()->id())->firstOrFail();
|
||||
$question = Question::with('options')->find($request->question_id);
|
||||
|
||||
$isCorrect = false;
|
||||
$pointsEarned = 0;
|
||||
|
||||
// Check if correct
|
||||
$correctOption = $question->options->where('is_correct', true)->first();
|
||||
if ($correctOption && $correctOption->id == $request->selected_option_id) {
|
||||
$isCorrect = true;
|
||||
|
||||
// MATH: Speed Bonus Calculation
|
||||
// If base score is 10, and you answer in 2 seconds (out of 15), you get a bonus!
|
||||
$baseScore = 10;
|
||||
$maxTime = 15;
|
||||
$speedBonus = ($maxTime - $request->time_taken_seconds) * 0.5; // Half a point for every second saved
|
||||
|
||||
$pointsEarned = $baseScore + $speedBonus;
|
||||
}
|
||||
|
||||
// Save the answer
|
||||
$match->answers()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'question_id' => $question->id,
|
||||
'selected_option_id' => $request->selected_option_id,
|
||||
'is_correct' => $isCorrect,
|
||||
'time_taken' => $request->time_taken_seconds,
|
||||
'points_earned' => $pointsEarned
|
||||
]);
|
||||
|
||||
// Update total score for the player
|
||||
$player->increment('score', $pointsEarned);
|
||||
|
||||
// ==========================================
|
||||
// CHECK IF THE MATCH IS OVER
|
||||
// ==========================================
|
||||
$totalQuestions = $match->questions()->count(); // Should be 5
|
||||
$totalExpectedAnswers = $totalQuestions * 2; // 5 questions * 2 players = 10 answers
|
||||
|
||||
// Count how many answers have been submitted in this match globally
|
||||
$currentTotalAnswers = $match->answers()->count();
|
||||
|
||||
$isMatchOver = false;
|
||||
|
||||
if ($currentTotalAnswers >= $totalExpectedAnswers) {
|
||||
// The final answer was just submitted! End the game.
|
||||
$this->finalizeMatch($match);
|
||||
$isMatchOver = true;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'is_correct' => $isCorrect,
|
||||
'points_earned' => round($pointsEarned, 2),
|
||||
'current_total_score' => round($player->score, 2),
|
||||
'is_match_over' => $isMatchOver
|
||||
], 'موفق', 'پاسخ ثبت شد.');
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 3: Finalize the Match and Declare Winner (Internal Helper)
|
||||
*/
|
||||
private function finalizeMatch(PvpMatch $match)
|
||||
{
|
||||
// Get both players, ordered by the highest score first
|
||||
$players = $match->players()->orderByDesc('score')->get();
|
||||
|
||||
$player1 = $players->first(); // The one with the higher score
|
||||
$player2 = $players->last(); // The one with the lower score
|
||||
|
||||
$winnerId = null;
|
||||
|
||||
// Check if it's a draw, or if someone actually won
|
||||
if ($player1->score > $player2->score) {
|
||||
$winnerId = $player1->user_id;
|
||||
}
|
||||
// If scores are exactly equal, winnerId stays null (Draw/Tie)
|
||||
|
||||
// Close the room
|
||||
$match->update([
|
||||
'status' => 'completed',
|
||||
'ended_at' => now(),
|
||||
'winner_id' => $winnerId
|
||||
]);
|
||||
|
||||
// TODO (Next Step): Fire a WebSocket Event here to push the "Game Over" screen!
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 4: Get Final Match Results
|
||||
*/
|
||||
public function matchResult(PvpMatch $match)
|
||||
{
|
||||
// Ensure user was actually in this match
|
||||
if (!$match->players()->where('user_id', auth()->id())->exists()) {
|
||||
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
||||
}
|
||||
|
||||
$match->load([
|
||||
'players.user:id,name', // Load player names
|
||||
'winner:id,name' // Load winner name
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'match_id' => $match->id,
|
||||
'status' => $match->status,
|
||||
'winner_id' => $match->winner_id,
|
||||
'winner_name' => $match->winner ? $match->winner->name : 'مساوی (Draw)',
|
||||
'players' => $match->players->map(function ($p) {
|
||||
return [
|
||||
'user_id' => $p->user_id,
|
||||
'name' => optional($p->user)->name,
|
||||
'final_score' => round($p->score, 2)
|
||||
];
|
||||
})
|
||||
], 'موفق', 'نتایج نهایی مسابقه دریافت شد.');
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/api/QuizController.php
Normal file
207
app/Http/Controllers/api/QuizController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Quiz;
|
||||
use App\Models\QuizCategory;
|
||||
use App\Models\QuizSubject;
|
||||
use App\Models\UserQuizAttempt;
|
||||
use App\Models\UserQuizAnswer;
|
||||
use App\Models\Question;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuizController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
/**
|
||||
* 1. Get all active Categories
|
||||
*/
|
||||
public function categories()
|
||||
{
|
||||
$categories = QuizCategory::where('is_active', true)->get();
|
||||
return $this->success($categories, 'موفق', 'لیست دستهبندیها دریافت شد');
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Get all Subjects
|
||||
*/
|
||||
public function subjects()
|
||||
{
|
||||
$subjects = QuizSubject::all();
|
||||
return $this->success($subjects, 'موفق', 'لیست دروس دریافت شد');
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. List Quizzes (Filterable by Category or Subject)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Quiz::where('is_published', true)->with('category');
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($request->filled('subject_id')) {
|
||||
$query->whereHas('questions', function ($q) use ($request) {
|
||||
$q->where('subject_id', $request->subject_id);
|
||||
});
|
||||
}
|
||||
|
||||
$quizzes = $query->latest()->get();
|
||||
|
||||
return $this->success($quizzes, 'موفق', 'لیست آزمونها دریافت شد');
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. Start a Quiz & Fetch Questions (SECURELY)
|
||||
*/
|
||||
public function start(Quiz $quiz)
|
||||
{
|
||||
if (!$quiz->is_published) {
|
||||
return response()->json(['message' => 'این آزمون در دسترس نیست.'], 403);
|
||||
}
|
||||
|
||||
$attempt = UserQuizAttempt::create([
|
||||
'user_id' => auth()->id(),
|
||||
'quiz_id' => $quiz->id,
|
||||
'started_at' => now(),
|
||||
'status' => 'in_progress'
|
||||
]);
|
||||
|
||||
$quiz->load(['questions' => function($q) {
|
||||
$q->orderBy('order', 'asc');
|
||||
}, 'questions.options']);
|
||||
|
||||
// SECURITY: Hide the correct answers and explanations before sending to mobile app
|
||||
$quiz->questions->each(function ($question) {
|
||||
$question->makeHidden(['solution_explanation']);
|
||||
$question->options->each->makeHidden(['is_correct']);
|
||||
});
|
||||
|
||||
$data = [
|
||||
'attempt_id' => $attempt->id,
|
||||
'quiz' => $quiz
|
||||
];
|
||||
|
||||
return $this->success($data, 'موفق', 'آزمون با موفقیت شروع شد');
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Submit the Quiz & Calculate Score
|
||||
*/
|
||||
public function submit(Request $request, UserQuizAttempt $attempt)
|
||||
{
|
||||
if ($attempt->user_id !== auth()->id() || $attempt->status !== 'in_progress') {
|
||||
return response()->json(['message' => 'دسترسی غیرمجاز یا آزمون قبلاً پایان یافته است.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'answers' => 'required|array',
|
||||
'answers.*.question_id' => 'required|exists:questions,id',
|
||||
'answers.*.selected_option_id' => 'nullable|exists:question_options,id',
|
||||
'answers.*.text_answer' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$quiz = $attempt->quiz;
|
||||
|
||||
$totalScore = 0.00;
|
||||
$correctCount = 0;
|
||||
$wrongCount = 0;
|
||||
$unansweredCount = 0;
|
||||
$needsGrading = false;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($request->answers as $ans) {
|
||||
$question = Question::with('options')->find($ans['question_id']);
|
||||
|
||||
$isCorrect = false;
|
||||
$earnedScore = 0.00;
|
||||
|
||||
// Handle Multiple Choice
|
||||
if ($question->type === 'multiple_choice') {
|
||||
if (empty($ans['selected_option_id'])) {
|
||||
$unansweredCount++;
|
||||
$isCorrect = null;
|
||||
} else {
|
||||
$correctOption = $question->options->where('is_correct', true)->first();
|
||||
|
||||
if ($correctOption && $correctOption->id == $ans['selected_option_id']) {
|
||||
$isCorrect = true;
|
||||
$earnedScore = $question->score;
|
||||
$totalScore += $earnedScore;
|
||||
$correctCount++;
|
||||
} else {
|
||||
$isCorrect = false;
|
||||
if ($quiz->has_negative_score) {
|
||||
$earnedScore = -($question->score / 3);
|
||||
$totalScore += $earnedScore;
|
||||
}
|
||||
$wrongCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle Descriptive
|
||||
else if ($question->type === 'descriptive') {
|
||||
$needsGrading = true;
|
||||
$isCorrect = null;
|
||||
}
|
||||
|
||||
UserQuizAnswer::create([
|
||||
'attempt_id' => $attempt->id,
|
||||
'question_id' => $question->id,
|
||||
'selected_option_id' => $ans['selected_option_id'] ?? null,
|
||||
'text_answer' => $ans['text_answer'] ?? null,
|
||||
'is_correct' => $isCorrect,
|
||||
'earned_score' => $earnedScore,
|
||||
]);
|
||||
}
|
||||
|
||||
$attempt->update([
|
||||
'completed_at' => now(),
|
||||
'total_score' => $totalScore,
|
||||
'correct_answers' => $correctCount,
|
||||
'wrong_answers' => $wrongCount,
|
||||
'unanswered' => $unansweredCount,
|
||||
'status' => $needsGrading ? 'pending_grading' : 'completed',
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
$data = [
|
||||
'attempt_id' => $attempt->id,
|
||||
'status' => $attempt->status
|
||||
];
|
||||
|
||||
return $this->success($data, 'موفق', 'آزمون با موفقیت ثبت شد.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['message' => 'خطا در ثبت اطلاعات آزمون.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. Fetch Results
|
||||
*/
|
||||
public function result(UserQuizAttempt $attempt)
|
||||
{
|
||||
if ($attempt->user_id !== auth()->id()) {
|
||||
return response()->json(['message' => 'غیرمجاز'], 403);
|
||||
}
|
||||
|
||||
$attempt->load([
|
||||
'quiz.category',
|
||||
'answers.question.options',
|
||||
'answers.question.subject',
|
||||
'answers.selectedOption'
|
||||
]);
|
||||
|
||||
return $this->success($attempt, 'موفق', 'نتیجه آزمون با موفقیت دریافت شد');
|
||||
}
|
||||
}
|
||||
125
app/Http/Controllers/api/TicketApiController.php
Normal file
125
app/Http/Controllers/api/TicketApiController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TicketApiController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$tickets = Ticket::where('user_id', auth()->id())
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get()
|
||||
->map(function ($ticket) {
|
||||
return [
|
||||
'id' => $ticket->id,
|
||||
'subject' => $ticket->subject,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at,
|
||||
'updated_at' => $ticket->updated_at,
|
||||
];
|
||||
});
|
||||
|
||||
return $this->success($tickets, 'موفق', 'لیست تیکتها با موفقیت دریافت شد');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$ticket = Ticket::with(['messages' => function ($query) {
|
||||
$query->orderBy('created_at', 'asc');
|
||||
}])->where('user_id', auth()->id())->find($id);
|
||||
|
||||
if (!$ticket) {
|
||||
return $this->failed([], 'یافت نشد', 'تیکت مورد نظر یافت نشد.', 404);
|
||||
}
|
||||
|
||||
$formattedTicket = [
|
||||
'id' => $ticket->id,
|
||||
'subject' => $ticket->subject,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at,
|
||||
'messages' => $ticket->messages->map(function ($msg) {
|
||||
return [
|
||||
'id' => $msg->id,
|
||||
// If user_id matches the logged in user, it's their message. Otherwise, it's admin's reply.
|
||||
'is_me' => $msg->user_id === auth()->id(),
|
||||
'message' => $msg->message,
|
||||
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
|
||||
'created_at' => $msg->created_at,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return $this->success($formattedTicket, 'موفق', 'جزئیات تیکت دریافت شد');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject' => 'required|string|max:255',
|
||||
'priority' => 'nullable|in:low,medium,high',
|
||||
'message' => 'required|string',
|
||||
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120', // Max 5MB
|
||||
]);
|
||||
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => auth()->id(),
|
||||
'subject' => $validated['subject'],
|
||||
'status' => 'open',
|
||||
'priority' => $validated['priority'] ?? 'medium',
|
||||
]);
|
||||
|
||||
$attachmentPath = null;
|
||||
if ($request->hasFile('attachment')) {
|
||||
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
|
||||
}
|
||||
|
||||
TicketMessage::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => auth()->id(),
|
||||
'message' => $validated['message'],
|
||||
'attachment_path' => $attachmentPath,
|
||||
]);
|
||||
|
||||
return $this->success(['ticket_id' => $ticket->id], 'موفق', 'تیکت شما با موفقیت ثبت شد');
|
||||
}
|
||||
|
||||
public function reply(Request $request, $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => 'required|string',
|
||||
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120',
|
||||
]);
|
||||
|
||||
$ticket = Ticket::where('user_id', auth()->id())->find($id);
|
||||
|
||||
if (!$ticket) {
|
||||
return $this->failed([], 'خطا', 'تیکت یافت نشد', 404);
|
||||
}
|
||||
|
||||
$attachmentPath = null;
|
||||
if ($request->hasFile('attachment')) {
|
||||
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
|
||||
}
|
||||
|
||||
TicketMessage::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => auth()->id(),
|
||||
'message' => $validated['message'],
|
||||
'attachment_path' => $attachmentPath,
|
||||
]);
|
||||
|
||||
$ticket->update(['status' => 'user_reply']);
|
||||
|
||||
return $this->success([], 'موفق', 'پاسخ شما با موفقیت ثبت شد');
|
||||
}
|
||||
}
|
||||
8
app/Models/ArtHighlight.php
Normal file
8
app/Models/ArtHighlight.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ArtHighlight extends Model {
|
||||
protected $fillable = ['user_id', 'art_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
|
||||
}
|
||||
8
app/Models/BylawHighlight.php
Normal file
8
app/Models/BylawHighlight.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BylawHighlight extends Model {
|
||||
protected $fillable = ['user_id', 'bylaw_document_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
|
||||
}
|
||||
11
app/Models/BylawNote.php
Normal file
11
app/Models/BylawNote.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BylawNote extends Model
|
||||
{
|
||||
protected $table = 'bylaw_notes';
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -16,6 +16,21 @@ class Folder extends Model
|
||||
|
||||
public function arts()
|
||||
{
|
||||
return $this->belongsToMany(Art::class, 'folder_art');
|
||||
return $this->belongsToMany(Art::class, 'folder_art')->withTimestamps();
|
||||
}
|
||||
|
||||
public function bylaws()
|
||||
{
|
||||
return $this->belongsToMany(BylawDocument::class, 'folder_bylaw', 'folder_id', 'bylaw_document_id')->withTimestamps();
|
||||
}
|
||||
|
||||
public function judicialPrecedents()
|
||||
{
|
||||
return $this->belongsToMany(JudicialPrecedent::class, 'folder_judicial_precedent', 'folder_id', 'judicial_precedent_id')->withTimestamps();
|
||||
}
|
||||
|
||||
public function legalOpinions()
|
||||
{
|
||||
return $this->belongsToMany(LegalOpinion::class, 'folder_legal_opinion', 'folder_id', 'legal_opinion_id')->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
13
app/Models/FolderBylaw.php
Normal file
13
app/Models/FolderBylaw.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FolderBylaw extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'folder_bylaw';
|
||||
protected $fillable = ['folder_id', 'bylaw_document_id'];
|
||||
}
|
||||
13
app/Models/FolderJudicialPrecedent.php
Normal file
13
app/Models/FolderJudicialPrecedent.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FolderJudicialPrecedent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'folder_judicial_precedent';
|
||||
protected $fillable = ['folder_id', 'judicial_precedent_id'];
|
||||
}
|
||||
13
app/Models/FolderLegalOpinion.php
Normal file
13
app/Models/FolderLegalOpinion.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FolderLegalOpinion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'folder_legal_opinion';
|
||||
protected $fillable = ['folder_id', 'legal_opinion_id'];
|
||||
}
|
||||
8
app/Models/JudicialPrecedentHighlight.php
Normal file
8
app/Models/JudicialPrecedentHighlight.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class JudicialPrecedentHighlight extends Model {
|
||||
protected $fillable = ['user_id', 'judicial_precedent_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
|
||||
}
|
||||
11
app/Models/JudicialPrecedentNote.php
Normal file
11
app/Models/JudicialPrecedentNote.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class JudicialPrecedentNote extends Model
|
||||
{
|
||||
protected $table = 'judicial_precedent_notes';
|
||||
protected $guarded = [];
|
||||
}
|
||||
8
app/Models/LegalOpinionHighlight.php
Normal file
8
app/Models/LegalOpinionHighlight.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LegalOpinionHighlight extends Model {
|
||||
protected $fillable = ['user_id', 'legal_opinion_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
|
||||
}
|
||||
11
app/Models/LegalOpinionNote.php
Normal file
11
app/Models/LegalOpinionNote.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LegalOpinionNote extends Model
|
||||
{
|
||||
protected $table = 'legal_opinion_notes';
|
||||
protected $guarded = [];
|
||||
}
|
||||
47
app/Models/PvpMatch.php
Normal file
47
app/Models/PvpMatch.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PvpMatch extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'status',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'winner_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
];
|
||||
|
||||
// The two players in this match
|
||||
public function players()
|
||||
{
|
||||
return $this->hasMany(PvpMatchPlayer::class, 'pvp_match_id');
|
||||
}
|
||||
|
||||
// All answers submitted in this match
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(PvpMatchAnswer::class, 'pvp_match_id');
|
||||
}
|
||||
|
||||
// The 5 random questions assigned to this match via pivot table
|
||||
public function questions()
|
||||
{
|
||||
return $this->belongsToMany(Question::class, 'pvp_match_questions', 'pvp_match_id', 'question_id');
|
||||
}
|
||||
|
||||
// The user who won
|
||||
public function winner()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'winner_id');
|
||||
}
|
||||
}
|
||||
47
app/Models/PvpMatchAnswer.php
Normal file
47
app/Models/PvpMatchAnswer.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PvpMatchAnswer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'pvp_match_id',
|
||||
'user_id',
|
||||
'question_id',
|
||||
'selected_option_id',
|
||||
'is_correct',
|
||||
'time_taken',
|
||||
'points_earned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_correct' => 'boolean',
|
||||
'time_taken' => 'float',
|
||||
'points_earned' => 'float',
|
||||
];
|
||||
|
||||
public function match()
|
||||
{
|
||||
return $this->belongsTo(PvpMatch::class, 'pvp_match_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function question()
|
||||
{
|
||||
return $this->belongsTo(Question::class, 'question_id');
|
||||
}
|
||||
|
||||
public function selectedOption()
|
||||
{
|
||||
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
|
||||
}
|
||||
}
|
||||
31
app/Models/PvpMatchPlayer.php
Normal file
31
app/Models/PvpMatchPlayer.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PvpMatchPlayer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'pvp_match_id',
|
||||
'user_id',
|
||||
'score',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'score' => 'float',
|
||||
];
|
||||
|
||||
public function match()
|
||||
{
|
||||
return $this->belongsTo(PvpMatch::class, 'pvp_match_id');
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
46
app/Models/Question.php
Normal file
46
app/Models/Question.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Question extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'quiz_id',
|
||||
'subject_id',
|
||||
'question_text',
|
||||
'type',
|
||||
'score',
|
||||
'solution_explanation',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'score' => 'float',
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function quiz()
|
||||
{
|
||||
return $this->belongsTo(Quiz::class, 'quiz_id');
|
||||
}
|
||||
|
||||
public function subject()
|
||||
{
|
||||
return $this->belongsTo(QuizSubject::class, 'subject_id');
|
||||
}
|
||||
|
||||
public function options()
|
||||
{
|
||||
return $this->hasMany(QuestionOption::class, 'question_id');
|
||||
}
|
||||
|
||||
public function userAnswers()
|
||||
{
|
||||
return $this->hasMany(UserQuizAnswer::class, 'question_id');
|
||||
}
|
||||
}
|
||||
26
app/Models/QuestionOption.php
Normal file
26
app/Models/QuestionOption.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuestionOption extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'question_id',
|
||||
'option_text',
|
||||
'is_correct',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_correct' => 'boolean',
|
||||
];
|
||||
|
||||
public function question()
|
||||
{
|
||||
return $this->belongsTo(Question::class, 'question_id');
|
||||
}
|
||||
}
|
||||
47
app/Models/Quiz.php
Normal file
47
app/Models/Quiz.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Quiz extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'title',
|
||||
'slug',
|
||||
'year',
|
||||
'type',
|
||||
'duration_minutes',
|
||||
'has_negative_score',
|
||||
'is_free',
|
||||
'is_published',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'year' => 'integer',
|
||||
'duration_minutes' => 'integer',
|
||||
'has_negative_score' => 'boolean',
|
||||
'is_free' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
];
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(QuizCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(Question::class, 'quiz_id')->orderBy('order');
|
||||
}
|
||||
|
||||
public function attempts()
|
||||
{
|
||||
return $this->hasMany(UserQuizAttempt::class, 'quiz_id');
|
||||
}
|
||||
}
|
||||
27
app/Models/QuizCategory.php
Normal file
27
app/Models/QuizCategory.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuizCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function quizzes()
|
||||
{
|
||||
return $this->hasMany(Quiz::class, 'category_id');
|
||||
}
|
||||
}
|
||||
21
app/Models/QuizSubject.php
Normal file
21
app/Models/QuizSubject.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QuizSubject extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
];
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(Question::class, 'subject_id');
|
||||
}
|
||||
}
|
||||
25
app/Models/Ticket.php
Normal file
25
app/Models/Ticket.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject',
|
||||
'status', // 'open', 'answered', 'user_reply', 'closed'
|
||||
'priority', // 'low', 'medium', 'high'
|
||||
];
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(TicketMessage::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/TicketMessage.php
Normal file
26
app/Models/TicketMessage.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\User;
|
||||
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'message',
|
||||
'attachment_path',
|
||||
];
|
||||
|
||||
public function ticket()
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/UserQuizAnswer.php
Normal file
41
app/Models/UserQuizAnswer.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserQuizAnswer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'attempt_id',
|
||||
'question_id',
|
||||
'selected_option_id',
|
||||
'text_answer',
|
||||
'is_correct',
|
||||
'earned_score',
|
||||
'reviewer_feedback',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_correct' => 'boolean',
|
||||
'earned_score' => 'float',
|
||||
];
|
||||
|
||||
public function attempt()
|
||||
{
|
||||
return $this->belongsTo(UserQuizAttempt::class, 'attempt_id');
|
||||
}
|
||||
|
||||
public function question()
|
||||
{
|
||||
return $this->belongsTo(Question::class, 'question_id');
|
||||
}
|
||||
|
||||
public function selectedOption()
|
||||
{
|
||||
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
|
||||
}
|
||||
}
|
||||
47
app/Models/UserQuizAttempt.php
Normal file
47
app/Models/UserQuizAttempt.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserQuizAttempt extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'quiz_id',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'total_score',
|
||||
'correct_answers',
|
||||
'wrong_answers',
|
||||
'unanswered',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'total_score' => 'float',
|
||||
'correct_answers' => 'integer',
|
||||
'wrong_answers' => 'integer',
|
||||
'unanswered' => 'integer',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function quiz()
|
||||
{
|
||||
return $this->belongsTo(Quiz::class, 'quiz_id');
|
||||
}
|
||||
|
||||
public function answers()
|
||||
{
|
||||
return $this->hasMany(UserQuizAnswer::class, 'attempt_id');
|
||||
}
|
||||
}
|
||||
234
resources/views/admin/dashboard/index.blade.php
Normal file
234
resources/views/admin/dashboard/index.blade.php
Normal file
@@ -0,0 +1,234 @@
|
||||
@extends('admin.layouts.app')
|
||||
@section('content')
|
||||
|
||||
<!-- 1. Statistics Cards -->
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card mini-stats-wid text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1">
|
||||
<p class="text-white-50 font-weight-medium mb-2">کل کاربران</p>
|
||||
<h4 class="text-white mb-0">{{ number_format($totalUsers) }}</h4>
|
||||
</div>
|
||||
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
|
||||
<span class="avatar-title bg-white text-primary"><i class="mdi mdi-account-group font-size-24"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mini-stats-wid text-white bg-success">
|
||||
<div class="card-body">
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1">
|
||||
<p class="text-white-50 font-weight-medium mb-2">اشتراکهای فعال (پولی)</p>
|
||||
<h4 class="text-white mb-2">{{ number_format($paidSubscribers) }}</h4>
|
||||
|
||||
<!-- Breakdown of Paid Plans -->
|
||||
<div>
|
||||
@foreach($paidBreakdown as $plan)
|
||||
<span class="badge bg-light text-success font-size-12 me-1 mb-1 shadow-sm">
|
||||
{{ $plan->plan_name }}: {{ $plan->total }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
|
||||
<span class="avatar-title bg-white text-success"><i class="mdi mdi-cash-multiple font-size-24"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mini-stats-wid text-white bg-info">
|
||||
<div class="card-body">
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1">
|
||||
<p class="text-white-50 font-weight-medium mb-2">اشتراکهای فعال (رایگان)</p>
|
||||
<h4 class="text-white mb-0">{{ number_format($freeSubscribers) }}</h4>
|
||||
</div>
|
||||
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
|
||||
<span class="avatar-title bg-white text-info"><i class="mdi mdi-gift font-size-24"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Charts Section -->
|
||||
<div class="row mb-4">
|
||||
<!-- Line Chart: New Users -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-4">ثبتنام کاربران جدید (۷ روز گذشته)</h4>
|
||||
<div id="users-line-chart" style="height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Chart: Subscriptions -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-4">اشتراکهای ثبت شده (۷ روز گذشته)</h4>
|
||||
<div id="subs-column-chart" style="height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Recent Tables Section -->
|
||||
<div class="row">
|
||||
<!-- Recent Tickets -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-4">آخرین تیکتها</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-centered table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کاربر</th>
|
||||
<th>موضوع</th>
|
||||
<th>وضعیت</th>
|
||||
<th>مشاهده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentTickets as $ticket)
|
||||
<tr>
|
||||
<td>{{ optional($ticket->user)->mobile ?? 'ناشناس' }}</td>
|
||||
<td>{{ Str::limit($ticket->subject, 30) }}</td>
|
||||
<td>
|
||||
@if($ticket->status === 'open' || $ticket->status === 'user_reply')
|
||||
<span class="badge bg-danger">نیاز به پاسخ</span>
|
||||
@else
|
||||
<span class="badge bg-success">پاسخ داده شده</span>
|
||||
@endif
|
||||
</td>
|
||||
<td><a href="{{ route('tickets.show', $ticket->id) }}" class="btn btn-sm btn-primary">مشاهده</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="text-center">تیکتی وجود ندارد</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Suggestions -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-4">آخرین پیشنهادات</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-centered table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کاربر</th>
|
||||
<th>پیشنهاد</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentSuggestions as $suggestion)
|
||||
<tr>
|
||||
<td>{{ optional($suggestion->user)->mobile ?? 'ناشناس' }}</td>
|
||||
<td title="{{ $suggestion->text }}">{{ Str::limit($suggestion->text, 40) }}</td>
|
||||
<td dir="ltr">{{ \Morilog\Jalali\Jalalian::fromCarbon($suggestion->created_at)->format('Y/m/d H:i') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="text-center">پیشنهادی وجود ندارد</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
// --- 1. Line Chart: New Users ---
|
||||
var userOptions = {
|
||||
series: [{
|
||||
name: "کاربران جدید",
|
||||
data: {!! json_encode($newUsersData) !!}
|
||||
}],
|
||||
chart: {
|
||||
height: 350,
|
||||
type: 'line',
|
||||
fontFamily: 'Vazirmatn, Tahoma, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
stroke: {
|
||||
width: 3,
|
||||
curve: 'smooth' // Makes the line wavy and modern
|
||||
},
|
||||
colors: ['#007bff'],
|
||||
xaxis: {
|
||||
categories: {!! json_encode($chartDates) !!}
|
||||
},
|
||||
dataLabels: { enabled: false }
|
||||
};
|
||||
var userChart = new ApexCharts(document.querySelector("#users-line-chart"), userOptions);
|
||||
userChart.render();
|
||||
|
||||
|
||||
// --- 2. Column Chart: Subscriptions ---
|
||||
var subsOptions = {
|
||||
series: [{
|
||||
name: 'اشتراک پولی',
|
||||
data: {!! json_encode($paidSubsData) !!}
|
||||
}, {
|
||||
name: 'اشتراک رایگان',
|
||||
data: {!! json_encode($freeSubsData) !!}
|
||||
}],
|
||||
chart: {
|
||||
type: 'bar', // 'bar' is used for column charts in ApexCharts
|
||||
height: 350,
|
||||
fontFamily: 'Vazirmatn, Tahoma, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '50%',
|
||||
borderRadius: 4
|
||||
},
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { show: true, width: 2, colors: ['transparent'] },
|
||||
colors: ['#28a745', '#17a2b8'], // Green for paid, Info/Blue for free
|
||||
xaxis: {
|
||||
categories: {!! json_encode($chartDates) !!}
|
||||
},
|
||||
fill: { opacity: 1 },
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: function (val) {
|
||||
return val + " عدد"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var subsChart = new ApexCharts(document.querySelector("#subs-column-chart"), subsOptions);
|
||||
subsChart.render();
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -43,6 +43,7 @@
|
||||
<div class="rightbar-overlay"></div>
|
||||
|
||||
@include('admin.partial.scripts')
|
||||
@yield('scripts')
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -23,6 +23,51 @@
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a href="{{ route('tickets.index') }}" class="waves-effect">
|
||||
<i class="mdi mdi-headset"></i>
|
||||
<span>تیکتهای پشتیبانی</span>
|
||||
@php
|
||||
$openTicketsCount = \App\Models\Ticket::whereIn('status', ['open', 'user_reply'])->count();
|
||||
@endphp
|
||||
@if($openTicketsCount > 0)
|
||||
<span class="badge badge-pill badge-danger float-right">{{ $openTicketsCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
|
||||
<i class="mdi mdi-account-circle-outline"></i>
|
||||
<span>پیشنهادات</span>
|
||||
</a>
|
||||
<ul class="sub-menu" aria-expanded="false">
|
||||
<li><a href="{{route('suggestions.index')}}">پیشنهادات</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ route('quizzes.index') }}" class="waves-effect">
|
||||
<i class="mdi mdi-format-list-checks"></i>
|
||||
<span>مدیریت آزمونها</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ route('quiz-categories.index') }}" class="waves-effect">
|
||||
<i class="mdi mdi-shape-plus"></i>
|
||||
<span>دستهبندی آزمونها</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ route('quiz-subjects.index') }}" class="waves-effect">
|
||||
<i class="mdi mdi-book-open-page-variant"></i>
|
||||
<span>مدیریت دروس</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
|
||||
<i class="mdi mdi-account-circle-outline"></i>
|
||||
@@ -256,15 +301,6 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
|
||||
<i class="mdi mdi-account-circle-outline"></i>
|
||||
<span>پیشنهادات</span>
|
||||
</a>
|
||||
<ul class="sub-menu" aria-expanded="false">
|
||||
<li><a href="{{route('suggestions.index')}}">پیشنهادات</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
82
resources/views/admin/quiz-categories/index.blade.php
Normal file
82
resources/views/admin/quiz-categories/index.blade.php
Normal file
@@ -0,0 +1,82 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<!-- Form to Add Category -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h6 class="mb-0 text-white">افزودن دستهبندی جدید</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ route('quiz-categories.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="mb-3">
|
||||
<label class="form-label">عنوان دستهبندی <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" placeholder="مثال: کانون وکلا" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">توضیحات (اختیاری)</label>
|
||||
<textarea name="description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isActive" checked>
|
||||
<label class="form-check-label" for="isActive">فعال باشد</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100">ثبت دستهبندی</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List of Categories -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">دستهبندیهای موجود</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>عنوان</th>
|
||||
<th>تعداد آزمونها</th>
|
||||
<th>وضعیت</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($categories as $category)
|
||||
<tr>
|
||||
<td><strong>{{ $category->title }}</strong></td>
|
||||
<td>{{ $category->quizzes_count }} آزمون</td>
|
||||
<td>
|
||||
@if($category->is_active)
|
||||
<span class="badge bg-success">فعال</span>
|
||||
@else
|
||||
<span class="badge bg-danger">غیرفعال</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<form action="{{ route('quiz-categories.destroy', $category->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این دستهبندی اطمینان دارید؟')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-sm btn-outline-danger">حذف</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-3">هیچ دستهبندی یافت نشد.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
77
resources/views/admin/quiz-subjects/index.blade.php
Normal file
77
resources/views/admin/quiz-subjects/index.blade.php
Normal file
@@ -0,0 +1,77 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<!-- Form to Add Subject -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h6 class="mb-0 text-white">افزودن درس جدید</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('quiz-subjects.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="mb-3">
|
||||
<label class="form-label">عنوان درس <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" value="{{ old('title') }}" placeholder="مثال: حقوق مدنی" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100">ثبت درس</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List of Subjects -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-4">دروس ثبت شده</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>عنوان درس</th>
|
||||
<th>تعداد سوالات مرتبط</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($subjects as $subject)
|
||||
<tr>
|
||||
<td><strong>{{ $subject->title }}</strong></td>
|
||||
<td>
|
||||
<span class="badge bg-soft-info text-info">{{ $subject->questions_count }} سوال</span>
|
||||
</td>
|
||||
<td>
|
||||
<form action="{{ route('quiz-subjects.destroy', $subject->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این درس اطمینان دارید؟')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-sm btn-outline-danger">حذف</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-3">هیچ درسی یافت نشد.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
94
resources/views/admin/quizzes/create.blade.php
Normal file
94
resources/views/admin/quizzes/create.blade.php
Normal file
@@ -0,0 +1,94 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0 text-white">تعریف آزمون جدید</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Display Validation Errors if any -->
|
||||
@if ($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('quizzes.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">عنوان آزمون <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" value="{{ old('title') }}" placeholder="مثلاً: آزمون وکالت کانون وکلا سال ۱۴۰۲" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">دستهبندی <span class="text-danger">*</span></label>
|
||||
<select name="category_id" class="form-select" required>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->title }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">سال برگزاری</label>
|
||||
<input type="number" name="year" class="form-control" value="{{ old('year') }}" placeholder="1402">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">نوع سوالات <span class="text-danger">*</span></label>
|
||||
<select name="type" class="form-select" required>
|
||||
<option value="multiple_choice" {{ old('type') == 'multiple_choice' ? 'selected' : '' }}>چهار گزینهای (تستی)</option>
|
||||
<option value="descriptive" {{ old('type') == 'descriptive' ? 'selected' : '' }}>تشریحی</option>
|
||||
<option value="mixed" {{ old('type') == 'mixed' ? 'selected' : '' }}>ترکیبی</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">مدت زمان (دقیقه)</label>
|
||||
<input type="number" name="duration_minutes" class="form-control" value="{{ old('duration_minutes', 60) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">توضیحات آزمون</label>
|
||||
<textarea name="description" class="form-control" rows="3" placeholder="توضیحات و راهنمای شرکت در آزمون...">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="has_negative_score" id="negScore" {{ old('has_negative_score', true) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="negScore">محاسبه نمره منفی (برای تستی)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_free" id="isFree" {{ old('is_free') ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isFree">آزمون رایگان است (بدون نیاز به اشتراک)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_published" id="isPub" {{ old('is_published', true) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isPub">انتشار فوری روی اپلیکیشن</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-4 text-end">
|
||||
<a href="{{ route('quizzes.index') }}" class="btn btn-light me-2">انصراف</a>
|
||||
<button type="submit" class="btn btn-success">ثبت و ورود به مرحله ثبت سوالات</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
71
resources/views/admin/quizzes/index.blade.php
Normal file
71
resources/views/admin/quizzes/index.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">مدیریت آزمونها</h4>
|
||||
<a href="{{ route('quizzes.create') }}" class="btn btn-primary">+ تعریف آزمون جدید</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>عنوان آزمون</th>
|
||||
<th>دستهبندی</th>
|
||||
<th>سال</th>
|
||||
<th>نوع</th>
|
||||
<th>تعداد سوالات</th>
|
||||
<th>مدت (دقیقه)</th>
|
||||
<th>وضعیت</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($quizzes as $quiz)
|
||||
<tr>
|
||||
<td><strong>{{ $quiz->title }}</strong></td>
|
||||
<td><span class="badge bg-soft-info text-info">{{ optional($quiz->category)->title }}</span></td>
|
||||
<td>{{ $quiz->year ?? '-' }}</td>
|
||||
<td>
|
||||
@if($quiz->type === 'multiple_choice')
|
||||
<span class="badge bg-primary">چهار گزینهای</span>
|
||||
@elseif($quiz->type === 'descriptive')
|
||||
<span class="badge bg-warning">تشریحی</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">ترکیبی</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $quiz->questions_count }} سوال</td>
|
||||
<td>{{ $quiz->duration_minutes }} دقیقه</td>
|
||||
<td>
|
||||
@if($quiz->is_published)
|
||||
<span class="badge bg-success">منتشر شده</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">پیشنویس</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('quizzes.questions.index', $quiz->id) }}" class="btn btn-sm btn-info me-1">مدیریت سوالات</a>
|
||||
<form action="{{ route('quizzes.destroy', $quiz->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این آزمون اطمینان دارید؟')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-sm btn-danger">حذف</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-4">هیچ آزمونی ثبت نشده است.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
{{ $quizzes->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
165
resources/views/admin/quizzes/questions.blade.php
Normal file
165
resources/views/admin/quizzes/questions.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4>مدیریت سوالات: {{ $quiz->title }}</h4>
|
||||
<a href="{{ route('quizzes.index') }}" class="btn btn-outline-secondary">بازگشت به لیست آزمونها</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Form to Add Question -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h6 class="mb-0 text-white">افزودن سوال جدید</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- 🚨 Display Validation Errors Here 🚨 -->
|
||||
@if ($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('quizzes.questions.store', $quiz->id) }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">نوع سوال</label>
|
||||
<select name="type" id="questionTypeSelect" class="form-select" onchange="toggleOptionFields(this.value)">
|
||||
<option value="multiple_choice" {{ old('type') == 'multiple_choice' ? 'selected' : '' }}>چهار گزینهای (تستی)</option>
|
||||
<option value="descriptive" {{ old('type') == 'descriptive' ? 'selected' : '' }}>تشریحی</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">درس مربوطه</label>
|
||||
<select name="subject_id" class="form-select">
|
||||
<option value="">انتخاب درس (مثلاً: حقوق مدنی)</option>
|
||||
@foreach($subjects as $subject)
|
||||
<option value="{{ $subject->id }}" {{ old('subject_id') == $subject->id ? 'selected' : '' }}>
|
||||
{{ $subject->title }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">متن سوال / مسئله حقوقی <span class="text-danger">*</span></label>
|
||||
<textarea name="question_text" class="form-control" rows="4" required placeholder="صورت سوال یا مسئله حقوقی را وارد کنید...">{{ old('question_text') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">بارم / ضریب سوال</label>
|
||||
<input type="number" step="0.25" name="score" class="form-control" value="{{ old('score', '1.00') }}" required>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Choice Options Box -->
|
||||
<div id="optionsContainer">
|
||||
<h6 class="border-bottom pb-2 mb-3">گزینهها و انتخاب پاسخ صحیح:</h6>
|
||||
@for($i = 0; $i < 4; $i++)
|
||||
<div class="input-group mb-2">
|
||||
<div class="input-group-text">
|
||||
<!-- Keep the correct radio button checked -->
|
||||
<input class="form-check-input mt-0" type="radio" name="correct_option" value="{{ $i }}" {{ old('correct_option', 0) == $i ? 'checked' : '' }}>
|
||||
</div>
|
||||
<!-- Keep the typed option text -->
|
||||
<input type="text" name="options[]" class="form-control" value="{{ old('options.'.$i) }}" placeholder="متن گزینه {{ $i + 1 }}">
|
||||
</div>
|
||||
@endfor
|
||||
<small class="text-muted d-block mb-3">دکمه رادیویی کنار هر گزینه نشاندهنده گزینه صحیح است.</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">پاسخ تشریحی و مستندات مواد قانونی</label>
|
||||
<textarea name="solution_explanation" class="form-control" rows="3" placeholder="توضیح پاسخ، استدلال و مواد قانونی مربوطه...">{{ old('solution_explanation') }}</textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100">افزودن سوال</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions List -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h6 class="mb-0 text-white">سوالات ثبت شده ({{ $quiz->questions->count() }})</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@forelse($quiz->questions as $index => $question)
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-secondary">سوال شماره {{ $index + 1 }}</span>
|
||||
<div>
|
||||
@if($question->subject)
|
||||
<span class="badge bg-info me-1">{{ $question->subject->title }}</span>
|
||||
@endif
|
||||
<span class="badge bg-{{ $question->type === 'multiple_choice' ? 'primary' : 'warning' }}">
|
||||
{{ $question->type === 'multiple_choice' ? 'تستی' : 'تشریحی' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="fw-bold mb-2">{{ $question->question_text }}</p>
|
||||
|
||||
@if($question->type === 'multiple_choice')
|
||||
<div class="row g-2 mb-2">
|
||||
@foreach($question->options as $optIndex => $option)
|
||||
<div class="col-6">
|
||||
<div class="p-2 border rounded {{ $option->is_correct ? 'bg-success text-white fw-bold' : 'bg-white' }}">
|
||||
{{ $optIndex + 1 }}) {{ $option->option_text }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($question->solution_explanation)
|
||||
<div class="alert alert-secondary py-2 px-3 mt-2 mb-0 font-size-13">
|
||||
<strong>پاسخ تشریحی / مستند قانون:</strong> {{ $question->solution_explanation }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-end mt-2">
|
||||
<form action="{{ route('questions.destroy', $question->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا این سوال حذف شود؟')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-sm btn-outline-danger">حذف سوال</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-center text-muted py-4">هنوز سوالی برای این آزمون ثبت نشده است.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
// Run this immediately on page load to check if we should hide options
|
||||
// in case the page reloads with old('type') set to 'descriptive'
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var typeSelect = document.getElementById('questionTypeSelect');
|
||||
toggleOptionFields(typeSelect.value);
|
||||
});
|
||||
|
||||
function toggleOptionFields(type) {
|
||||
const container = document.getElementById('optionsContainer');
|
||||
if (type === 'descriptive') {
|
||||
container.style.display = 'none';
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
76
resources/views/admin/tickets/index.blade.php
Normal file
76
resources/views/admin/tickets/index.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
@extends('admin.layouts.app')
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- Reuses your existing search component -->
|
||||
@include('admin.partial.list-filter', ['filterRoute' => 'tickets.index'])
|
||||
|
||||
<div id="datatable_wrapper" class="dataTables_wrapper dt-bootstrap4 no-footer">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<table id="datatable" class="table table-bordered dt-responsive nowrap dataTable no-footer dtr-inline" style="border-collapse: collapse; border-spacing: 0px; width: 100%;" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th>شناسه</th>
|
||||
<th>کاربر (موبایل)</th>
|
||||
<th>موضوع</th>
|
||||
<th>وضعیت</th>
|
||||
<th>اولویت</th>
|
||||
<th>تاریخ بروزرسانی</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($tickets as $ticket)
|
||||
<tr>
|
||||
<td>#{{ $ticket->id }}</td>
|
||||
<td>{{ optional($ticket->user)->mobile ?? 'کاربر ناشناس' }}</td>
|
||||
<td>{{ Str::limit($ticket->subject, 50) }}</td>
|
||||
<td>
|
||||
@if($ticket->status === 'open')
|
||||
<span class="badge bg-primary">باز (جدید)</span>
|
||||
@elseif($ticket->status === 'user_reply')
|
||||
<span class="badge bg-warning text-dark">پاسخ کاربر</span>
|
||||
@elseif($ticket->status === 'answered')
|
||||
<span class="badge bg-success">پاسخ داده شده</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">بسته شده</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($ticket->priority === 'high')
|
||||
<span class="badge bg-danger">مهم</span>
|
||||
@elseif($ticket->priority === 'medium')
|
||||
<span class="badge bg-info">متوسط</span>
|
||||
@else
|
||||
<span class="badge bg-light text-dark">کم</span>
|
||||
@endif
|
||||
</td>
|
||||
<td dir="ltr">{{ $ticket->updated_at->format('Y/m/d H:i') }}</td>
|
||||
<td>
|
||||
<a href="{{ route('tickets.show', $ticket->id) }}" class="btn btn-primary waves-effect waves-light btn-sm">مشاهده و پاسخ</a>
|
||||
|
||||
@if($ticket->status !== 'closed')
|
||||
<form action="{{ route('tickets.close', $ticket->id) }}" method="POST" style="display: inline;">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-secondary waves-effect waves-light btn-sm" onclick="return confirm('آیا از بستن این تیکت اطمینان دارید؟')">بستن</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center mt-3">
|
||||
{{ $tickets->withQueryString()->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
69
resources/views/admin/tickets/show.blade.php
Normal file
69
resources/views/admin/tickets/show.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
@extends('admin.layouts.app')
|
||||
@section('content')
|
||||
<div class="row">
|
||||
@include('admin.layouts.errors')
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<!-- Ticket Info Header -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body bg-light">
|
||||
<h4 class="card-title">موضوع: {{ $ticket->subject }}</h4>
|
||||
<p class="mb-0 text-muted">تیکت #{{ $ticket->id }} | ایجاد کننده: {{ optional($ticket->user)->mobile }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat History -->
|
||||
@foreach($ticket->messages as $msg)
|
||||
<div class="card mb-3 shadow-sm {{ $msg->user_id === $ticket->user_id ? 'border-primary' : 'border-success' }}">
|
||||
<div class="card-header {{ $msg->user_id === $ticket->user_id ? 'bg-primary text-white' : 'bg-success text-white' }}">
|
||||
<strong>{{ $msg->user_id === $ticket->user_id ? 'کاربر' : 'پشتیبانی (شما)' }}</strong>
|
||||
<span class="float-end" dir="ltr" style="font-size: 0.85rem;">{{ $msg->created_at->format('Y/m/d H:i') }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="white-space: pre-wrap;">{{ $msg->message }}</p>
|
||||
|
||||
@if($msg->attachment_path)
|
||||
<hr>
|
||||
<a href="{{ asset('storage/' . $msg->attachment_path) }}" target="_blank" class="btn btn-sm btn-info waves-effect waves-light">
|
||||
<i class="fas fa-paperclip"></i> مشاهده فایل ضمیمه
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<!-- Reply Form -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
@if($ticket->status !== 'closed')
|
||||
<h4 class="card-title mb-3">ارسال پاسخ</h4>
|
||||
<!-- Notice the enctype="multipart/form-data" which is required for attachments -->
|
||||
<form action="{{ route('tickets.reply', $ticket->id) }}" method="post" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="mb-4">
|
||||
<label for="message">متن پاسخ</label>
|
||||
<textarea class="form-control" name="message" id="message" rows="5" placeholder="پاسخ خود را بنویسید..." required></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="attachment">فایل ضمیمه (اختیاری)</label>
|
||||
<input class="form-control" name="attachment" id="attachment" type="file" accept=".jpg,.jpeg,.png,.pdf">
|
||||
<small class="text-muted">فرمتهای مجاز: jpg, png, pdf (حداکثر 5 مگابایت)</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-success waves-effect waves-light">ارسال پاسخ</button>
|
||||
<a href="{{ route('tickets.index') }}" class="btn btn-outline-secondary">بازگشت به لیست</a>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="alert alert-secondary text-center mb-0">
|
||||
این تیکت بسته شده است و امکان ارسال پاسخ جدید وجود ندارد.
|
||||
<br>
|
||||
<a href="{{ route('tickets.index') }}" class="btn btn-secondary btn-sm mt-2">بازگشت به لیست</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -11,6 +11,7 @@ use App\Http\Controllers\api\HomeController;
|
||||
use App\Http\Controllers\api\LawController;
|
||||
use App\Http\Controllers\api\NoteController;
|
||||
use App\Http\Controllers\api\NotificationController;
|
||||
use App\Http\Controllers\api\TicketApiController;
|
||||
use App\Http\Controllers\api\PartController;
|
||||
use App\Http\Controllers\api\PayController;
|
||||
use App\Http\Controllers\api\SectionController;
|
||||
@@ -19,6 +20,8 @@ use App\Http\Controllers\api\VersionController;
|
||||
use App\Http\Controllers\api\VolumController;
|
||||
use App\Http\Controllers\SuggestionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\api\QuizController;
|
||||
use App\Http\Controllers\api\PvPQuizController;
|
||||
|
||||
Route::prefix('v1')->group(function () {
|
||||
Route::post('login', [AuthController::class, 'login']);
|
||||
@@ -95,4 +98,66 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
|
||||
// Global Search
|
||||
Route::post('global-search', [\App\Http\Controllers\api\GlobalSearchApiController::class, 'search']);
|
||||
Route::post('global-search/fast-search', [\App\Http\Controllers\api\GlobalSearchApiController::class, 'fast_search']); // NEW
|
||||
|
||||
// Bylaw Notes
|
||||
Route::post('/bylaw-notes', [\App\Http\Controllers\api\BylawNoteController::class, 'create']);
|
||||
Route::put('/bylaw-notes/{id}', [\App\Http\Controllers\api\BylawNoteController::class, 'update']);
|
||||
Route::delete('/bylaw-notes/{id}', [\App\Http\Controllers\api\BylawNoteController::class, 'destroy']);
|
||||
|
||||
// Judicial Precedent Notes
|
||||
Route::post('/judicial-notes', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'create']);
|
||||
Route::put('/judicial-notes/{id}', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'update']);
|
||||
Route::delete('/judicial-notes/{id}', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'destroy']);
|
||||
|
||||
// Legal Opinion Notes
|
||||
Route::post('/opinion-notes', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'create']);
|
||||
Route::put('/opinion-notes/{id}', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'update']);
|
||||
Route::delete('/opinion-notes/{id}', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'destroy']);
|
||||
|
||||
|
||||
Route::delete('/folders/{id}/bylaw/{bylaw_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_bylaw']);
|
||||
Route::delete('/folders/{id}/judicial/{judicial_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_judicial']);
|
||||
Route::delete('/folders/{id}/opinion/{opinion_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_opinion']);
|
||||
|
||||
// --- HIGHLIGHTS ---
|
||||
Route::prefix('highlights')->middleware('auth:sanctum')->group(function () {
|
||||
// Arts
|
||||
Route::post('/art', [\App\Http\Controllers\api\ArtHighlightController::class, 'create']);
|
||||
Route::put('/art/{id}', [\App\Http\Controllers\api\ArtHighlightController::class, 'update']);
|
||||
Route::delete('/art/{id}', [\App\Http\Controllers\api\ArtHighlightController::class, 'destroy']);
|
||||
|
||||
// Bylaws
|
||||
Route::post('/bylaw', [\App\Http\Controllers\api\BylawHighlightController::class, 'create']);
|
||||
Route::put('/bylaw/{id}', [\App\Http\Controllers\api\BylawHighlightController::class, 'update']);
|
||||
Route::delete('/bylaw/{id}', [\App\Http\Controllers\api\BylawHighlightController::class, 'destroy']);
|
||||
|
||||
// Judicial Precedents
|
||||
Route::post('/judicial', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'create']);
|
||||
Route::put('/judicial/{id}', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'update']);
|
||||
Route::delete('/judicial/{id}', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'destroy']);
|
||||
|
||||
// Legal Opinions
|
||||
Route::post('/opinion', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'create']);
|
||||
Route::put('/opinion/{id}', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'update']);
|
||||
Route::delete('/opinion/{id}', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// --- TICKETING SYSTEM ---
|
||||
Route::prefix('tickets')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\api\TicketApiController::class, 'index']);
|
||||
Route::get('/{id}', [\App\Http\Controllers\api\TicketApiController::class, 'show']);
|
||||
Route::post('/', [\App\Http\Controllers\api\TicketApiController::class, 'store']);
|
||||
Route::post('/{id}/reply', [\App\Http\Controllers\api\TicketApiController::class, 'reply']);
|
||||
});
|
||||
|
||||
Route::get('/quiz/categories', [QuizController::class, 'categories']);
|
||||
Route::get('/quiz/subjects', [QuizController::class, 'subjects']);
|
||||
Route::get('/quizzes', [QuizController::class, 'index']);
|
||||
|
||||
// Start, Submit, Result flow
|
||||
Route::get('/quizzes/{quiz}/start', [QuizController::class, 'start']);
|
||||
Route::post('/quiz-attempts/{attempt}/submit', [QuizController::class, 'submit']);
|
||||
Route::get('/quiz-attempts/{attempt}/result', [QuizController::class, 'result']);
|
||||
|
||||
Route::get('/pvp-matches/{match}/result', [PvPQuizController::class, 'matchResult']);
|
||||
});
|
||||
|
||||
@@ -29,10 +29,17 @@ use App\Http\Controllers\Admin\VersionController;
|
||||
use App\Http\Controllers\Admin\VolumController;
|
||||
use App\Http\Controllers\api\PayController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Admin\TicketController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\QuizController;
|
||||
use App\Http\Controllers\Admin\QuizCategoryController;
|
||||
use App\Http\Controllers\Admin\QuizSubjectController;
|
||||
|
||||
Route::get('payment/callback', [PayController::class, 'callback']);
|
||||
|
||||
Route::middleware(['auth', config('jetstream.auth_session')])->group(function () {
|
||||
Route::get('dashboard', [HomeController::class, 'index'])->name('dashboard');
|
||||
|
||||
|
||||
Route::resource('categories', CategoryController::class);
|
||||
Route::resource('versions', VersionController::class);
|
||||
Route::resource('users', UsersController::class);
|
||||
@@ -58,5 +65,21 @@ Route::middleware(['auth', config('jetstream.auth_session')])->group(function ()
|
||||
Route::resource('bylaw-node-type', BylawNodeTypeController::class);
|
||||
Route::resource('bylaw-node', BylawNodeController::class);
|
||||
Route::resource('bylaw-codification-link', BylawCodificationLinkController::class);
|
||||
|
||||
Route::get('suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
|
||||
|
||||
Route::get('tickets', [TicketController::class, 'index'])->name('tickets.index');
|
||||
Route::get('tickets/{ticket}', [TicketController::class, 'show'])->name('tickets.show');
|
||||
Route::post('tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('tickets.reply');
|
||||
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
|
||||
|
||||
Route::resource('quizzes', QuizController::class);
|
||||
Route::get('quizzes/{quiz}/questions', [QuizController::class, 'questions'])->name('quizzes.questions.index');
|
||||
Route::post('quizzes/{quiz}/questions', [QuizController::class, 'storeQuestion'])->name('quizzes.questions.store');
|
||||
Route::delete('questions/{question}', [QuizController::class, 'destroyQuestion'])->name('questions.destroy');
|
||||
Route::resource('quiz-categories', QuizCategoryController::class)->except(['create', 'show', 'edit', 'update']);
|
||||
|
||||
Route::resource('quiz-subjects', QuizSubjectController::class)->except(['create', 'show', 'edit', 'update']);
|
||||
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user