Compare commits

..

39 Commits

Author SHA1 Message Date
m.razaghi
2d6766760f Fixed Quiz 2026-08-07 21:36:48 +03:30
m.razaghi
bb69e7f953 Fixed quiz 2026-08-07 21:30:03 +03:30
m.razaghi
d705c7c56d Fixed Quiz 2026-08-07 20:01:46 +03:30
m.razaghi
768a0aaeb0 Added quiz features 2026-08-06 14:31:46 +03:30
m.razaghi
0fffd2eb3d Added quiz features 2026-08-06 14:25:54 +03:30
m.razaghi
041a3e7bd5 Added quiz features 2026-08-06 14:20:38 +03:30
m.razaghi
af21c1c77e Updated dashboard 2026-08-05 13:56:46 +03:30
m.razaghi
5aa2447e23 Updated dashboard 2026-08-03 18:00:05 +03:30
m.razaghi
74ead9d0ed Updated dashboard 2026-08-03 17:32:04 +03:30
m.razaghi
aad799d29f Updated dashboard 2026-08-03 17:26:15 +03:30
m.razaghi
6e68ed45e4 Added ticketing feature 2026-07-31 22:20:40 +03:30
m.razaghi
067c1d2351 Added ticketing feature 2026-07-31 19:17:40 +03:30
m.razaghi
0ae3343b17 Added ticketing feature 2026-07-31 19:07:54 +03:30
m.razaghi
516bc4b26b Added ticketing feature 2026-07-31 18:54:09 +03:30
m.razaghi
d77605db75 fixed bugs 2026-07-24 18:44:02 +03:30
m.razaghi
a2799a8d1b - Added new apis 2026-07-22 10:29:01 +03:30
m.razaghi
86c68951bb Fixed api issue 2026-07-16 17:56:59 +03:30
m.razaghi
cf0f8f0c3f Fixed api issue 2026-07-16 17:51:10 +03:30
m.razaghi
209b1f2530 Fixed api issue 2026-07-16 14:58:11 +03:30
m.razaghi
a6ae5724f6 added foldering apis 2026-07-14 00:24:30 +03:30
m.razaghi
a173f17cae updated apis 2026-07-14 00:10:53 +03:30
m.razaghi
39e85df27a Fixed api issue 2026-07-13 09:12:26 +03:30
m.razaghi
2d6ba2a70d Fixed api issue 2026-07-13 09:03:47 +03:30
m.razaghi
abdfe190a7 Fixed api issue 2026-07-12 18:34:10 +03:30
m.razaghi
032ae6ebf8 Fixed api issue 2026-07-12 18:28:57 +03:30
m.razaghi
2474f268d1 Fixed api issue 2026-07-12 18:25:31 +03:30
m.razaghi
7f744beec4 Fixed api issue 2026-07-12 18:21:02 +03:30
m.razaghi
7a987a33c9 Fixed api issue 2026-07-12 18:18:02 +03:30
m.razaghi
7ca903ec3c Fixed api issue 2026-07-12 18:16:02 +03:30
m.razaghi
56e57719eb Fixed api issue 2026-07-12 18:09:33 +03:30
m.razaghi
680cf9c4fd Fixed api issue 2026-07-12 17:50:51 +03:30
m.razaghi
375e3b166c Fixed api issue 2026-07-12 17:42:34 +03:30
m.razaghi
2fab528d0a Fixed api issue 2026-07-12 17:38:35 +03:30
m.razaghi
677e101181 Fixed api issue 2026-07-12 17:33:31 +03:30
m.razaghi
e041b85e74 Merge remote-tracking branch 'upstream-local/clean-main' into clean-main 2026-07-12 17:24:03 +03:30
m.razaghi
aad67585ef Fixed api issue 2026-07-12 17:23:53 +03:30
4f94af7fc4 Merge pull request 'Fixed api issue' (#14) from new-api into clean-main
Reviewed-on: #14
2026-07-12 13:23:51 +00:00
ee8d9cdb8f Merge pull request 'Fixed api issue' (#13) from new-api into clean-main
Reviewed-on: #13
2026-07-12 13:19:50 +00:00
6062d0b3a0 Merge pull request 'Fixed api issue' (#12) from new-api into clean-main
Reviewed-on: #12
2026-07-12 13:13:17 +00:00
51 changed files with 2819 additions and 184 deletions

View 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'
));
}
}

View 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', 'دسته‌بندی با موفقیت حذف شد.');
}
}

View 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', 'سوال حذف شد.');
}
}

View 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', 'درس با موفقیت حذف شد.');
}
}

View 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', 'تیکت با موفقیت بسته شد.');
}
}

View File

@@ -96,7 +96,8 @@ class ArtController extends Controller
} }
$art->is_like = $this->isLiked($art->id); $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->category = $law?->category?->name;
$art->route = array_values($this->route(Art::class, $art)); $art->route = array_values($this->route(Art::class, $art));
@@ -109,7 +110,8 @@ class ArtController extends Controller
if ($previousArt) { if ($previousArt) {
$previousLaw = Law::find($previousArt->law_id); $previousLaw = Law::find($previousArt->law_id);
$previousArt->is_like = $this->isLiked($previousArt->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->category = $previousLaw?->category?->name;
$previousArt->route = array_values($this->route(Art::class, $previousArt)); $previousArt->route = array_values($this->route(Art::class, $previousArt));
} }
@@ -123,7 +125,8 @@ class ArtController extends Controller
if ($nextArt) { if ($nextArt) {
$nextLaw = Law::find($nextArt->law_id); $nextLaw = Law::find($nextArt->law_id);
$nextArt->is_like = $this->isLiked($nextArt->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->category = $nextLaw?->category?->name;
$nextArt->route = array_values($this->route(Art::class, $nextArt)); $nextArt->route = array_values($this->route(Art::class, $nextArt));
} }

View 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([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View File

@@ -10,7 +10,6 @@ use App\Models\BylawNode;
use App\Models\BylawCodificationLink; use App\Models\BylawCodificationLink;
use App\Traits\BaseApiResponse; use App\Traits\BaseApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BylawApiController extends Controller class BylawApiController extends Controller
{ {
@@ -80,8 +79,19 @@ class BylawApiController extends Controller
$perPage = min(max((int) $request->input('per_page', 15), 10), 100); $perPage = min(max((int) $request->input('per_page', 15), 10), 100);
$paginator = $query->paginate($perPage); $paginator = $query->paginate($perPage);
$items = collect($paginator->items())->map(function ($item) { $docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
return $this->formatBylaw($item); $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 = [ $responseData = [
@@ -110,12 +120,24 @@ class BylawApiController extends Controller
$formatted = $this->formatBylaw($bylaw); $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']) { if (!$formatted['is_locked']) {
$nodes = BylawNode::with('type') $eloquentNodes = BylawNode::with('type')
->where('document_id', $bylaw->id) ->where('document_id', $bylaw->id)
->orderBy('sort_order', 'asc') ->orderBy('sort_order', 'asc')
->get() ->get();
->map(function ($node) {
$formatted['nodes'] = $eloquentNodes->map(function ($node) {
return [ return [
'id' => $node->id, 'id' => $node->id,
'parent_id' => $node->parent_id, 'parent_id' => $node->parent_id,
@@ -128,9 +150,7 @@ class BylawApiController extends Controller
'valid_to' => $node->valid_to, 'valid_to' => $node->valid_to,
'node_status' => $node->node_status, 'node_status' => $node->node_status,
]; ];
}); })->toArray();
$formatted['nodes'] = $nodes->toArray();
$links = BylawCodificationLink::where('source_entity_type', 'bylaw_doc') $links = BylawCodificationLink::where('source_entity_type', 'bylaw_doc')
->where('source_entity_id', $bylaw->id) ->where('source_entity_id', $bylaw->id)
@@ -175,7 +195,8 @@ class BylawApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) { ->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id') $subQuery->select('document_id')
->from('bylaw_nodes') ->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); $perPage = min(max((int) $request->input('per_page', 15), 10), 100);
$paginator = $query->paginate($perPage); $paginator = $query->paginate($perPage);
$items = collect($paginator->items())->map(function ($item) { $docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
return $this->formatBylaw($item); $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 = [ $responseData = [
@@ -224,18 +256,32 @@ class BylawApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) { ->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id') $subQuery->select('document_id')
->from('bylaw_nodes') ->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%"); ->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
}); });
}); });
} }
$count = $query->count(); $count = $query->count();
$items = $query->orderBy('approval_date', 'desc')->take(5)->get()->map(function ($item) { $items = $query->orderBy('approval_date', 'desc')->take(5)->get();
return $this->formatBylaw($item);
$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([ return $this->success([
'items' => $items, 'items' => $formattedItems,
'count' => $count 'count' => $count
], 'Success', 'Fast Search Results'); ], 'Success', 'Fast Search Results');
} }
@@ -290,38 +336,66 @@ class BylawApiController extends Controller
->orderBy('id', 'asc') ->orderBy('id', 'asc')
->first(); ->first();
$results = array_values(array_filter([ $itemsToFormat = array_filter([$previousItem, $current, $nextItem]);
$previousItem ? $this->formatBylaw($previousItem) : null,
$this->formatBylaw($current), $docIds = collect($itemsToFormat)->pluck('id')->map(fn($id) => (int)$id)->toArray();
$nextItem ? $this->formatBylaw($nextItem) : null $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'); return $this->success($results, 'Success', 'Pager Window Details');
} }
private function isLockedForCurrentUser($item): bool private function isLockedForCurrentUser($item): bool
{ {
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false); 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); $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>) $nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
$cleanDesc = trim(strip_tags($description ?? '')); ->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($nodeContentAsDescription)) {
if (empty($cleanDesc)) { $nodeContentAsDescription = mb_convert_encoding(trim((string)$item->description), 'UTF-8', 'UTF-8');
$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));
} }
return [ return [
@@ -341,8 +415,9 @@ class BylawApiController extends Controller
'execution_date' => $item->execution_date, 'execution_date' => $item->execution_date,
'bylaw_type' => $item->bylaw_type, 'bylaw_type' => $item->bylaw_type,
'global_status' => $item->global_status, 'global_status' => $item->global_status,
'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset('storage/' . $item->pdf_file_path) : null), 'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset(str_replace('storage/storage/', 'storage/', 'storage/' . $item->pdf_file_path)) : null),
'description' => $isLocked ? null : $description, 'description' => $isLocked ? null : $nodeContentAsDescription,
'debug_node_count' => collect($itemNodes)->count(),
'is_locked' => $isLocked, 'is_locked' => $isLocked,
]; ];
} }

View 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([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View 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([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View File

@@ -5,8 +5,12 @@ namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Folder; use App\Models\Folder;
use App\Models\FolderArt; use App\Models\FolderArt;
use App\Models\FolderBylaw;
use App\Models\FolderJudicialPrecedent;
use App\Models\FolderLegalOpinion;
use App\Traits\BaseApiResponse; use App\Traits\BaseApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FolderController extends Controller class FolderController extends Controller
{ {
@@ -14,12 +18,18 @@ class FolderController extends Controller
public function index() 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) { ->map(function ($q) {
return [ return [
'id' => $q->id, 'id' => $q->id,
'name' => $q->name, 'name' => $q->name,
'count_of_arts' => $q->arts->count(), '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, 'created_at' => $q->created_at,
]; ];
}); });
@@ -34,7 +44,7 @@ class FolderController extends Controller
]); ]);
Folder::create([ Folder::create([
'user_id' => auth()->user()->id, 'user_id' => auth()->id(),
'name' => $validted['name'], 'name' => $validted['name'],
]); ]);
@@ -45,39 +55,71 @@ class FolderController extends Controller
{ {
$validted = $request->validate([ $validted = $request->validate([
'folder_id' => 'required', 'folder_id' => 'required',
'art_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) { // 🚨 SECURITY FIX: Check if the folder exists AND belongs to the logged-in user
return $this->failed([], 'پوشه مورد نظر یافت نشد', 404); 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) { if ($request->filled('art_id')) {
if (FolderArt::where('folder_id', $validted['folder_id'])->where('art_id', $validted['art_id'])->exists()) {
return $this->failed([], 'ارت مورد نظر قبلا اضافه شده است', 400); 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([ return $this->success([], 'اضافه شد', 'با موفیت اضافه شد');
'folder_id' => $validted['folder_id'],
'art_id' => $validted['art_id'],
]);
return $this->success([], 'اضافه شد', 'ارت با موفیت اضافه شد');
} }
public function folder($id) public function folder($id)
{ {
$arts = FolderArt::where('folder_id', $id) // Added 'arts.law.category' so we can fetch category_name without slowing down the DB
->with('arts.chapter', 'arts.part', 'arts.volum', 'arts.law', 'arts.book', 'arts.section', 'arts.gate') $folder = Folder::with([
->get() 'arts.chapter', 'arts.part', 'arts.volum', 'arts.law.category', 'arts.book', 'arts.section', 'arts.gate',
->pluck('arts') 'bylaws.category', 'bylaws.approvalAuthority', 'bylaws.promulgationAuthority',
->flatten(); '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 ?? ''; $text = $art->text ?? '';
$shortText = ''; $shortText = '';
if (strlen($text) > 50) { if (strlen($text) > 50) {
$shortText = substr($text, 0, 50); $shortText = mb_substr($text, 0, 50);
$shortText = substr($shortText, 0, strrpos($shortText, ' ')).'...'; $shortText = mb_substr($shortText, 0, mb_strrpos($shortText, ' ')).'...';
} else { } else {
$shortText = $text; $shortText = $text;
} }
@@ -89,6 +131,17 @@ class FolderController extends Controller
'text' => $isLocked ? null : $shortText, 'text' => $isLocked ? null : $shortText,
'number' => $art->number, 'number' => $art->number,
'is_locked' => $isLocked, '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 ? [ 'chapter' => $art->chapter != null ? [
'id' => $art->chapter->id, 'id' => $art->chapter->id,
'title' => $art->chapter->title, 'title' => $art->chapter->title,
@@ -104,11 +157,6 @@ class FolderController extends Controller
'title' => $art->volum->title, 'title' => $art->volum->title,
'number' => $art->volum->number, 'number' => $art->volum->number,
] : null, ] : null,
'law' => $art->law != null ? [
'id' => $art->law->id,
'title' => $art->law->title,
'number' => $art->law->number,
] : null,
'book' => $art->book != null ? [ 'book' => $art->book != null ? [
'id' => $art->book->id, 'id' => $art->book->id,
'title' => $art->book->title, 'title' => $art->book->title,
@@ -126,27 +174,191 @@ class FolderController extends Controller
] : null, ] : 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) public function delete_folder($id)
{ {
if (Folder::query()->where('id', $id)->count() == 0) { $folder = Folder::where('id', $id)->where('user_id', auth()->id())->first();
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد', 404);
}
Folder::query()->where('id', $id)->delete();
if (!$folder) {
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد یا شما دسترسی ندارید', 404);
}
$folder->delete();
return $this->success([], 'حذف شد', 'پوشه با موفقیت حذف شد'); 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) public function delete_art($id, $art_id)
{ {
if (FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->count() == 0) { if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
return $this->failed([], 'ارت مورد نظر یافت نشد', 404);
}
FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->delete();
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(['&nbsp;', '&zwnj;', "\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,
];
} }
} }

View File

@@ -54,7 +54,8 @@ class GlobalSearchApiController extends Controller
$arts = Art::with(['law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate']) $arts = Art::with(['law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
->where(function ($q) use ($searchTerm) { ->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$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) { ->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
$formatted = $this->formatArt($item, $searchTerm, Art::class); $formatted = $this->formatArt($item, $searchTerm, Art::class);
@@ -91,7 +92,7 @@ class GlobalSearchApiController extends Controller
// ========================================================= // =========================================================
// 3. Search Bylaw Documents // 3. Search Bylaw Documents
// ========================================================= // =========================================================
$bylaws = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority']) $bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
->where('is_published', true) ->where('is_published', true)
->where(function ($q) use ($searchTerm) { ->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%") $q->where('title', 'LIKE', "%{$searchTerm}%")
@@ -101,10 +102,24 @@ class GlobalSearchApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) { ->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id') $subQuery->select('document_id')
->from('bylaw_nodes') ->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%"); ->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
}); });
})->take($limitPerTable)->get()->map(function($item) { })->take($limitPerTable)->get();
$formatted = $this->formatBylaw($item);
// 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'; $formatted['type'] = 'bylaw';
return $formatted; return $formatted;
}); });
@@ -193,7 +208,7 @@ class GlobalSearchApiController extends Controller
'total' => $paginator->total(), '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']) $artQuery = Art::with(['law', 'law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
->where(function ($q) use ($searchTerm) { ->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%") $q->where('title', 'LIKE', "%{$searchTerm}%")
->orWhere('text', 'LIKE', "%{$searchTerm}%"); ->orWhere('text', 'LIKE', "%{$searchTerm}%")
->orWhere('number', 'LIKE', "%{$searchTerm}%");
}); });
$totalCount += $artQuery->count(); $totalCount += $artQuery->count();
$arts = $artQuery->take($limitPerTable)->get()->map(function($item) use ($searchTerm) { $arts = $artQuery->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
@@ -250,7 +266,7 @@ class GlobalSearchApiController extends Controller
} }
// 3. Bylaw Documents // 3. Bylaw Documents
$bylawQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority']) $bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
->where('is_published', true) ->where('is_published', true)
->where(function ($q) use ($searchTerm) { ->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%") $q->where('title', 'LIKE', "%{$searchTerm}%")
@@ -260,12 +276,27 @@ class GlobalSearchApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) { ->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id') $subQuery->select('document_id')
->from('bylaw_nodes') ->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) { $totalCount += $bylawsQuery->count();
$formatted = $this->formatBylaw($item); $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'; $formatted['type'] = 'bylaw';
return $formatted; return $formatted;
}); });
@@ -306,7 +337,7 @@ class GlobalSearchApiController extends Controller
return $this->success([ return $this->success([
'items' => $results->take(15)->values(), 'items' => $results->take(15)->values(),
'count' => $totalCount 'count' => $totalCount
], '', ''); ], 'Success', 'Global Fast Search Results');
} }
/* ----------------------------------------------------------------- /* -----------------------------------------------------------------
@@ -315,24 +346,35 @@ class GlobalSearchApiController extends Controller
private function isLockedForCurrentUserGeneric($item): bool 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 !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); $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)) { $nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
$nodeContents = DB::table('bylaw_nodes') ->map(function ($text) {
->where('document_id', $item->id) $safeText = trim((string)$text);
->orderBy('sort_order', 'asc') return mb_convert_encoding($safeText, 'UTF-8', 'UTF-8');
->pluck('node_content') })
->toArray(); ->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 [ return [
@@ -342,7 +384,7 @@ class GlobalSearchApiController extends Controller
'approval_authority_name' => optional($item->approvalAuthority)->name, 'approval_authority_name' => optional($item->approvalAuthority)->name,
'approval_date' => $item->approval_date, 'approval_date' => $item->approval_date,
'promulgation_number' => $item->promulgation_number, 'promulgation_number' => $item->promulgation_number,
'description' => $isLocked ? null : $description, 'description' => $isLocked ? null : $nodeContentAsDescription,
'is_locked' => $isLocked, '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) private function formatArt($item, $searchTerm, $modelClass)
{ {
$law = $item->law ?? Law::find($item->law_id); $law = $item->law ?? Law::find($item->law_id);
@@ -390,6 +428,7 @@ class GlobalSearchApiController extends Controller
$text = $item->text ?? ''; $text = $item->text ?? '';
$searchQuoted = preg_quote($searchTerm, '/'); $searchQuoted = preg_quote($searchTerm, '/');
if (!empty($searchTerm) && preg_match('/\b(.{0,50})\b('.$searchQuoted.')\b(.{0,50})\b/i', $text, $matches)) { 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] ?? ''); $context = trim($matches[1] ?? '').' '.($matches[2] ?? '').' '.trim($matches[3] ?? '');
} else { } else {
@@ -404,10 +443,10 @@ class GlobalSearchApiController extends Controller
'is_locked' => $isLocked, 'is_locked' => $isLocked,
'route' => array_values($this->route($modelClass, $item)), 'route' => array_values($this->route($modelClass, $item)),
'category' => optional($law?->category)->name, 'category' => optional($law?->category)->name,
'law' => $law?->title ?? '', // STRICLY STRING! Prevents Android App Crash! 'law' => $law?->title ?? '',
'count_art' => $law?->arts?->count() ?? 0, 'count_art' => $law?->arts?->count() ?? 0,
'count_volums' => $law?->volums?->count() ?? 0, // WITH S 'count_volums' => $law?->volums?->count() ?? 0,
'count_volum' => $law?->volums?->count() ?? 0, // WITHOUT S (Fallback) 'count_volum' => $law?->volums?->count() ?? 0,
'price' => $law?->price, 'price' => $law?->price,
'image' => $law?->image, 'image' => $law?->image,
]; ];
@@ -424,7 +463,7 @@ class GlobalSearchApiController extends Controller
'number' => $item->number, 'number' => $item->number,
'is_locked' => $isLocked, 'is_locked' => $isLocked,
'route' => array_values($this->route($modelClass, $item)), 'route' => array_values($this->route($modelClass, $item)),
'law' => $law?->title ?? '', // STRICTLY STRING 'law' => $law?->title ?? '',
]; ];
if ((new $modelClass)->getTable() == 'laws') { if ((new $modelClass)->getTable() == 'laws') {

View File

@@ -146,38 +146,46 @@ class JudicialPrecedentApiController extends Controller
$current = JudicialPrecedent::find($id); $current = JudicialPrecedent::find($id);
if (!$current) { if (!$current) {
return $this->failed([], [ return $this->failed([], ['title' => 'خطا', 'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'], 404);
'title' => 'خطا',
'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'
], 404);
} }
$previousItem = JudicialPrecedent::where(function ($query) use ($current) { $previousItem = JudicialPrecedent::where(function ($query) use ($current) {
$query->where('ruling_date', '<', $current->ruling_date) $query->where('ruling_date', '<', $current->ruling_date)
->orWhere(function ($subQuery) use ($current) { ->orWhere(function ($subQuery) use ($current) {
$subQuery->where('ruling_date', '=', $current->ruling_date) $subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '<', $current->id);
->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) { $nextItem = JudicialPrecedent::where(function ($query) use ($current) {
$query->where('ruling_date', '>', $current->ruling_date) $query->where('ruling_date', '>', $current->ruling_date)
->orWhere(function ($subQuery) use ($current) { ->orWhere(function ($subQuery) use ($current) {
$subQuery->where('ruling_date', '=', $current->ruling_date) $subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '>', $current->id);
->where('id', '>', $current->id);
}); });
}) })->orderBy('ruling_date', 'asc')->orderBy('id', 'asc')->first();
->orderBy('ruling_date', 'asc')
->orderBy('id', 'asc') // Apply format and add Notes & Highlights
->first(); $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([ $results = array_values(array_filter([
$previousItem ? $this->formatItem($previousItem) : null, $previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
$this->formatItem($current), $formatWithNoteAndHighlight($current),
$nextItem ? $this->formatItem($nextItem) : null $nextItem ? $formatWithNoteAndHighlight($nextItem) : null
])); ]));
return $this->success($results, 'Success', 'Pager Window Details'); return $this->success($results, 'Success', 'Pager Window Details');

View File

@@ -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([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View 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([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View File

@@ -147,49 +147,54 @@ class LegalOpinionApiController extends Controller
$current = LegalOpinion::find($id); $current = LegalOpinion::find($id);
if (!$current) { if (!$current) {
return $this->failed([], [ return $this->failed([], ['title' => 'خطا', 'message' => 'نظریه قانونی مورد نظر یافت نشد.'], 404);
'title' => 'خطا',
'message' => 'نظریه قانونی مورد نظر یافت نشد.'
], 404);
} }
$previousItem = LegalOpinion::where(function ($query) use ($current) { $previousItem = LegalOpinion::where(function ($query) use ($current) {
if ($current->opinion_date) { if ($current->opinion_date) {
$query->where('opinion_date', '<', $current->opinion_date) $query->where('opinion_date', '<', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
->orWhere(function ($subQuery) use ($current) { $subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '<', $current->id);
$subQuery->where('opinion_date', '=', $current->opinion_date)
->where('id', '<', $current->id);
}); });
} else { } else {
$query->whereNull('opinion_date')->where('id', '<', $current->id); $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) { $nextItem = LegalOpinion::where(function ($query) use ($current) {
if ($current->opinion_date) { if ($current->opinion_date) {
$query->where('opinion_date', '>', $current->opinion_date) $query->where('opinion_date', '>', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
->orWhere(function ($subQuery) use ($current) { $subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '>', $current->id);
$subQuery->where('opinion_date', '=', $current->opinion_date)
->where('id', '>', $current->id);
}); });
} else { } else {
$query->whereNotNull('opinion_date') $query->whereNotNull('opinion_date')->orWhere(function ($subQuery) use ($current) {
->orWhere(function ($subQuery) use ($current) {
$subQuery->whereNull('opinion_date')->where('id', '>', $current->id); $subQuery->whereNull('opinion_date')->where('id', '>', $current->id);
}); });
} }
}) })->orderBy('opinion_date', 'asc')->orderBy('id', 'asc')->first();
->orderBy('opinion_date', 'asc')
->orderBy('id', 'asc') // Apply format and add Notes & Highlights
->first(); $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([ $results = array_values(array_filter([
$previousItem ? $this->formatItem($previousItem) : null, $previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
$this->formatItem($current), $formatWithNoteAndHighlight($current),
$nextItem ? $this->formatItem($nextItem) : null $nextItem ? $formatWithNoteAndHighlight($nextItem) : null
])); ]));
return $this->success($results, 'Success', 'Pager Window Details'); return $this->success($results, 'Success', 'Pager Window Details');

View 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([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View 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([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View 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([], 'موفق', 'پاسخ شما با موفقیت ثبت شد');
}
}

View 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'];
}

View 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
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BylawNote extends Model
{
protected $table = 'bylaw_notes';
protected $guarded = [];
}

View File

@@ -16,6 +16,21 @@ class Folder extends Model
public function arts() 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();
} }
} }

View 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'];
}

View 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'];
}

View 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'];
}

View 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'];
}

View 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 = [];
}

View 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'];
}

View 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 = [];
}

46
app/Models/Question.php Normal file
View 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');
}
}

View 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
View 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');
}
}

View 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');
}
}

View 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
View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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');
}
}

View 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

View File

@@ -43,6 +43,7 @@
<div class="rightbar-overlay"></div> <div class="rightbar-overlay"></div>
@include('admin.partial.scripts') @include('admin.partial.scripts')
@yield('scripts')
</body> </body>
</html> </html>

View File

@@ -23,6 +23,51 @@
</li> </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> <li>
<a href="javascript:%20void(0);" class="has-arrow waves-effect"> <a href="javascript:%20void(0);" class="has-arrow waves-effect">
<i class="mdi mdi-account-circle-outline"></i> <i class="mdi mdi-account-circle-outline"></i>
@@ -256,15 +301,6 @@
</ul> </ul>
</li> </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> </ul>
</div> </div>
</div> </div>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -11,6 +11,7 @@ use App\Http\Controllers\api\HomeController;
use App\Http\Controllers\api\LawController; use App\Http\Controllers\api\LawController;
use App\Http\Controllers\api\NoteController; use App\Http\Controllers\api\NoteController;
use App\Http\Controllers\api\NotificationController; use App\Http\Controllers\api\NotificationController;
use App\Http\Controllers\api\TicketApiController;
use App\Http\Controllers\api\PartController; use App\Http\Controllers\api\PartController;
use App\Http\Controllers\api\PayController; use App\Http\Controllers\api\PayController;
use App\Http\Controllers\api\SectionController; use App\Http\Controllers\api\SectionController;
@@ -95,4 +96,57 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// Global Search // Global Search
Route::post('global-search', [\App\Http\Controllers\api\GlobalSearchApiController::class, '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 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']);
});
}); });

View File

@@ -29,10 +29,17 @@ use App\Http\Controllers\Admin\VersionController;
use App\Http\Controllers\Admin\VolumController; use App\Http\Controllers\Admin\VolumController;
use App\Http\Controllers\api\PayController; use App\Http\Controllers\api\PayController;
use Illuminate\Support\Facades\Route; 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::get('payment/callback', [PayController::class, 'callback']);
Route::middleware(['auth', config('jetstream.auth_session')])->group(function () { Route::middleware(['auth', config('jetstream.auth_session')])->group(function () {
Route::get('dashboard', [HomeController::class, 'index'])->name('dashboard');
Route::resource('categories', CategoryController::class); Route::resource('categories', CategoryController::class);
Route::resource('versions', VersionController::class); Route::resource('versions', VersionController::class);
Route::resource('users', UsersController::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-type', BylawNodeTypeController::class);
Route::resource('bylaw-node', BylawNodeController::class); Route::resource('bylaw-node', BylawNodeController::class);
Route::resource('bylaw-codification-link', BylawCodificationLinkController::class); Route::resource('bylaw-codification-link', BylawCodificationLinkController::class);
Route::get('suggestions', [SuggestionController::class, 'index'])->name('suggestions.index'); 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');
}); });