- Mengubah `user_id` menjadi nullable dan menambah kolom `email` pada tabel employees

- Memperbarui EmployeeController agar pembuatan data karyawan tidak otomatis membuat akun user
- Menambahkan sinkronisasi update data karyawan ke akun user (jika sudah tertaut)
- Menyesuaikan UI Frontend (Create, Edit, Index) agar membaca data langsung dari objek employee
- Menambahkan icon Settings dan tombol Logout pada AdminLayout
- Menyesuaikan host localhost pada konfigurasi Vite
This commit is contained in:
IlhamIslamy 2026-05-21 00:56:00 +07:00
parent 285db59bde
commit df88547cc3
17 changed files with 358 additions and 205 deletions

View File

@ -3,13 +3,12 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmployeeRequest;
use App\Http\Requests\UpdateEmployeeRequest;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class EmployeeController extends Controller
@ -20,9 +19,7 @@ public function index(Request $request)
if ($request->search) {
$query->where('nip', 'like', '%' . $request->search . '%')
->orWhereHas('user', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->search . '%');
});
->orWhere('name', 'like', '%' . $request->search . '%');
}
if ($request->department_id) {
@ -40,73 +37,32 @@ public function create()
{
return Inertia::render('admin/employees/create', [
'departments' => Department::all(),
'positions' => Position::all(),
'positions' => Position::all(),
]);
}
public function store(Request $request)
public function store(StoreEmployeeRequest $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'nip' => 'required|string|unique:employees,nip',
'gender' => 'required|in:L,P',
'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string|max:20',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
], [
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'nip.required' => 'NIP wajib diisi.',
'nip.unique' => 'NIP sudah terdaftar.',
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.',
'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'phone_number.max' => 'Nomor telepon maksimal 20 karakter.',
'department_id.required' => 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
]);
$validated = $request->validated();
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make('password'),
'role' => 'employee',
// Simpan hanya data karyawan ke tabel employees.
// Pembuatan akun User dilakukan secara terpisah oleh admin
// melalui menu Manajemen Pengguna.
Employee::create([
'nip' => $validated['nip'],
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'] ?? null,
'birth_date' => $validated['birth_date'],
'address' => $validated['address'] ?? null,
'phone_number' => $validated['phone_number'] ?? null,
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
$employee = Employee::create([
'user_id' => $user->id,
'nip' => $validated['nip'],
'name' => $validated['name'],
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'],
'birth_date' => $validated['birth_date'],
'address' => $validated['address'],
'phone_number' => $validated['phone_number'],
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
$user->update(['employee_id' => $employee->id]);
return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan berhasil ditambahkan.');
}
@ -116,65 +72,47 @@ public function edit(Employee $employee)
$employee->load(['user', 'department', 'position']);
return Inertia::render('admin/employees/edit', [
'employee' => $employee,
'employee' => $employee,
'departments' => Department::all(),
'positions' => Position::all(),
'positions' => Position::all(),
]);
}
public function update(Request $request, Employee $employee)
public function update(UpdateEmployeeRequest $request, Employee $employee)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => ['required', 'email', Rule::unique('users')->ignore($employee->user_id)],
'nip' => ['required', 'string', Rule::unique('employees')->ignore($employee->id)],
'gender' => 'required|in:L,P',
'place_of_birth' => 'nullable|string|max:255',
'birth_date' => 'required|date',
'address' => 'nullable|string',
'phone_number' => 'nullable|string|max:20',
'department_id' => 'required|exists:departments,id',
'position_id' => 'required|exists:positions,id',
'status' => 'required|in:PKWT,PKWTT,Magang',
'join_date' => 'required|date',
], [
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'nip.required' => 'NIP wajib diisi.',
'nip.unique' => 'NIP sudah terdaftar.',
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin hanya boleh Laki-laki atau Perempuan.',
'place_of_birth.max' => 'Tempat lahir maksimal 255 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'phone_number.max' => 'Nomor telepon maksimal 20 karakter.',
'department_id.required' => 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
]);
$validated = $request->validated();
$employee->user->update([
'name' => $validated['name'],
'email' => $validated['email'],
]);
// Sinkronisasi ke akun User jika karyawan sudah memiliki akun
if ($employee->user) {
$employee->user->update([
'name' => $validated['name'],
'email' => $validated['email'] ?? $employee->user->email,
]);
}
$employee->update($validated);
// Update data karyawan di tabel employees
$employee->update([
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'nip' => $validated['nip'],
'gender' => $validated['gender'],
'place_of_birth' => $validated['place_of_birth'] ?? null,
'birth_date' => $validated['birth_date'],
'address' => $validated['address'] ?? null,
'phone_number' => $validated['phone_number'] ?? null,
'department_id' => $validated['department_id'],
'position_id' => $validated['position_id'],
'status' => $validated['status'],
'join_date' => $validated['join_date'],
]);
return redirect()->route('admin.employees.index')
->with('success', 'Data karyawan diperbarui.');
->with('success', 'Data karyawan berhasil diperbarui.');
}
public function destroy(Employee $employee)
{
$employee->user->delete();
return back()->with('success', 'Karyawan dihapus.');
$employee->user?->delete();
return back()->with('success', 'Karyawan berhasil dihapus.');
}
}

View File

@ -6,29 +6,77 @@
class StoreEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'nip' => 'required|string|unique:employees,nip',
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
// ── Identitas ────────────────────────────────────────────────
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
'email' => ['nullable', 'email:rfc,dns', 'max:255', 'unique:employees,email'],
// ── Kepegawaian ──────────────────────────────────────────────
'nip' => ['required', 'digits:6', 'unique:employees,nip'],
// ── Data Pribadi ─────────────────────────────────────────────
'gender' => ['required', 'in:L,P'],
'place_of_birth'=> ['nullable', 'string', 'max:100'],
'birth_date' => ['required', 'date', 'before:today'],
'address' => ['nullable', 'string', 'max:500'],
'phone_number' => ['nullable', 'digits_between:8,15'],
// ── Jabatan & Departemen ─────────────────────────────────────
'department_id' => ['required', 'exists:departments,id'],
'position_id' => ['required', 'exists:positions,id'],
// ── Status & Tanggal ─────────────────────────────────────────
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
'join_date' => ['required', 'date'],
];
}
public function messages(): array
{
return [
// Nama
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
// Email
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'email.max' => 'Email maksimal 255 karakter.',
// NIP
'nip.required' => 'NIP wajib diisi.',
'nip.digits' => 'NIP harus tepat 6 digit angka.',
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
// Data Pribadi
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin tidak valid.',
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
'address.max' => 'Alamat maksimal 500 karakter.',
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
// Jabatan & Departemen
'department_id.required'=> 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
// Status & Tanggal
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
];
}
}

View File

@ -3,36 +3,95 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$employeeId = $this->route('employee')->id;
$userId = $this->route('employee')->user_id;
$employee = $this->route('employee');
$employeeId = $employee->id;
$userId = $employee->user_id;
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $userId,
'nip' => 'required|string|unique:employees,nip,' . $employeeId,
'position' => 'required|string',
'status' => 'required|in:PKWT,PKWTT',
'join_date' => 'required|date',
// Password opsional saat update
'password' => 'nullable|string|min:8',
// ── Identitas ────────────────────────────────────────────────
'name' => ['required', 'string', 'max:255', 'regex:/^[\pL\s]+$/u'],
'email' => [
'nullable',
'email:rfc,dns',
'max:255',
// Abaikan email milik employee ini sendiri
Rule::unique('employees', 'email')->ignore($employeeId),
],
// ── Kepegawaian ──────────────────────────────────────────────
'nip' => [
'required',
'digits:6',
Rule::unique('employees', 'nip')->ignore($employeeId),
],
// ── Data Pribadi ─────────────────────────────────────────────
'gender' => ['required', 'in:L,P'],
'place_of_birth'=> ['nullable', 'string', 'max:100'],
'birth_date' => ['required', 'date', 'before:today'],
'address' => ['nullable', 'string', 'max:500'],
'phone_number' => ['nullable', 'digits_between:8,15'],
// ── Jabatan & Departemen ─────────────────────────────────────
'department_id' => ['required', 'exists:departments,id'],
'position_id' => ['required', 'exists:positions,id'],
// ── Status & Tanggal ─────────────────────────────────────────
'status' => ['required', 'in:PKWT,PKWTT,Magang'],
'join_date' => ['required', 'date'],
];
}
public function messages(): array
{
return [
// Nama
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama lengkap maksimal 255 karakter.',
'name.regex' => 'Nama lengkap hanya boleh berisi huruf dan spasi.',
// Email
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah digunakan oleh karyawan lain.',
'email.max' => 'Email maksimal 255 karakter.',
// NIP
'nip.required' => 'NIP wajib diisi.',
'nip.digits' => 'NIP harus tepat 6 digit angka.',
'nip.unique' => 'NIP sudah terdaftar pada karyawan lain.',
// Data Pribadi
'gender.required' => 'Jenis kelamin wajib dipilih.',
'gender.in' => 'Jenis kelamin tidak valid.',
'place_of_birth.max' => 'Tempat lahir maksimal 100 karakter.',
'birth_date.required' => 'Tanggal lahir wajib diisi.',
'birth_date.date' => 'Format tanggal lahir tidak valid.',
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini.',
'address.max' => 'Alamat maksimal 500 karakter.',
'phone_number.digits_between' => 'Nomor HP harus berupa angka, minimal 8 dan maksimal 15 digit.',
// Jabatan & Departemen
'department_id.required'=> 'Departemen wajib dipilih.',
'department_id.exists' => 'Departemen tidak ditemukan.',
'position_id.required' => 'Jabatan wajib dipilih.',
'position_id.exists' => 'Jabatan tidak ditemukan.',
// Status & Tanggal
'status.required' => 'Status kepegawaian wajib dipilih.',
'status.in' => 'Status hanya boleh PKWT, PKWTT, atau Magang.',
'join_date.required' => 'Tanggal masuk wajib diisi.',
'join_date.date' => 'Format tanggal masuk tidak valid.',
];
}
}

View File

@ -10,9 +10,10 @@ class Employee extends Model
use HasFactory;
protected $fillable = [
'user_id',
'user_id', // diisi oleh UserController saat akun digenerate
'nip',
'name',
'email',
'gender',
'place_of_birth',
'birth_date',
@ -21,13 +22,13 @@ class Employee extends Model
'department_id',
'position_id',
'status',
'join_date'
'join_date',
];
// Relasi ke User
// Relasi ke User (nullable: karyawan boleh belum punya akun)
public function user()
{
return $this->belongsTo(User::class);
return $this->belongsTo(User::class)->withDefault();
}
// Relasi ke Departemen

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Ubah kolom user_id menjadi nullable agar data karyawan
* dapat disimpan tanpa harus memiliki akun User terlebih dahulu.
* Pembuatan akun User dilakukan secara manual oleh admin
* melalui menu Manajemen Pengguna.
*/
public function up(): void
{
Schema::table('employees', function (Blueprint $table) {
// Drop foreign key constraint lama (NOT NULL + cascade delete)
$table->dropForeign(['user_id']);
// Ubah kolom menjadi nullable dan pasang kembali foreign key
// dengan nullOnDelete agar baris employee tidak ikut terhapus
// jika akun User-nya dihapus.
$table->foreignId('user_id')
->nullable()
->change();
$table->foreign('user_id')
->references('id')
->on('users')
->nullOnDelete();
});
}
/**
* Rollback: kembalikan user_id ke NOT NULL dengan cascade delete.
*/
public function down(): void
{
Schema::table('employees', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->foreignId('user_id')
->nullable(false)
->change();
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tambahkan kolom email ke tabel employees.
* Sebelumnya email hanya disimpan di tabel users, namun sejak
* pembuatan akun User dipisahkan dari proses tambah karyawan,
* email perlu ikut disimpan di tabel employees sebagai data kontak.
*/
public function up(): void
{
Schema::table('employees', function (Blueprint $table) {
// Ditempatkan setelah kolom 'name', nullable karena
// data karyawan lama mungkin belum memiliki email.
$table->string('email')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('employees', function (Blueprint $table) {
$table->dropColumn('email');
});
}
};

View File

@ -2,12 +2,6 @@ import type { SVGAttributes } from 'react';
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
return (
<svg {...props} viewBox="0 0 40 42" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.2 5.63325L8.6 0.855469L0 5.63325V32.1434L16.2 41.1434L32.4 32.1434V23.699L40 19.4767V9.85547L31.4 5.07769L22.8 9.85547V18.2999L17.2 21.411V5.63325ZM38 18.2999L32.4 21.411V15.2545L38 12.1434V18.2999ZM36.9409 10.4439L31.4 13.5221L25.8591 10.4439L31.4 7.36561L36.9409 10.4439ZM24.8 18.2999V12.1434L30.4 15.2545V21.411L24.8 18.2999ZM23.8 20.0323L29.3409 23.1105L16.2 30.411L10.6591 27.3328L23.8 20.0323ZM7.6 27.9212L15.2 32.1434V38.2999L2 30.9666V7.92116L7.6 11.0323V27.9212ZM8.6 9.29991L3.05913 6.22165L8.6 3.14339L14.1409 6.22165L8.6 9.29991ZM30.4 24.8101L17.2 32.1434V38.2999L30.4 30.9666V24.8101ZM9.6 11.0323L15.2 7.92117V22.5221L9.6 25.6333V11.0323Z"
/>
</svg>
<img src="/assets/logo-clear.png" alt="Logo" className="w-8 h-8"/>
);
}

View File

@ -3,12 +3,12 @@ import AppLogoIcon from './app-logo-icon';
export default function AppLogo() {
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
<div className="flex aspect-square size-8 items-center justify-center">
<AppLogoIcon className="size-3" />
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
SDM App
HRIS App
</span>
</div>
</>

View File

@ -52,11 +52,6 @@ export function AppSidebar() {
href: '/admin/payrolls',
icon: Wallet,
},
{
title: 'Manajemen Absensi',
href: '/admin/attendances',
icon: CalendarClock,
},
{
title: 'Manajemen Pengguna',
href: '/admin/users',

View File

@ -37,9 +37,6 @@ export default function EmployeeLayout({ children, title }: EmployeeLayoutProps)
<div className="text-sm font-medium text-slate-700 hidden sm:block">
Halo, {user.name}
</div>
<Link href="/profile" className="p-2 text-slate-500 hover:text-sky-600 transition-colors rounded-full hover:bg-slate-100">
<User className="w-5 h-5" />
</Link>
<Link href="/logout" method="post" as="button" className="p-2 text-slate-500 hover:text-red-600 transition-colors rounded-full hover:bg-slate-100">
<LogOut className="w-5 h-5" />
</Link>

View File

@ -76,7 +76,8 @@ export default function Create({ departments, positions }: PageProps) {
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
type="email"
placeholder="contoh@email.com"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>

View File

@ -11,6 +11,7 @@ import AppLayout from '@/layouts/app-layout';
interface Employee {
id: number;
name: string;
nip: string;
gender: string;
birth_date: string;
@ -24,7 +25,7 @@ interface Employee {
user: {
name: string;
email: string;
};
} | null;
}
interface PageProps {
@ -37,8 +38,8 @@ interface PageProps {
export default function Edit({ employee, departments, positions }: PageProps) {
// Inisialisasi State
const { data, setData, put, processing, errors } = useForm({
name: employee.user.name,
email: employee.user.email,
name: employee.name,
email: employee.user?.email ?? '',
nip: employee.nip,
gender: employee.gender,
birth_date: employee.birth_date,
@ -58,7 +59,7 @@ export default function Edit({ employee, departments, positions }: PageProps) {
return (
<AppLayout>
<Head title={`Edit Karyawan: ${employee.user.name}`} />
<Head title={`Edit Karyawan: ${employee.name}`} />
<div className="p-4 md:p-8 max-w-5xl mx-auto">
<Button variant="outline" asChild className="mb-6">

View File

@ -18,6 +18,8 @@ import AppLayout from '@/layouts/app-layout';
interface Employee {
id: number;
name: string;
email: string | null;
nip: string;
status: string;
join_date: string;
@ -68,8 +70,8 @@ export default function Index({ employees, departments, filters }: PageProps) {
const exportExcel = () => {
const rows = employees.map((p, i) => ({
No: i + 1,
Nama: p.user?.name || '-',
Email: p.user?.email || '-',
Nama: p.name || '-',
Email: p.email || '-',
NIP: p.nip,
Departemen: p.department?.name || '-',
Jabatan: p.position?.name || '-',
@ -150,8 +152,8 @@ export default function Index({ employees, departments, filters }: PageProps) {
employees.map((employee) => (
<tr key={employee.id} className="border-b hover:bg-zinc-50">
<td className="p-4">
<div className="font-bold">{employee.user?.name}</div>
<div className="text-xs text-muted-foreground">{employee.user?.email}</div>
<div className="font-bold">{employee.name}</div>
<div className="text-xs text-muted-foreground">{employee.email || '-'}</div>
<div className="text-xs text-zinc-400 mt-1">NIP: {employee.nip}</div>
</td>
<td className="p-4">

View File

@ -24,7 +24,6 @@ import { useState } from 'react';
import * as XLSX from 'xlsx';
interface Payroll {
id: number;
employee_name: string;

View File

@ -47,7 +47,7 @@ export default function Create({ employees }: PageProps) {
<AppLayout>
<Head title="Buat Akun User" />
<div className="p-4 md:p-8 w-full">
<div className="p-4 md:p-8 w-full mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Buat Akun User</h2>
@ -60,7 +60,7 @@ export default function Create({ employees }: PageProps) {
</Button>
</div>
<Card className="max-w-xl">
<Card>
<CardHeader>
<CardTitle>Form Akun Baru</CardTitle>
</CardHeader>

View File

@ -10,6 +10,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import EmployeeLayout from '@/layouts/employee-layout';
import { AttendanceMapModal } from '@/components/AttendanceMapModal';
import { MapPin, AlertCircle, RefreshCw, CheckCircle2, LogOut } from 'lucide-react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
interface Attendance {
id: number;
@ -146,37 +148,67 @@ export default function AttendanceIndex({ attendances, todayAttendance }: PagePr
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 md:-mt-24 relative z-10 pb-12 space-y-6 md:space-y-8">
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm flex items-start md:items-center gap-3 shadow-sm ${
<div className={`p-4 md:p-5 rounded-xl md:rounded-2xl text-sm shadow-sm ${
isLoadingLocation ? 'bg-sky-100 text-sky-800' :
locationError ? 'bg-red-100 text-red-800' :
'bg-green-100 text-green-800'
}`}>
{isLoadingLocation ? (
<RefreshCw className="w-5 h-5 md:w-6 md:h-6 animate-spin shrink-0 mt-0.5 md:mt-0" />
) : locationError ? (
<AlertCircle className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
) : (
<MapPin className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
)}
<div className="flex-1 md:flex md:items-center md:justify-between">
<div>
<p className="font-semibold mb-0.5 md:text-base">
{isLoadingLocation ? 'Mencari lokasi Anda...' :
locationError ? 'Gagal Akses Lokasi' :
'Lokasi Ditemukan'}
</p>
<p className="text-xs md:text-sm opacity-90">
{isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' :
locationError ? locationError :
`Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`}
</p>
</div>
{locationError && (
<Button onClick={getLocation} variant="outline" size="sm" className="mt-2 md:mt-0 h-8 md:h-9 bg-white">
Coba Lagi
</Button>
<div className="flex items-start md:items-center gap-3">
{isLoadingLocation ? (
<RefreshCw className="w-5 h-5 md:w-6 md:h-6 animate-spin shrink-0 mt-0.5 md:mt-0" />
) : locationError ? (
<AlertCircle className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
) : (
<MapPin className="w-5 h-5 md:w-6 md:h-6 shrink-0 mt-0.5 md:mt-0" />
)}
<div className="flex-1 md:flex md:items-center md:justify-between">
<div>
<p className="font-semibold mb-0.5 md:text-base">
{isLoadingLocation ? 'Mencari lokasi Anda...' :
locationError ? 'Gagal Akses Lokasi' :
'Lokasi Ditemukan'}
</p>
<p className="text-xs md:text-sm opacity-90">
{isLoadingLocation ? 'Harap tunggu, pastikan GPS aktif.' :
locationError ? locationError :
`Koordinat: ${coordinates?.lat.toFixed(6)}, ${coordinates?.lng.toFixed(6)}`}
</p>
</div>
{locationError && (
<Button onClick={getLocation} variant="outline" size="sm" className="mt-2 md:mt-0 h-8 md:h-9 bg-white">
Coba Lagi
</Button>
)}
</div>
</div>
{/* Peta Preview - Hanya muncul jika koordinat berhasil didapatkan */}
{coordinates && !isLoadingLocation && !locationError && (
<div className="mt-4 h-48 md:h-64 w-full rounded-md overflow-hidden border-2 border-green-200 shadow-sm">
<MapContainer
center={[coordinates.lat, coordinates.lng]}
zoom={16}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[coordinates.lat, coordinates.lng]}>
<Popup>
<div className="text-center">
<p className="font-semibold text-sm">📍 Lokasi Anda Saat Ini</p>
<p className="text-xs text-slate-600 mt-1">
Lat: {coordinates.lat.toFixed(6)}<br />
Lng: {coordinates.lng.toFixed(6)}
</p>
</div>
</Popup>
</Marker>
</MapContainer>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">

View File

@ -5,6 +5,9 @@ import laravel from 'laravel-vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],