Compare commits
4 Commits
957947cc0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589e9956f9 | ||
|
|
d6e5b44e47 | ||
|
|
c4ab5c97b2 | ||
|
|
84a9c6bb14 |
@@ -23,7 +23,10 @@ class AIAnalysisController extends Controller
|
||||
}
|
||||
|
||||
// Restriction: Une analyse tous les 7 jours maximum par candidat
|
||||
if ($candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) {
|
||||
// Le super_admin peut outrepasser cette restriction via le paramètre 'force'
|
||||
$shouldCheckRestriction = !($request->force && auth()->user()->isSuperAdmin());
|
||||
|
||||
if ($shouldCheckRestriction && $candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) {
|
||||
$lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']);
|
||||
if ($lastAnalysis->diffInDays(now()) < 7) {
|
||||
return response()->json([
|
||||
|
||||
@@ -41,6 +41,12 @@ class RegisteredUserController extends Controller
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'role' => 'candidate',
|
||||
]);
|
||||
|
||||
// Create the associated candidate record so they appear in the lists
|
||||
$user->candidate()->create([
|
||||
'status' => 'en_attente',
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
@@ -237,6 +237,15 @@ class CandidateController extends Controller
|
||||
$this->storeDocument($candidate, $file, $type);
|
||||
}
|
||||
|
||||
public function toggleSelection(Candidate $candidate)
|
||||
{
|
||||
$candidate->update([
|
||||
'is_selected' => !$candidate->is_selected
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Statut de sélection mis à jour.');
|
||||
}
|
||||
|
||||
private function storeDocument(Candidate $candidate, $file, string $type)
|
||||
{
|
||||
if (!$file) {
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
|
||||
class Candidate extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant;
|
||||
@@ -30,6 +30,7 @@ class Candidate extends Model
|
||||
|
||||
protected $casts = [
|
||||
'ai_analysis' => 'array',
|
||||
'is_selected' => 'boolean',
|
||||
];
|
||||
|
||||
public function jobPosition(): BelongsTo
|
||||
|
||||
@@ -25,7 +25,10 @@ trait BelongsToTenant
|
||||
}
|
||||
|
||||
if ($user->tenant_id) {
|
||||
$builder->where('tenant_id', $user->tenant_id);
|
||||
$builder->where(function ($query) use ($user) {
|
||||
$query->where('tenant_id', $user->tenant_id)
|
||||
->orWhereNull('tenant_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('candidates', function (Blueprint $table) {
|
||||
$table->boolean('is_selected')->default(false)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('candidates', function (Blueprint $table) {
|
||||
$table->dropColumn('is_selected');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -43,10 +43,14 @@ const submit = () => {
|
||||
|
||||
const deleteCandidate = (id) => {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce candidat ?')) {
|
||||
router.delete(route('admin.candidates.destroy', id));
|
||||
router.delete(route('admin.candidates.destroy', id), { preserveScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
router.patch(route('admin.candidates.toggle-selection', id), {}, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const openPreview = (doc) => {
|
||||
selectedDocument.value = doc;
|
||||
};
|
||||
@@ -69,11 +73,24 @@ const getNestedValue = (obj, path) => {
|
||||
};
|
||||
|
||||
const selectedJobPosition = ref('');
|
||||
const showOnlySelected = ref(false);
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
if (selectedJobPosition.value === '') return props.candidates;
|
||||
if (selectedJobPosition.value === 'none') return props.candidates.filter(c => !c.job_position_id);
|
||||
return props.candidates.filter(c => c.job_position_id === selectedJobPosition.value);
|
||||
let result = props.candidates;
|
||||
|
||||
if (showOnlySelected.value) {
|
||||
result = result.filter(c => c.is_selected);
|
||||
}
|
||||
|
||||
if (selectedJobPosition.value !== '') {
|
||||
if (selectedJobPosition.value === 'none') {
|
||||
result = result.filter(c => !c.job_position_id);
|
||||
} else {
|
||||
result = result.filter(c => c.job_position_id === selectedJobPosition.value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const sortedCandidates = computed(() => {
|
||||
@@ -102,18 +119,26 @@ const sortedCandidates = computed(() => {
|
||||
<div class="flex justify-between items-end mb-8">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-2xl font-bold">Liste des Candidats</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
|
||||
<select
|
||||
v-model="selectedJobPosition"
|
||||
class="block w-64 rounded-xl border-slate-300 dark:border-slate-700 dark:bg-slate-900 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Toutes les fiches de poste</option>
|
||||
<option value="none">➜ Non assigné (Candidature Spontanée)</option>
|
||||
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">
|
||||
{{ jp.title }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-3 bg-white dark:bg-slate-800 p-2 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
<label class="flex items-center gap-2 cursor-pointer px-2">
|
||||
<input type="checkbox" v-model="showOnlySelected" class="rounded border-amber-300 text-amber-500 focus:ring-amber-500/20 cursor-pointer">
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">Retenus uniquement</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
|
||||
<select
|
||||
v-model="selectedJobPosition"
|
||||
class="block w-64 rounded-xl border-slate-300 dark:border-slate-700 dark:bg-slate-900 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Toutes les fiches de poste</option>
|
||||
<option value="none">➜ Non assigné (Candidature Spontanée)</option>
|
||||
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">
|
||||
{{ jp.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton @click="isModalOpen = true">
|
||||
@@ -139,6 +164,7 @@ const sortedCandidates = computed(() => {
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<th class="w-12 px-6 py-4"></th>
|
||||
<th @click="sortBy('user.name')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
Nom
|
||||
@@ -201,6 +227,16 @@ const sortedCandidates = computed(() => {
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<button @click="toggleSelection(candidate.id)" class="text-amber-400 hover:text-amber-500 hover:scale-110 transition-transform focus:outline-none" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
|
||||
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-300 hover:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-bold text-slate-900 dark:text-white">{{ candidate.user.name }}</div>
|
||||
<div class="text-[10px] text-slate-500 font-bold uppercase tracking-tight">{{ candidate.phone }}</div>
|
||||
@@ -294,7 +330,7 @@ const sortedCandidates = computed(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="candidates.length === 0">
|
||||
<td colspan="7" class="px-6 py-12 text-center text-slate-500 italic">
|
||||
<td colspan="8" class="px-6 py-12 text-center text-slate-500 italic">
|
||||
Aucun candidat trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -41,6 +41,10 @@ const updateTenant = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelection = () => {
|
||||
router.patch(route('admin.candidates.toggle-selection', props.candidate.id), {}, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
|
||||
const docForm = useForm({
|
||||
@@ -161,7 +165,7 @@ const buildRadarChart = () => {
|
||||
radarChartInstance = null;
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const isDark = false; // Désactivation forcée du mode sombre
|
||||
const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(100,116,139,0.15)';
|
||||
const labelColor = isDark ? '#94a3b8' : '#64748b';
|
||||
|
||||
@@ -248,6 +252,7 @@ watch(
|
||||
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
||||
const isAnalyzing = ref(false);
|
||||
const selectedProvider = ref(props.ai_config?.default || 'ollama');
|
||||
const forceAnalysis = ref(false);
|
||||
|
||||
// Error Modal state
|
||||
const showErrorModal = ref(false);
|
||||
@@ -263,7 +268,8 @@ const runAI = async () => {
|
||||
isAnalyzing.value = true;
|
||||
try {
|
||||
const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), {
|
||||
provider: selectedProvider.value
|
||||
provider: selectedProvider.value,
|
||||
force: forceAnalysis.value
|
||||
});
|
||||
aiAnalysis.value = response.data;
|
||||
} catch (error) {
|
||||
@@ -310,7 +316,27 @@ const runAI = async () => {
|
||||
<!-- Profile Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="h-24 bg-gradient-to-r from-indigo-500 to-purple-600"></div>
|
||||
<div class="px-6 pb-6 text-center -mt-12">
|
||||
<div class="px-6 pb-6 text-center -mt-12 relative">
|
||||
<div class="absolute right-6 top-16 right-0 text-center w-full max-w-[50px] ml-auto mr-auto sm:right-6 sm:top-14 sm:w-auto">
|
||||
<button
|
||||
@click="toggleSelection"
|
||||
class="flex flex-col items-center gap-1 group focus:outline-none"
|
||||
:title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer pour entretien'"
|
||||
>
|
||||
<div
|
||||
class="p-2 rounded-full transition-all"
|
||||
:class="candidate.is_selected ? 'bg-amber-100 text-amber-500 shadow-sm' : 'bg-slate-100 text-slate-400 group-hover:bg-amber-50 group-hover:text-amber-400'"
|
||||
>
|
||||
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[9px] font-black uppercase tracking-widest hidden sm:block" :class="candidate.is_selected ? 'text-amber-500' : 'text-slate-400 group-hover:text-amber-400'">Retenu</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-24 h-24 bg-white dark:bg-slate-900 rounded-2xl shadow-xl border-4 border-white dark:border-slate-800 flex items-center justify-center text-4xl font-black text-indigo-600 mx-auto mb-4">
|
||||
{{ candidate.user.name.charAt(0) }}
|
||||
</div>
|
||||
@@ -630,6 +656,19 @@ const runAI = async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Force option for Super Admin -->
|
||||
<div v-if="$page.props.auth.user.role === 'super_admin'" class="flex items-center gap-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/50 rounded-xl">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="forceAnalysis"
|
||||
v-model="forceAnalysis"
|
||||
class="rounded border-red-300 text-red-600 focus:ring-red-500/20 w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<label for="forceAnalysis" class="text-[10px] font-black uppercase tracking-widest text-red-600 cursor-pointer select-none">
|
||||
Forcer (Bypass 7 jours)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
@click="runAI"
|
||||
:disabled="isAnalyzing"
|
||||
|
||||
@@ -46,11 +46,23 @@ const getStatusColor = (status) => {
|
||||
|
||||
<div v-if="isAdmin" class="p-8 space-y-8">
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
|
||||
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Total Candidats</div>
|
||||
<div class="text-4xl font-black mt-2 text-indigo-600 dark:text-indigo-400">{{ stats.total_candidates }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300 relative overflow-hidden group">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="text-amber-600 dark:text-amber-500 text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
Retenus
|
||||
</div>
|
||||
<div class="text-4xl font-black mt-2 text-amber-600 dark:text-amber-400">{{ stats.selected_candidates }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
|
||||
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Tests terminés</div>
|
||||
<div class="text-4xl font-black mt-2 text-emerald-600 dark:text-emerald-400">{{ stats.finished_tests }}</div>
|
||||
@@ -61,7 +73,7 @@ const getStatusColor = (status) => {
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
|
||||
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Meilleur Score</div>
|
||||
<div class="text-4xl font-black mt-2 text-amber-600 dark:text-amber-400">{{ stats.best_score }} / 20</div>
|
||||
<div class="text-4xl font-black mt-2 text-purple-600 dark:text-purple-400">{{ stats.best_score }} / 20</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Route::get('/dashboard', function () {
|
||||
$allCandidates = Candidate::with(['attempts'])->get();
|
||||
$stats = [
|
||||
'total_candidates' => Candidate::count(),
|
||||
'selected_candidates' => Candidate::where('is_selected', true)->count(),
|
||||
'finished_tests' => Attempt::whereNotNull('finished_at')->count(),
|
||||
'average_score' => round($allCandidates->avg('weighted_score') ?? 0, 1),
|
||||
'best_score' => round($allCandidates->max('weighted_score') ?? 0, 1),
|
||||
@@ -86,6 +87,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');
|
||||
Route::patch('/candidates/{candidate}/position', [\App\Http\Controllers\CandidateController::class, 'updatePosition'])->name('candidates.update-position');
|
||||
Route::patch('/candidates/{candidate}/tenant', [\App\Http\Controllers\CandidateController::class, 'updateTenant'])->name('candidates.update-tenant');
|
||||
Route::patch('/candidates/{candidate}/toggle-selection', [\App\Http\Controllers\CandidateController::class, 'toggleSelection'])->name('candidates.toggle-selection');
|
||||
Route::post('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
|
||||
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
|
||||
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
'./resources/views/**/*.blade.php',
|
||||
'./resources/js/**/*.vue',
|
||||
],
|
||||
darkMode: 'class',
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user