chore: update document approval workflow and bug fixes
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Http\Controllers;
|
||||
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
}
|
||||
476
Modules/UserManagement/app/Http/Controllers/UserController.php
Normal file
476
Modules/UserManagement/app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Modules\ContractorManagement\Models\Contractor;
|
||||
use Modules\UserManagement\Models\CustomerProfile;
|
||||
use Modules\UserManagement\Models\EmployeeProfile;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = User::with(['roles', 'employeeProfile', 'customerProfile', 'contractor']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('type') && $request->type !== 'all') {
|
||||
$query->ofType($request->type);
|
||||
}
|
||||
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$users = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('UserManagement::Users/Index', [
|
||||
'users' => $users,
|
||||
'filters' => $request->only(['search', 'type', 'status']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
|
||||
return Inertia::render('UserManagement::Users/Create', [
|
||||
'contractors' => $isPlatformAdmin
|
||||
? Contractor::where('status', 'active')
|
||||
->orderBy('company_name')
|
||||
->get(['id', 'company_name', 'type'])
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
|
||||
$allowedRoles = $isPlatformAdmin ? ['admin', 'contractor', 'employee', 'customer'] : ['contractor', 'employee', 'customer'];
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'user_type' => ['required', Rule::in($allowedRoles)],
|
||||
'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])],
|
||||
'invite_method' => ['required', Rule::in(['direct', 'email'])],
|
||||
'auto_password' => ['nullable', 'boolean'],
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed', 'required_if:auto_password,false'],
|
||||
// Platform admin only: pick the contractor for this user
|
||||
'contractor_id' => $isPlatformAdmin ? ['nullable', 'exists:contractors,id'] : ['prohibited'],
|
||||
// Employee fields
|
||||
'department' => ['nullable', 'string', 'max:255'],
|
||||
'position' => ['nullable', 'string', 'max:255'],
|
||||
'employee_code' => ['nullable', 'string', 'max:255', 'unique:employee_profiles'],
|
||||
'hire_date' => ['nullable', 'date'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['nullable', 'string'],
|
||||
// Customer fields
|
||||
'company_name' => ['nullable', 'string', 'max:255'],
|
||||
'contact_person' => ['nullable', 'string', 'max:255'],
|
||||
'tin_number' => ['nullable', 'string', 'max:255'],
|
||||
'spatie_role' => ['nullable', 'string', Rule::in(['Super Admin', 'Owner', 'Contractor', 'Project Manager', 'Designer', 'Site Technical', 'Construction Supervisor'])],
|
||||
]);
|
||||
|
||||
// Resolve contractor_id: contractor admins always use their own;
|
||||
// platform admins use the one selected in the form (null = platform user)
|
||||
$contractorId = $isPlatformAdmin
|
||||
? ($validated['contractor_id'] ?? null)
|
||||
: auth()->user()->contractor_id;
|
||||
|
||||
// Resolve temporary password
|
||||
$plainPassword = $request->boolean('auto_password')
|
||||
? Str::random(10)
|
||||
: $validated['password'];
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($plainPassword),
|
||||
'user_type' => $validated['user_type'],
|
||||
'status' => $validated['status'],
|
||||
'contractor_id' => $contractorId,
|
||||
'must_change_password' => true,
|
||||
]);
|
||||
|
||||
// Determine Spatie role to assign
|
||||
$roleToAssign = $validated['spatie_role'] ?? $validated['user_type'];
|
||||
if ($roleToAssign === 'admin') {
|
||||
$roleToAssign = 'Super Admin';
|
||||
}
|
||||
|
||||
// Explicitly assign the spatie role matching the user type or selected role
|
||||
$user->assignRole($roleToAssign);
|
||||
|
||||
// Create profile
|
||||
$this->createProfile($user, $validated);
|
||||
|
||||
// Deliver credentials
|
||||
if ($validated['invite_method'] === 'email') {
|
||||
$this->deliverByEmail($user, $plainPassword);
|
||||
}
|
||||
|
||||
$flashMessage = $validated['invite_method'] === 'direct'
|
||||
? "User created. Temporary password: {$plainPassword}"
|
||||
: "User invited. Credentials sent to {$user->email}.";
|
||||
|
||||
return redirect()->route('users.index')->with('success', $flashMessage);
|
||||
}
|
||||
|
||||
public function show(User $user): Response
|
||||
{
|
||||
$this->authorizeScopedUser($user);
|
||||
$user->load(['roles', 'permissions', 'employeeProfile', 'customerProfile']);
|
||||
|
||||
return Inertia::render('UserManagement::Users/Show', [
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(User $user): Response
|
||||
{
|
||||
$this->authorizeScopedUser($user);
|
||||
$user->load(['employeeProfile', 'customerProfile', 'contractor']);
|
||||
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
|
||||
return Inertia::render('UserManagement::Users/Edit', [
|
||||
'user' => $user,
|
||||
'contractors' => $isPlatformAdmin
|
||||
? Contractor::where('status', 'active')
|
||||
->orderBy('company_name')
|
||||
->get(['id', 'company_name', 'type'])
|
||||
: [],
|
||||
'isPlatformAdmin' => $isPlatformAdmin,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->authorizeScopedUser($user);
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
$allowedRoles = $isPlatformAdmin ? ['admin', 'contractor', 'employee', 'customer'] : ['contractor', 'employee', 'customer'];
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
'user_type' => ['required', Rule::in($allowedRoles)],
|
||||
'status' => ['required', Rule::in(['active', 'inactive', 'suspended'])],
|
||||
'department' => ['nullable', 'string', 'max:255'],
|
||||
'position' => ['nullable', 'string', 'max:255'],
|
||||
'employee_code' => ['nullable', 'string', 'max:255', Rule::unique('employee_profiles')->ignore($user->employeeProfile?->id)],
|
||||
'hire_date' => ['nullable', 'date'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['nullable', 'string'],
|
||||
'company_name' => ['nullable', 'string', 'max:255'],
|
||||
'contact_person'=> ['nullable', 'string', 'max:255'],
|
||||
'tin_number' => ['nullable', 'string', 'max:255'],
|
||||
'spatie_role' => ['nullable', 'string', Rule::in(['Super Admin', 'Owner', 'Contractor', 'Project Manager', 'Designer', 'Site Technical', 'Construction Supervisor'])],
|
||||
]);
|
||||
|
||||
$userData = [
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'user_type' => $validated['user_type'],
|
||||
'status' => $validated['status'],
|
||||
];
|
||||
|
||||
if (! empty($validated['password'])) {
|
||||
$userData['password'] = Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->update($userData);
|
||||
|
||||
$roleToAssign = $validated['spatie_role'] ?? $validated['user_type'];
|
||||
if ($roleToAssign === 'admin') {
|
||||
$roleToAssign = 'Super Admin';
|
||||
}
|
||||
|
||||
$user->syncRoles([$roleToAssign]);
|
||||
|
||||
// Update profile
|
||||
if (in_array($validated['user_type'], ['admin', 'employee', 'contractor'])) {
|
||||
$profile = $user->employeeProfile()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'department' => $validated['department'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'employee_code' => $validated['employee_code'] ?? null,
|
||||
'hire_date' => $validated['hire_date'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'address' => $validated['address'] ?? null,
|
||||
]
|
||||
);
|
||||
$user->update(['userable_type' => EmployeeProfile::class, 'userable_id' => $profile->id]);
|
||||
} elseif ($validated['user_type'] === 'customer') {
|
||||
$profile = $user->customerProfile()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'company_name' => $validated['company_name'] ?? null,
|
||||
'contact_person'=> $validated['contact_person'] ?? null,
|
||||
'tin_number' => $validated['tin_number'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'address' => $validated['address'] ?? null,
|
||||
]
|
||||
);
|
||||
$user->update(['userable_type' => CustomerProfile::class, 'userable_id' => $profile->id]);
|
||||
}
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'User updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->authorizeScopedUser($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'You cannot delete your own account.');
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'User deleted successfully.');
|
||||
}
|
||||
|
||||
public function toggleStatus(User $user): RedirectResponse
|
||||
{
|
||||
$this->authorizeScopedUser($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'You cannot change your own status.');
|
||||
}
|
||||
|
||||
$newStatus = $user->status === 'active' ? 'suspended' : 'active';
|
||||
$user->update(['status' => $newStatus]);
|
||||
|
||||
return back()->with('success', "User status changed to {$newStatus}.");
|
||||
}
|
||||
|
||||
public function linkContractor(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
// Only platform admins may link/unlink contractors
|
||||
abort_unless(is_null(auth()->user()->contractor_id), 403, 'Only platform admins can link contractors.');
|
||||
|
||||
$this->authorizeScopedUser($user);
|
||||
|
||||
$validated = $request->validate([
|
||||
'contractor_id' => ['nullable', 'exists:contractors,id'],
|
||||
]);
|
||||
|
||||
$user->update(['contractor_id' => $validated['contractor_id'] ?? null]);
|
||||
|
||||
$message = $validated['contractor_id']
|
||||
? 'Contractor linked to user successfully.'
|
||||
: 'Contractor link removed from user.';
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
// ─── Bulk Import ──────────────────────────────────────────────────────────
|
||||
|
||||
public function bulkImportForm(): Response
|
||||
{
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
|
||||
return Inertia::render('UserManagement::Users/BulkImport', [
|
||||
'contractors' => $isPlatformAdmin
|
||||
? Contractor::where('status', 'active')
|
||||
->orderBy('company_name')
|
||||
->get(['id', 'company_name', 'type'])
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkImport(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$isPlatformAdmin = is_null(auth()->user()->contractor_id);
|
||||
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'mimes:csv,txt', 'max:2048'],
|
||||
'invite_method' => ['required', Rule::in(['direct', 'email'])],
|
||||
'contractor_id' => $isPlatformAdmin ? ['nullable', 'exists:contractors,id'] : ['prohibited'],
|
||||
]);
|
||||
|
||||
$contractorId = $isPlatformAdmin
|
||||
? ($request->contractor_id ?? null)
|
||||
: auth()->user()->contractor_id;
|
||||
|
||||
$file = $request->file('file');
|
||||
$handle = fopen($file->getRealPath(), 'r');
|
||||
$headers = fgetcsv($handle); // skip header row
|
||||
|
||||
if (! $headers) {
|
||||
return back()->with('error', 'CSV file is empty or unreadable.');
|
||||
}
|
||||
|
||||
$created = [];
|
||||
$errors = [];
|
||||
$rowNum = 1;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$rowNum++;
|
||||
|
||||
if (count($row) < 2) {
|
||||
$errors[] = "Row {$rowNum}: Not enough columns.";
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $email, $role, $department, $position] = array_pad($row, 5, null);
|
||||
$name = trim($name);
|
||||
$email = trim($email);
|
||||
$role = trim($role ?: 'employee');
|
||||
$department= trim($department ?? '');
|
||||
$position = trim($position ?? '');
|
||||
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = "Row {$rowNum}: Invalid email '{$email}'.";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (User::where('email', $email)->exists()) {
|
||||
$errors[] = "Row {$rowNum}: Email '{$email}' already exists.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowedRoles = ['Super Admin', 'Owner', 'Contractor', 'Project Manager', 'Designer', 'Site Technical', 'Construction Supervisor'];
|
||||
$resolvedRole = in_array($role, $allowedRoles) ? $role : 'Designer';
|
||||
|
||||
// Map the Spatie role to the base user_type
|
||||
$userType = $resolvedRole;
|
||||
if (in_array($resolvedRole, ['Contractor', 'Project Manager', 'Designer', 'Site Technical', 'Construction Supervisor'])) {
|
||||
$userType = 'contractor';
|
||||
}
|
||||
|
||||
$plainPassword = Str::random(10);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($plainPassword),
|
||||
'user_type' => $userType,
|
||||
'status' => 'active',
|
||||
'contractor_id' => $contractorId,
|
||||
'must_change_password' => true,
|
||||
]);
|
||||
|
||||
$user->assignRole($resolvedRole);
|
||||
|
||||
if (in_array($userType, ['admin', 'employee', 'contractor'])) {
|
||||
$profile = EmployeeProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'department' => $department ?: null,
|
||||
'position' => $position ?: null,
|
||||
]);
|
||||
$user->update(['userable_type' => EmployeeProfile::class, 'userable_id' => $profile->id]);
|
||||
}
|
||||
|
||||
if ($request->invite_method === 'email') {
|
||||
$this->deliverByEmail($user, $plainPassword);
|
||||
$created[] = ['name' => $name, 'email' => $email, 'role' => $resolvedRole, 'password' => '(sent via email)'];
|
||||
} else {
|
||||
$created[] = ['name' => $name, 'email' => $email, 'role' => $resolvedRole, 'password' => $plainPassword];
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return Inertia::render('UserManagement::Users/BulkImport', [
|
||||
'results' => [
|
||||
'created' => $created,
|
||||
'errors' => $errors,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Forced Password Change ────────────────────────────────────────────────
|
||||
|
||||
public function changePasswordForm(): Response
|
||||
{
|
||||
return Inertia::render('UserManagement::Users/ChangePassword');
|
||||
}
|
||||
|
||||
public function changePassword(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
auth()->user()->update([
|
||||
'password' => Hash::make($request->password),
|
||||
'must_change_password' => false,
|
||||
]);
|
||||
|
||||
return redirect()->route('dashboard')->with('success', 'Password updated successfully. Welcome aboard!');
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redirect contractor admins away from users outside their scope (friendly 404 alternative).
|
||||
*/
|
||||
private function authorizeScopedUser(User $user): void
|
||||
{
|
||||
$authUser = auth()->user();
|
||||
|
||||
if (! is_null($authUser->contractor_id) && $user->contractor_id !== $authUser->contractor_id) {
|
||||
abort(redirect()->route('users.index')->with('error', 'You do not have permission to access this user.'));
|
||||
}
|
||||
}
|
||||
|
||||
private function createProfile(User $user, array $validated): void
|
||||
{
|
||||
if (in_array($validated['user_type'], ['admin', 'employee', 'contractor'])) {
|
||||
$profile = EmployeeProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'department' => $validated['department'] ?? null,
|
||||
'position' => $validated['position'] ?? null,
|
||||
'employee_code' => $validated['employee_code'] ?? null,
|
||||
'hire_date' => $validated['hire_date'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'address' => $validated['address'] ?? null,
|
||||
]);
|
||||
$user->update(['userable_type' => EmployeeProfile::class, 'userable_id' => $profile->id]);
|
||||
} elseif ($validated['user_type'] === 'customer') {
|
||||
$profile = CustomerProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'company_name' => $validated['company_name'] ?? null,
|
||||
'contact_person'=> $validated['contact_person'] ?? null,
|
||||
'tin_number' => $validated['tin_number'] ?? null,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'address' => $validated['address'] ?? null,
|
||||
]);
|
||||
$user->update(['userable_type' => CustomerProfile::class, 'userable_id' => $profile->id]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deliverByEmail(User $user, string $plainPassword): void
|
||||
{
|
||||
try {
|
||||
Mail::raw(
|
||||
"Hello {$user->name},\n\nYou have been added to the system.\n\nEmail: {$user->email}\nTemporary Password: {$plainPassword}\n\nPlease log in and change your password immediately.",
|
||||
fn ($m) => $m->to($user->email)->subject('Your Account Credentials')
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: log credentials if mail is not configured
|
||||
Log::info("User invite credentials (mail not configured) — Email: {$user->email} | Temp Password: {$plainPassword}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('usermanagement::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('usermanagement::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('usermanagement::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('usermanagement::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && !$request->user()->isActive()) {
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('error', 'Your account has been suspended. Contact an administrator.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
117
Modules/UserManagement/app/Livewire/UserManager.php
Normal file
117
Modules/UserManagement/app/Livewire/UserManager.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\User;
|
||||
use Modules\ContractorManagement\Models\Contractor;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UserManager extends Component
|
||||
{
|
||||
public $users;
|
||||
public $name;
|
||||
public $email;
|
||||
public $password;
|
||||
public $role;
|
||||
public $contractor_id;
|
||||
|
||||
public $availableRoles = [];
|
||||
public $availableContractors = [];
|
||||
public $availablePermissions = [];
|
||||
public $selectedPermissions = [];
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|min:8',
|
||||
'role' => 'required|string|exists:roles,name',
|
||||
'contractor_id' => 'required|exists:contractors,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Populate available permissions strictly based on what the current user possesses
|
||||
// (to prevent privilege escalation).
|
||||
$this->availablePermissions = $user->getAllPermissions()->pluck('name', 'name')->toArray();
|
||||
|
||||
// 1. Determine which users this person can see
|
||||
if ($user->hasRole('admin')) {
|
||||
$this->users = User::all();
|
||||
$this->availableRoles = Role::pluck('name', 'name')->toArray();
|
||||
$this->availableContractors = Contractor::all();
|
||||
} elseif ($user->hasRole('Main Contractor Admin') && $user->contractor) {
|
||||
$contractorIds = array_merge(
|
||||
[$user->contractor_id],
|
||||
$user->contractor->children()->pluck('id')->toArray()
|
||||
);
|
||||
$this->users = User::whereIn('contractor_id', $contractorIds)->get();
|
||||
$this->availableRoles = ['Main Contractor Admin', 'Main Contractor User', 'Sub Contractor Admin', 'Sub Contractor User'];
|
||||
$this->availableContractors = Contractor::whereIn('id', $contractorIds)->get();
|
||||
} elseif ($user->hasRole('Sub Contractor Admin') && $user->contractor) {
|
||||
$this->users = User::where('contractor_id', $user->contractor_id)->get();
|
||||
$this->availableRoles = ['Sub Contractor User'];
|
||||
$this->availableContractors = collect([$user->contractor]);
|
||||
} else {
|
||||
$this->users = collect();
|
||||
$this->availableRoles = [];
|
||||
$this->availableContractors = collect();
|
||||
$this->availablePermissions = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function createUser()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$contractor = Contractor::find($this->contractor_id);
|
||||
|
||||
// Enforce user limits in the UI layer as well before saving
|
||||
if ($contractor && $contractor->user_limit > 0) {
|
||||
$currentUsers = $contractor->users()->count();
|
||||
if ($currentUsers >= $contractor->user_limit) {
|
||||
session()->flash('error', 'Cannot create user: The contractor has reached its limit of ' . $contractor->user_limit . ' users.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$newUser = User::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => bcrypt($this->password),
|
||||
'contractor_id' => $this->contractor_id,
|
||||
]);
|
||||
|
||||
$newUser->assignRole($this->role);
|
||||
|
||||
// Assign direct permissions explicitly chosen
|
||||
if (!empty($this->selectedPermissions)) {
|
||||
$newUser->syncPermissions($this->selectedPermissions);
|
||||
}
|
||||
|
||||
$this->reset(['name', 'email', 'password', 'role', 'contractor_id', 'selectedPermissions']);
|
||||
$this->loadData();
|
||||
session()->flash('message', 'User created successfully.');
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('usermanagement::livewire.user-manager');
|
||||
}
|
||||
}
|
||||
23
Modules/UserManagement/app/Models/CustomerProfile.php
Normal file
23
Modules/UserManagement/app/Models/CustomerProfile.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CustomerProfile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'company_name',
|
||||
'contact_person',
|
||||
'tin_number',
|
||||
'phone',
|
||||
'address',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
}
|
||||
31
Modules/UserManagement/app/Models/EmployeeProfile.php
Normal file
31
Modules/UserManagement/app/Models/EmployeeProfile.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmployeeProfile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'department',
|
||||
'position',
|
||||
'employee_code',
|
||||
'hire_date',
|
||||
'phone',
|
||||
'address',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hire_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
}
|
||||
0
Modules/UserManagement/app/Providers/.gitkeep
Normal file
0
Modules/UserManagement/app/Providers/.gitkeep
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event handler mappings for the application.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
protected $listen = [];
|
||||
|
||||
/**
|
||||
* Indicates if events should be discovered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected static $shouldDiscoverEvents = true;
|
||||
|
||||
/**
|
||||
* Configure the proper event listeners for email verification.
|
||||
*/
|
||||
protected function configureEmailVerification(): void {}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $name = 'UserManagement';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map(): void
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
$this->mapWebRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*/
|
||||
protected function mapWebRoutes(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*/
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Providers;
|
||||
|
||||
use Nwidart\Modules\Support\ModuleServiceProvider;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
class UserManagementServiceProvider extends ModuleServiceProvider
|
||||
{
|
||||
/**
|
||||
* The name of the module.
|
||||
*/
|
||||
protected string $name = 'UserManagement';
|
||||
|
||||
/**
|
||||
* The lowercase version of the module name.
|
||||
*/
|
||||
protected string $nameLower = 'usermanagement';
|
||||
|
||||
/**
|
||||
* Command classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
// protected array $commands = [];
|
||||
|
||||
/**
|
||||
* Provider classes to register.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $providers = [
|
||||
EventServiceProvider::class,
|
||||
RouteServiceProvider::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Define module schedules.
|
||||
*
|
||||
* @param $schedule
|
||||
*/
|
||||
// protected function configureSchedules(Schedule $schedule): void
|
||||
// {
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// }
|
||||
}
|
||||
30
Modules/UserManagement/composer.json
Normal file
30
Modules/UserManagement/composer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "nwidart/usermanagement",
|
||||
"description": "",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Widart",
|
||||
"email": "n.widart@gmail.com"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [],
|
||||
"aliases": {
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\UserManagement\\": "app/",
|
||||
"Modules\\UserManagement\\Database\\Factories\\": "database/factories/",
|
||||
"Modules\\UserManagement\\Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\UserManagement\\Tests\\": "tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
Modules/UserManagement/config/.gitkeep
Normal file
0
Modules/UserManagement/config/.gitkeep
Normal file
5
Modules/UserManagement/config/config.php
Normal file
5
Modules/UserManagement/config/config.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'UserManagement',
|
||||
];
|
||||
0
Modules/UserManagement/database/factories/.gitkeep
Normal file
0
Modules/UserManagement/database/factories/.gitkeep
Normal file
0
Modules/UserManagement/database/migrations/.gitkeep
Normal file
0
Modules/UserManagement/database/migrations/.gitkeep
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->enum('user_type', ['admin', 'employee', 'customer'])->default('employee')->after('email');
|
||||
$table->enum('status', ['active', 'inactive', 'suspended'])->default('active')->after('user_type');
|
||||
$table->string('userable_type')->nullable()->after('status');
|
||||
$table->unsignedBigInteger('userable_id')->nullable()->after('userable_type');
|
||||
$table->index(['userable_type', 'userable_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropIndex(['userable_type', 'userable_id']);
|
||||
$table->dropColumn(['user_type', 'status', 'userable_type', 'userable_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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('employee_profiles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('department')->nullable();
|
||||
$table->string('position')->nullable();
|
||||
$table->string('employee_code')->unique()->nullable();
|
||||
$table->date('hire_date')->nullable();
|
||||
$table->string('phone')->nullable();
|
||||
$table->text('address')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('employee_profiles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('customer_profiles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('company_name')->nullable();
|
||||
$table->string('contact_person')->nullable();
|
||||
$table->string('tin_number')->nullable();
|
||||
$table->string('phone')->nullable();
|
||||
$table->text('address')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('customer_profiles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN user_type ENUM('admin', 'employee', 'customer', 'contractor') DEFAULT 'employee'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN user_type ENUM('admin', 'employee', 'customer') DEFAULT 'employee'");
|
||||
}
|
||||
};
|
||||
0
Modules/UserManagement/database/seeders/.gitkeep
Normal file
0
Modules/UserManagement/database/seeders/.gitkeep
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\UserManagement\Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\UserManagement\Models\EmployeeProfile;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UserManagementDatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Create permissions
|
||||
$permissions = [
|
||||
'users.view',
|
||||
'users.create',
|
||||
'users.edit',
|
||||
'users.delete',
|
||||
'users.manage-status',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
Permission::firstOrCreate(['name' => $permission]);
|
||||
}
|
||||
|
||||
// Create roles with permissions
|
||||
$adminRole = Role::firstOrCreate(['name' => 'admin']);
|
||||
$adminRole->syncPermissions($permissions);
|
||||
|
||||
$employeeRole = Role::firstOrCreate(['name' => 'employee']);
|
||||
$employeeRole->syncPermissions(['users.view']);
|
||||
|
||||
$customerRole = Role::firstOrCreate(['name' => 'customer']);
|
||||
|
||||
// Create default admin user
|
||||
$admin = User::firstOrCreate(
|
||||
['email' => 'admin@gsb-cons.com'],
|
||||
[
|
||||
'name' => 'System Admin',
|
||||
'password' => bcrypt('password'),
|
||||
'user_type' => 'admin',
|
||||
'status' => 'active',
|
||||
]
|
||||
);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$profile = EmployeeProfile::firstOrCreate(
|
||||
['user_id' => $admin->id],
|
||||
[
|
||||
'department' => 'Administration',
|
||||
'position' => 'System Administrator',
|
||||
'employee_code' => 'EMP-0001',
|
||||
'hire_date' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$admin->update([
|
||||
'userable_type' => EmployeeProfile::class,
|
||||
'userable_id' => $profile->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
Modules/UserManagement/module.json
Normal file
11
Modules/UserManagement/module.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "UserManagement",
|
||||
"alias": "usermanagement",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\UserManagement\\Providers\\UserManagementServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
15
Modules/UserManagement/package.json
Normal file
15
Modules/UserManagement/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios": "^1.1.2",
|
||||
"laravel-vite-plugin": "^0.7.5",
|
||||
"sass": "^1.69.5",
|
||||
"postcss": "^8.3.7",
|
||||
"vite": "^4.0.0"
|
||||
}
|
||||
}
|
||||
0
Modules/UserManagement/resources/assets/.gitkeep
Normal file
0
Modules/UserManagement/resources/assets/.gitkeep
Normal file
0
Modules/UserManagement/resources/assets/js/app.js
Normal file
0
Modules/UserManagement/resources/assets/js/app.js
Normal file
345
Modules/UserManagement/resources/js/Pages/Users/BulkImport.tsx
Normal file
345
Modules/UserManagement/resources/js/Pages/Users/BulkImport.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { Contractor, PageProps } from '@/types';
|
||||
import {
|
||||
Upload, CheckCircle2, XCircle, AlertTriangle, Copy, FileSpreadsheet, ArrowLeft, Send, UserPlus, Building2,
|
||||
} from 'lucide-react';
|
||||
import { FormEvent, useRef, useState } from 'react';
|
||||
|
||||
interface CreatedUser {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface Results {
|
||||
created: CreatedUser[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
results?: Results;
|
||||
contractors: Contractor[];
|
||||
}
|
||||
|
||||
export default function BulkImport({ results, contractors }: Props) {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
const isPlatformAdmin = auth.user.contractor_id === null;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [inviteMethod, setInviteMethod] = useState<'direct' | 'email'>('direct');
|
||||
const [selectedContractor, setSelectedContractor] = useState<string>('none');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped && (dropped.name.endsWith('.csv') || dropped.type === 'text/plain')) {
|
||||
setFile(dropped);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setProcessing(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('invite_method', inviteMethod);
|
||||
if (isPlatformAdmin && selectedContractor !== 'none') {
|
||||
formData.append('contractor_id', selectedContractor);
|
||||
}
|
||||
router.post(route('users.bulk-import'), formData, {
|
||||
forceFormData: true,
|
||||
onFinish: () => setProcessing(false),
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const copyAllCredentials = () => {
|
||||
if (!results) return;
|
||||
const text = results.created
|
||||
.map((u) => `${u.name} | ${u.email} | ${u.role} | ${u.password}`)
|
||||
.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied('all');
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<FileSpreadsheet className="h-5 w-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
Bulk Import Team Members
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Bulk Import Users" />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
{/* Upload Card */}
|
||||
{!results && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" /> Upload CSV File
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
CSV must have columns: <code className="rounded bg-gray-100 px-1 text-xs">Name, Email, Role, Department, Position</code>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
{/* Contractor picker for platform admins */}
|
||||
{isPlatformAdmin && contractors.length > 0 && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/60 p-4">
|
||||
<Label className="mb-1.5 flex items-center gap-2 text-sm font-medium text-blue-800">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Assign to Contractor
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedContractor}
|
||||
onValueChange={(v) => setSelectedContractor(v || 'none')}
|
||||
items={[
|
||||
{ value: 'none', label: '— No contractor (platform users) —' },
|
||||
...contractors.map((c) => ({ value: String(c.id), label: c.company_name })),
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id="bulk_contractor_id">
|
||||
<SelectValue placeholder="— No contractor (platform users) —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">— No contractor (platform users) —</SelectItem>
|
||||
{contractors.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.company_name}
|
||||
<span className="ml-2 text-xs text-gray-400 capitalize">({c.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-blue-600">All imported users will be assigned to this contractor.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed px-6 py-12 text-center transition-all ${
|
||||
dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-200 bg-gray-50 hover:border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/plain"
|
||||
className="hidden"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="space-y-1">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
<p className="font-medium text-gray-800">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB — Click to change</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FileSpreadsheet className="mx-auto h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">Drop your CSV here or click to browse</p>
|
||||
<p className="text-xs text-gray-400">Supports .csv files up to 2MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Method */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">Delivery Method</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['direct', 'email'] as const).map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => setInviteMethod(method)}
|
||||
className={`flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-sm transition-all ${
|
||||
inviteMethod === method
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{method === 'direct'
|
||||
? <><UserPlus className="h-5 w-5" /><span className="font-medium">Direct Creation</span><span className="text-xs opacity-70">Show passwords in summary</span></>
|
||||
: <><Send className="h-5 w-5" /><span className="font-medium">Email Invite</span><span className="text-xs opacity-70">Send credentials via email</span></>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSV format hint */}
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3">
|
||||
<p className="mb-1 text-xs font-semibold text-blue-700">Expected CSV Format</p>
|
||||
<code className="text-xs text-blue-600">
|
||||
Name,Email,Role,Department,Position<br />
|
||||
John Doe,john@example.com,employee,Engineering,Engineer<br />
|
||||
Jane Smith,jane@example.com,employee,Operations,Manager
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!file || processing} className="w-full">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{processing ? 'Importing...' : 'Import Team Members'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{results.created.length}</p>
|
||||
<p className="text-xs text-emerald-600">Users Created</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{results.errors.length}</p>
|
||||
<p className="text-xs text-red-500">Errors / Skipped</p>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1 flex items-center justify-center">
|
||||
<Link href={route('users.bulk-import.form')}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" /> Import Another File
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created Users */}
|
||||
{results.created.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
Successfully Created ({results.created.length})
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={copyAllCredentials}>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
{copied === 'all' ? 'Copied!' : 'Copy All'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
All users will be required to change their password on first login.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Temp Password</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.created.map((u, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">{u.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||
{u.password}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Copy credentials"
|
||||
onClick={() => copyToClipboard(`${u.email} / ${u.password}`, u.email)}
|
||||
>
|
||||
<Copy className={`h-3.5 w-3.5 ${copied === u.email ? 'text-emerald-500' : ''}`} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{results.errors.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Skipped Rows ({results.errors.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{results.errors.map((err, i) => (
|
||||
<div key={i} className="flex items-start gap-2 rounded-lg bg-red-50 p-3">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<p className="text-sm text-red-700">{err}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link href={route('users.index')}>
|
||||
<Button>View All Users</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { KeyRound, ShieldCheck } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
|
||||
export default function ChangePassword() {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const [strength, setStrength] = useState(0);
|
||||
|
||||
const handlePasswordChange = (val: string) => {
|
||||
setData('password', val);
|
||||
let score = 0;
|
||||
if (val.length >= 8) score++;
|
||||
if (/[A-Z]/.test(val)) score++;
|
||||
if (/[0-9]/.test(val)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(val)) score++;
|
||||
setStrength(score);
|
||||
};
|
||||
|
||||
const strengthLabel = ['', 'Weak', 'Fair', 'Good', 'Strong'];
|
||||
const strengthColor = ['', 'bg-red-500', 'bg-amber-400', 'bg-blue-500', 'bg-emerald-500'];
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('password.change.update'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-amber-500" />
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
Set Your New Password
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Change Password" />
|
||||
|
||||
<div className="flex min-h-[60vh] items-center justify-center py-12">
|
||||
<div className="w-full max-w-md px-4">
|
||||
{/* Warning Banner */}
|
||||
<div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800">Temporary Password Detected</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Your account was set up with a temporary password. Please create a new secure password to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Create New Password</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
placeholder="Minimum 8 characters"
|
||||
className="mt-1"
|
||||
/>
|
||||
{/* Strength bar */}
|
||||
{data.password.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-all duration-300 ${
|
||||
i <= strength ? strengthColor[strength] : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs font-medium ${strength >= 3 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{strengthLabel[strength]} password
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{errors.password && <p className="mt-1 text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password_confirmation">Confirm Password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
placeholder="Repeat your new password"
|
||||
className="mt-1"
|
||||
/>
|
||||
{data.password_confirmation && data.password !== data.password_confirmation && (
|
||||
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || strength < 2 || data.password !== data.password_confirmation}
|
||||
className="w-full"
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
{processing ? 'Saving...' : 'Set Password & Continue'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
386
Modules/UserManagement/resources/js/Pages/Users/Create.tsx
Normal file
386
Modules/UserManagement/resources/js/Pages/Users/Create.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import { ArrowLeft, Building2, KeyRound, Mail, RefreshCw, Save, Send, UserPlus } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Contractor, PageProps } from '@/types';
|
||||
|
||||
interface AuthUser {
|
||||
contractor_id: number | null;
|
||||
user_type: string;
|
||||
}
|
||||
|
||||
interface Props extends PageProps {
|
||||
contractors: Contractor[];
|
||||
}
|
||||
|
||||
export default function Create({ contractors }: Props) {
|
||||
const { auth } = usePage<PageProps & { auth: { user: AuthUser } }>().props;
|
||||
const isPlatformAdmin = auth.user.contractor_id === null;
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
user_type: 'employee',
|
||||
status: 'active',
|
||||
invite_method: 'direct' as 'direct' | 'email',
|
||||
auto_password: true,
|
||||
contractor_id: '' as string | number,
|
||||
department: '',
|
||||
position: '',
|
||||
employee_code: '',
|
||||
hire_date: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
company_name: '',
|
||||
contact_person: '',
|
||||
tin_number: '',
|
||||
spatie_role: 'employee',
|
||||
});
|
||||
|
||||
const [generatedPassword, setGeneratedPassword] = useState('');
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#';
|
||||
const pw = Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
setGeneratedPassword(pw);
|
||||
setData(d => ({ ...d, password: pw, password_confirmation: pw }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('users.store'));
|
||||
};
|
||||
|
||||
const isEmployee = data.user_type === 'admin' || data.user_type === 'employee';
|
||||
const isCustomer = data.user_type === 'customer';
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<UserPlus className="h-5 w-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">Add Team Member</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Add User" />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="rounded-md bg-red-50 p-4 border border-red-200">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">There were validation errors with your submission</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{Object.entries(errors).map(([field, message]) => (
|
||||
<li key={field}>{String(message)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Contractor Assignment (Platform Admin only) ── */}
|
||||
{isPlatformAdmin && contractors.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-blue-800">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Assign to Contractor
|
||||
</CardTitle>
|
||||
<p className="text-sm text-blue-600">
|
||||
Leave blank to create a platform-level user (no contractor affiliation).
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={String(data.contractor_id)}
|
||||
onValueChange={(v) => setData('contractor_id', v === 'none' ? '' : Number(v))}
|
||||
items={[
|
||||
{ value: 'none', label: '— No contractor (platform user) —' },
|
||||
...contractors.map((c) => ({ value: String(c.id), label: c.company_name })),
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id="contractor_id">
|
||||
<SelectValue placeholder="— No contractor (platform user) —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">— No contractor (platform user) —</SelectItem>
|
||||
{contractors.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.company_name}
|
||||
<span className="ml-2 text-xs text-gray-400 capitalize">({c.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.contractor_id && <p className="mt-1 text-sm text-red-500">{errors.contractor_id}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Provisioning Mode ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Invitation Method</CardTitle>
|
||||
<p className="text-sm text-gray-500">Choose how this team member receives their access credentials.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['direct', 'email'] as const).map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => setData('invite_method', method)}
|
||||
className={`flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-sm transition-all ${
|
||||
data.invite_method === method
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{method === 'direct'
|
||||
? <><UserPlus className="h-5 w-5" /><span className="font-semibold">Direct Creation</span><span className="text-xs opacity-70">You manage the password</span></>
|
||||
: <><Send className="h-5 w-5" /><span className="font-semibold">Email Invite</span><span className="text-xs opacity-70">Credentials sent via email</span></>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Account Info ── */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Account Information</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Full Name *</Label>
|
||||
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} className="mt-1" />
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} className="mt-1" />
|
||||
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>User Type *</Label>
|
||||
<Select value={data.user_type} onValueChange={(v) => {
|
||||
if (v) {
|
||||
setData('user_type', v);
|
||||
if (v !== 'contractor') setData('spatie_role', v);
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger id="user_type" className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{isPlatformAdmin && <SelectItem value="admin">Admin</SelectItem>}
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
<SelectItem value="contractor">Contractor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.user_type && <p className="mt-1 text-sm text-red-500">{errors.user_type}</p>}
|
||||
</div>
|
||||
|
||||
{data.user_type === 'contractor' && (
|
||||
<div>
|
||||
<Label>Role Level *</Label>
|
||||
<Select value={data.spatie_role} onValueChange={(v) => { if (v) setData('spatie_role', v); }}>
|
||||
<SelectTrigger id="spatie_role" className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main-contractor-admin">Main Contractor Admin</SelectItem>
|
||||
<SelectItem value="main-contractor-user">Main Contractor User</SelectItem>
|
||||
<SelectItem value="contractor">Sub Contractor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* @ts-ignore */}
|
||||
{errors.spatie_role && <p className="mt-1 text-sm text-red-500">{errors.spatie_role}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Status *</Label>
|
||||
<Select value={data.status} onValueChange={(v) => { if (v) setData('status', v); }}>
|
||||
<SelectTrigger id="status" className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Block */}
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-gray-400" />
|
||||
Temporary Password
|
||||
</Label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||||
<Checkbox
|
||||
checked={data.auto_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setData(d => ({ ...d, auto_password: checked, password: '', password_confirmation: '' }));
|
||||
setGeneratedPassword('');
|
||||
}}
|
||||
/>
|
||||
Auto-generate password
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{data.auto_password ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={generatedPassword}
|
||||
placeholder="Click Generate to create a password"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={generatePassword}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Generate
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Minimum 8 characters"
|
||||
value={data.password}
|
||||
onChange={(e) => setData('password', e.target.value)}
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
value={data.password_confirmation}
|
||||
onChange={(e) => setData('password_confirmation', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
The user will be required to change this password on their first login.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Employee Profile ── */}
|
||||
{isEmployee && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Employee Profile</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="department">Department</Label>
|
||||
<Input id="department" value={data.department} onChange={(e) => setData('department', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="position">Position</Label>
|
||||
<Input id="position" value={data.position} onChange={(e) => setData('position', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="employee_code">Employee Code</Label>
|
||||
<Input id="employee_code" value={data.employee_code} onChange={(e) => setData('employee_code', e.target.value)} placeholder="e.g., EMP-0002" className="mt-1" />
|
||||
{errors.employee_code && <p className="mt-1 text-sm text-red-500">{errors.employee_code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="hire_date">Hire Date</Label>
|
||||
<Input id="hire_date" type="date" value={data.hire_date} onChange={(e) => setData('hire_date', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="emp_phone">Phone</Label>
|
||||
<Input id="emp_phone" value={data.phone} onChange={(e) => setData('phone', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="emp_address">Address</Label>
|
||||
<Input id="emp_address" value={data.address} onChange={(e) => setData('address', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Customer Profile ── */}
|
||||
{isCustomer && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Customer Profile</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="company_name">Company Name</Label>
|
||||
<Input id="company_name" value={data.company_name} onChange={(e) => setData('company_name', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="contact_person">Contact Person</Label>
|
||||
<Input id="contact_person" value={data.contact_person} onChange={(e) => setData('contact_person', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="tin_number">TIN Number</Label>
|
||||
<Input id="tin_number" value={data.tin_number} onChange={(e) => setData('tin_number', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cust_phone">Phone</Label>
|
||||
<Input id="cust_phone" value={data.phone} onChange={(e) => setData('phone', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="outline" type="button">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{data.invite_method === 'email'
|
||||
? <><Mail className="mr-2 h-4 w-4" />{processing ? 'Sending...' : 'Send Invite'}</>
|
||||
: <><Save className="mr-2 h-4 w-4" />{processing ? 'Creating...' : 'Create User'}</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
327
Modules/UserManagement/resources/js/Pages/Users/Edit.tsx
Normal file
327
Modules/UserManagement/resources/js/Pages/Users/Edit.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm, usePage, router } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { ArrowLeft, Building2, Save, Unlink } from 'lucide-react';
|
||||
import { Contractor, User, PageProps } from '@/types';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Props extends PageProps {
|
||||
user: User;
|
||||
contractors: Contractor[];
|
||||
isPlatformAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function Edit({ user, contractors, isPlatformAdmin }: Props) {
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
user_type: user.user_type,
|
||||
status: user.status,
|
||||
department: user.employee_profile?.department || '',
|
||||
position: user.employee_profile?.position || '',
|
||||
employee_code: user.employee_profile?.employee_code || '',
|
||||
hire_date: user.employee_profile?.hire_date
|
||||
? user.employee_profile.hire_date.slice(0, 10)
|
||||
: '',
|
||||
phone: (user.employee_profile?.phone || user.customer_profile?.phone) || '',
|
||||
address: (user.employee_profile?.address || user.customer_profile?.address) || '',
|
||||
company_name: user.customer_profile?.company_name || '',
|
||||
contact_person: user.customer_profile?.contact_person || '',
|
||||
tin_number: user.customer_profile?.tin_number || '',
|
||||
spatie_role: user.roles?.[0]?.name || user.user_type,
|
||||
});
|
||||
|
||||
const contractorForm = useForm({
|
||||
contractor_id: user.contractor_id ? String(user.contractor_id) : 'none',
|
||||
});
|
||||
|
||||
const handleLinkContractor = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
contractorForm.patch(route('users.link-contractor', user.ulid));
|
||||
};
|
||||
|
||||
const handleUnlink = () => {
|
||||
if (!confirm('Remove contractor link from this user?')) return;
|
||||
contractorForm.setData('contractor_id', 'none');
|
||||
router.patch(route('users.link-contractor', user.ulid), {
|
||||
contractor_id: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(route('users.update', user.ulid));
|
||||
};
|
||||
|
||||
const isEmployee = data.user_type === 'admin' || data.user_type === 'employee';
|
||||
const isCustomer = data.user_type === 'customer';
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
Edit User: {user.name}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={`Edit ${user.name}`} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="rounded-md bg-red-50 p-4 border border-red-200 mb-6">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">There were validation errors with your submission</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{Object.entries(errors).map(([field, message]) => (
|
||||
<li key={field}>{String(message)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Full Name *</Label>
|
||||
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} />
|
||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} />
|
||||
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Input id="password" type="password" value={data.password} onChange={(e) => setData('password', e.target.value)} placeholder="Leave blank to keep current" />
|
||||
{errors.password && <p className="mt-1 text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password_confirmation">Confirm New Password</Label>
|
||||
<Input id="password_confirmation" type="password" value={data.password_confirmation} onChange={(e) => setData('password_confirmation', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>User Type *</Label>
|
||||
<Select value={data.user_type} onValueChange={(v) => {
|
||||
if (v) {
|
||||
setData('user_type', v);
|
||||
if (v !== 'contractor') setData('spatie_role', v);
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger id="user_type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{isPlatformAdmin && <SelectItem value="admin">Admin</SelectItem>}
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
<SelectItem value="contractor">Contractor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{data.user_type === 'contractor' && (
|
||||
<div>
|
||||
<Label>Role Level *</Label>
|
||||
<Select value={data.spatie_role} onValueChange={(v) => { if (v) setData('spatie_role', v); }}>
|
||||
<SelectTrigger id="spatie_role"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main-contractor-admin">Main Contractor Admin</SelectItem>
|
||||
<SelectItem value="main-contractor-user">Main Contractor User</SelectItem>
|
||||
<SelectItem value="contractor">Sub Contractor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Status *</Label>
|
||||
<Select value={data.status} onValueChange={(v) => { if (v) setData('status', v); }} items={[{ value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, { value: 'suspended', label: 'Suspended' }]}>
|
||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isEmployee && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader><CardTitle>Employee Profile</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="department">Department</Label>
|
||||
<Input id="department" value={data.department} onChange={(e) => setData('department', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="position">Position</Label>
|
||||
<Input id="position" value={data.position} onChange={(e) => setData('position', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="employee_code">Employee Code</Label>
|
||||
<Input id="employee_code" value={data.employee_code} onChange={(e) => setData('employee_code', e.target.value)} />
|
||||
{errors.employee_code && <p className="mt-1 text-sm text-red-500">{errors.employee_code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="hire_date">Hire Date</Label>
|
||||
<Input id="hire_date" type="date" value={data.hire_date} onChange={(e) => setData('hire_date', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<Input id="phone" value={data.phone} onChange={(e) => setData('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Input id="address" value={data.address} onChange={(e) => setData('address', e.target.value)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isCustomer && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader><CardTitle>Customer Profile</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="company_name">Company Name</Label>
|
||||
<Input id="company_name" value={data.company_name} onChange={(e) => setData('company_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="contact_person">Contact Person</Label>
|
||||
<Input id="contact_person" value={data.contact_person} onChange={(e) => setData('contact_person', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="tin_number">TIN Number</Label>
|
||||
<Input id="tin_number" value={data.tin_number} onChange={(e) => setData('tin_number', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<Input id="phone" value={data.phone} onChange={(e) => setData('phone', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Input id="address" value={data.address} onChange={(e) => setData('address', e.target.value)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="outline" type="button">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" /> Update User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Contractor Link — MUST be outside main form (no nested forms) ── */}
|
||||
{isPlatformAdmin && contractors.length > 0 && (
|
||||
<Card className="mt-6 border-blue-200 bg-blue-50/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-blue-800">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Contractor Link
|
||||
</CardTitle>
|
||||
<p className="text-sm text-blue-600">
|
||||
Link this user to a contractor so they can access the bidding portal.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.contractor && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-md bg-white border px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Building2 className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">{(user.contractor as any).company_name}</span>
|
||||
<span className="text-xs text-gray-400 capitalize">({(user.contractor as any).type})</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={handleUnlink}
|
||||
disabled={contractorForm.processing}
|
||||
>
|
||||
<Unlink className="mr-1 h-3.5 w-3.5" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleLinkContractor} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={contractorForm.data.contractor_id}
|
||||
onValueChange={(v) => contractorForm.setData('contractor_id', v || 'none')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="— Select contractor —">
|
||||
{contractorForm.data.contractor_id && contractorForm.data.contractor_id !== 'none'
|
||||
? contractors.find(c => String(c.id) === contractorForm.data.contractor_id)?.company_name ?? contractorForm.data.contractor_id
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">— No contractor —</SelectItem>
|
||||
{contractors.map(c => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.company_name}
|
||||
<span className="ml-2 text-xs text-gray-400 capitalize">({c.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={contractorForm.processing}>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
{user.contractor ? 'Update' : 'Link'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
222
Modules/UserManagement/resources/js/Pages/Users/Index.tsx
Normal file
222
Modules/UserManagement/resources/js/Pages/Users/Index.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent } from '@/Components/ui/card';
|
||||
import { DataTableToolbar } from '@/Components/DataTableToolbar';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import { PaginatedData, User, PageProps } from '@/types';
|
||||
import { Plus, Eye, Pencil, Trash2, ShieldCheck, ShieldOff, Upload, KeyRound, Building2 } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
interface Props extends PageProps {
|
||||
users: PaginatedData<User>;
|
||||
filters: {
|
||||
search?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'default';
|
||||
case 'inactive': return 'secondary';
|
||||
case 'suspended': return 'destructive';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'admin': return 'Admin';
|
||||
case 'employee': return 'Employee';
|
||||
case 'customer': return 'Customer';
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Index({ users, filters }: Props) {
|
||||
const { flash } = usePage<PageProps>().props;
|
||||
const [search, setSearch] = useState(filters.search || '');
|
||||
const [typeFilter, setTypeFilter] = useState(filters.type || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || 'all');
|
||||
|
||||
const applyFilters = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
router.get(route('users.index'), {
|
||||
search: search || undefined,
|
||||
type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
}, { preserveState: true, replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
if (confirm(`Are you sure you want to delete "${user.name}"?`)) {
|
||||
router.delete(route('users.destroy', user.ulid));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = (user: User) => {
|
||||
router.patch(route('users.toggle-status', user.ulid));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">User Management</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title="Users" />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{flash?.success && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>
|
||||
)}
|
||||
{flash?.error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<DataTableToolbar
|
||||
searchValue={search}
|
||||
searchPlaceholder="Search by name or email..."
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={applyFilters}
|
||||
filters={
|
||||
<>
|
||||
<Select value={typeFilter} onValueChange={(v) => { if (v) setTypeFilter(v); }} items={[{ value: 'all', label: 'All Types' }, { value: 'admin', label: 'Admin' }, { value: 'employee', label: 'Employee' }, { value: 'customer', label: 'Customer' }]}>
|
||||
<SelectTrigger id="filter-type" className="w-[150px]">
|
||||
<SelectValue placeholder="User Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="employee">Employee</SelectItem>
|
||||
<SelectItem value="customer">Customer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={(v) => { if (v) setStatusFilter(v); }} items={[{ value: 'all', label: 'All Status' }, { value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, { value: 'suspended', label: 'Suspended' }]}>
|
||||
<SelectTrigger id="filter-status" className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="suspended">Suspended</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={route('users.bulk-import.form')}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" /> Bulk Import
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={route('users.create')}>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Add User</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Contractor</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-gray-500 py-8">No users found.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.data.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.must_change_password && (
|
||||
<span title="Pending password change">
|
||||
<KeyRound className="h-3.5 w-3.5 text-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
{user.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{user.contractor ? (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
{user.contractor.company_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 italic">Platform</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{typeLabel(user.user_type)}</Badge>
|
||||
{user.roles && user.roles.length > 0 && (
|
||||
<div className="mt-1 text-[10px] uppercase text-gray-500">
|
||||
{user.roles[0].name.replace(/-/g, ' ')}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={statusVariant(user.status)}>{user.status}</Badge></TableCell>
|
||||
<TableCell>{new Date(user.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={route('users.show', user.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="View"><Eye className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<Link href={route('users.edit', user.ulid)}>
|
||||
<Button variant="ghost" size="icon-sm" title="Edit"><Pencil className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon-sm" title={user.status === 'active' ? 'Suspend' : 'Activate'} onClick={() => handleToggleStatus(user)}>
|
||||
{user.status === 'active' ? <ShieldOff className="h-4 w-4 text-amber-500" /> : <ShieldCheck className="h-4 w-4 text-green-500" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Delete" onClick={() => handleDelete(user)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{users.last_page > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">Showing {users.from} to {users.to} of {users.total} users</p>
|
||||
<div className="flex gap-1">
|
||||
{users.prev_page_url && <Link href={users.prev_page_url}><Button variant="outline" size="sm">Previous</Button></Link>}
|
||||
{users.next_page_url && <Link href={users.next_page_url}><Button variant="outline" size="sm">Next</Button></Link>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
170
Modules/UserManagement/resources/js/Pages/Users/Show.tsx
Normal file
170
Modules/UserManagement/resources/js/Pages/Users/Show.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs';
|
||||
import { ArrowLeft, Pencil, Mail, Phone, MapPin, Building2, CalendarDays, Hash } from 'lucide-react';
|
||||
import { User, PageProps } from '@/types';
|
||||
|
||||
interface Props extends PageProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'default';
|
||||
case 'inactive': return 'secondary';
|
||||
case 'suspended': return 'destructive';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 text-gray-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Show({ user }: Props) {
|
||||
const isEmployee = user.user_type === 'admin' || user.user_type === 'employee';
|
||||
const isCustomer = user.user_type === 'customer';
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800">
|
||||
{user.name}
|
||||
</h2>
|
||||
</div>
|
||||
<Link href={route('users.edit', user.ulid)}>
|
||||
<Button size="sm" variant="outline">
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Head title={user.name} />
|
||||
|
||||
<div className="py-6">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Summary Card */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 text-2xl font-bold text-gray-600">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">{user.name}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<div className="mt-3 flex justify-center gap-2">
|
||||
<Badge variant="outline">{user.user_type}</Badge>
|
||||
<Badge variant={statusVariant(user.status)}>{user.status}</Badge>
|
||||
</div>
|
||||
{user.roles && user.roles.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-gray-500 mb-1">Roles</p>
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role.id} variant="secondary" className="text-xs">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-4" />
|
||||
<InfoRow icon={Mail} label="Email" value={user.email} />
|
||||
<InfoRow icon={CalendarDays} label="Member Since" value={new Date(user.created_at).toLocaleDateString()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<div className="lg:col-span-2">
|
||||
<Tabs defaultValue="profile">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
{user.permissions && user.permissions.length > 0 && (
|
||||
<TabsTrigger value="permissions">Permissions</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile">
|
||||
{isEmployee && user.employee_profile && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Employee Information</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
<InfoRow icon={Building2} label="Department" value={user.employee_profile.department} />
|
||||
<InfoRow icon={Hash} label="Position" value={user.employee_profile.position} />
|
||||
<InfoRow icon={Hash} label="Employee Code" value={user.employee_profile.employee_code} />
|
||||
<InfoRow icon={CalendarDays} label="Hire Date" value={user.employee_profile.hire_date ? new Date(user.employee_profile.hire_date).toLocaleDateString() : undefined} />
|
||||
<InfoRow icon={Phone} label="Phone" value={user.employee_profile.phone} />
|
||||
<InfoRow icon={MapPin} label="Address" value={user.employee_profile.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isCustomer && user.customer_profile && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Customer Information</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
<InfoRow icon={Building2} label="Company Name" value={user.customer_profile.company_name} />
|
||||
<InfoRow icon={Hash} label="Contact Person" value={user.customer_profile.contact_person} />
|
||||
<InfoRow icon={Hash} label="TIN Number" value={user.customer_profile.tin_number} />
|
||||
<InfoRow icon={Phone} label="Phone" value={user.customer_profile.phone} />
|
||||
<InfoRow icon={MapPin} label="Address" value={user.customer_profile.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!user.employee_profile && !user.customer_profile && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-gray-500">
|
||||
No profile information available.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{user.permissions && user.permissions.length > 0 && (
|
||||
<TabsContent value="permissions">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Permissions</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.permissions.map((perm) => (
|
||||
<Badge key={perm.id} variant="outline">{perm.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
0
Modules/UserManagement/resources/views/.gitkeep
Normal file
0
Modules/UserManagement/resources/views/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<title>UserManagement Module - {{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<meta name="description" content="{{ $description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $keywords ?? '' }}">
|
||||
<meta name="author" content="{{ $author ?? '' }}">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
{{-- Vite CSS --}}
|
||||
{{-- {{ module_vite('build-usermanagement', 'resources/assets/sass/app.scss') }} --}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Vite JS --}}
|
||||
{{-- {{ module_vite('build-usermanagement', 'resources/assets/js/app.js') }} --}}
|
||||
</body>
|
||||
</html>
|
||||
5
Modules/UserManagement/resources/views/index.blade.php
Normal file
5
Modules/UserManagement/resources/views/index.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<x-usermanagement::layouts.master>
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>Module: {!! config('usermanagement.name') !!}</p>
|
||||
</x-usermanagement::layouts.master>
|
||||
@@ -0,0 +1,115 @@
|
||||
<div>
|
||||
<h2>User Management</h2>
|
||||
|
||||
@if (session()->has('message'))
|
||||
<div class="alert alert-success">
|
||||
{{ session('message') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div class="alert alert-danger">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h4>Invite / Add User</h4>
|
||||
<form wire:submit.prevent="createUser">
|
||||
<div class="mb-3">
|
||||
<label>Name</label>
|
||||
<input type="text" wire:model="name" class="form-control">
|
||||
@error('name') <span class="text-danger">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Email</label>
|
||||
<input type="email" wire:model="email" class="form-control">
|
||||
@error('email') <span class="text-danger">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Password (Temporary)</label>
|
||||
<input type="password" wire:model="password" class="form-control">
|
||||
@error('password') <span class="text-danger">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Role</label>
|
||||
<select wire:model="role" class="form-control">
|
||||
<option value="">Select Role...</option>
|
||||
@foreach($availableRoles as $roleName)
|
||||
<option value="{{ $roleName }}">{{ $roleName }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('role') <span class="text-danger">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label>Assign to Contractor</label>
|
||||
<select wire:model="contractor_id" class="form-control">
|
||||
<option value="">Select Contractor...</option>
|
||||
@foreach($availableContractors as $contractor)
|
||||
<option value="{{ $contractor->id }}">{{ $contractor->company_name }} ({{ ucfirst($contractor->type) }}) - Limit: {{ $contractor->user_limit }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('contractor_id') <span class="text-danger">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
@if(count($availablePermissions) > 0)
|
||||
<div class="mb-3">
|
||||
<label class="d-block">Direct Permissions</label>
|
||||
<div class="row">
|
||||
@foreach($availablePermissions as $permission)
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" wire:model="selectedPermissions" value="{{ $permission }}" id="perm_{{ $loop->index }}">
|
||||
<label class="form-check-label" for="perm_{{ $loop->index }}">
|
||||
{{ ucwords(str_replace('_', ' ', $permission)) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4>Existing Users</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Direct Permissions</th>
|
||||
<th>Contractor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($users as $user)
|
||||
<tr>
|
||||
<td>{{ $user->name }}</td>
|
||||
<td>{{ $user->email }}</td>
|
||||
<td>{{ $user->getRoleNames()->implode(', ') }}</td>
|
||||
<td>
|
||||
@if($user->getDirectPermissions()->count() > 0)
|
||||
<span class="badge bg-secondary">{{ $user->getDirectPermissions()->implode('name', ', ') }}</span>
|
||||
@else
|
||||
<span class="text-muted">None</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $user->contractor ? $user->contractor->company_name : 'N/A' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
0
Modules/UserManagement/routes/.gitkeep
Normal file
0
Modules/UserManagement/routes/.gitkeep
Normal file
8
Modules/UserManagement/routes/api.php
Normal file
8
Modules/UserManagement/routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\UserManagement\Http\Controllers\UserManagementController;
|
||||
|
||||
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
||||
Route::apiResource('usermanagements', UserManagementController::class)->names('usermanagement');
|
||||
});
|
||||
20
Modules/UserManagement/routes/web.php
Normal file
20
Modules/UserManagement/routes/web.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\UserManagement\Http\Controllers\UserController;
|
||||
|
||||
Route::middleware(['auth', 'verified', 'permission:users.access'])->group(function () {
|
||||
Route::resource('users', UserController::class);
|
||||
Route::patch('users/{user}/toggle-status', [UserController::class, 'toggleStatus'])->name('users.toggle-status');
|
||||
Route::patch('users/{user}/link-contractor', [UserController::class, 'linkContractor'])->name('users.link-contractor');
|
||||
|
||||
// Bulk import
|
||||
Route::get('users-bulk-import', [UserController::class, 'bulkImportForm'])->name('users.bulk-import.form');
|
||||
Route::post('users-bulk-import', [UserController::class, 'bulkImport'])->name('users.bulk-import');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
// Forced password change (exempt from EnforcePasswordChange via route name check)
|
||||
Route::get('change-password', [UserController::class, 'changePasswordForm'])->name('password.change.form');
|
||||
Route::post('change-password', [UserController::class, 'changePassword'])->name('password.change.update');
|
||||
});
|
||||
0
Modules/UserManagement/tests/Feature/.gitkeep
Normal file
0
Modules/UserManagement/tests/Feature/.gitkeep
Normal file
0
Modules/UserManagement/tests/Unit/.gitkeep
Normal file
0
Modules/UserManagement/tests/Unit/.gitkeep
Normal file
41
Modules/UserManagement/vite.config.js
Normal file
41
Modules/UserManagement/vite.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
// Uncomment the import for your frontend framework:
|
||||
// import vue from '@vitejs/plugin-vue';
|
||||
// import react from '@vitejs/plugin-react';
|
||||
// import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: '../../public/build-usermanagement',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
},
|
||||
plugins: [
|
||||
laravel({
|
||||
publicDirectory: '../../public',
|
||||
buildDirectory: 'build-usermanagement',
|
||||
input: [
|
||||
__dirname + '/resources/assets/sass/app.scss',
|
||||
__dirname + '/resources/assets/js/app.js'
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
// Uncomment the plugin for your frontend framework:
|
||||
// vue({
|
||||
// template: {
|
||||
// transformAssetUrls: {
|
||||
// base: null,
|
||||
// includeAbsolute: false,
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// react(),
|
||||
// svelte(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': __dirname + '/resources/js',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user