first commit

This commit is contained in:
Daffa Aditya Rejasa Ruswanto 2026-03-14 15:16:49 +07:00
commit ce5945ded3
853 changed files with 308797 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=sidakdesa_pelem_website
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
AGENTS.md

217
README.md Normal file
View File

@ -0,0 +1,217 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
# Sidak Desa QR Code System
Sistem absensi menggunakan QR Code dengan token dinamis yang berubah secara otomatis.
## Fitur
- QR Code dengan token yang berubah setiap 60 detik
- Real-time update menggunakan Pusher
- Anti-replay protection dengan nonce
- Auto-refresh ketika token expired
- Force rotation ketika ada scan yang berhasil
## Konfigurasi
### 1. Environment Variables
Tambahkan konfigurasi berikut di file `.env`:
```env
# Broadcasting (Pusher)
BROADCAST_DRIVER=pusher
PUSHER_APP_KEY=your_pusher_key
PUSHER_APP_SECRET=your_pusher_secret
PUSHER_APP_ID=your_pusher_app_id
PUSHER_APP_CLUSTER=ap1
# Cache (Redis recommended)
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
```
### 2. Pusher Setup
1. Daftar di [Pusher](https://pusher.com/)
2. Buat aplikasi baru
3. Copy credentials ke file `.env`
4. Pastikan channel `attendance.session.{sessionId}` diizinkan
## API Endpoints
### QR Code Generation
```
GET /api/attendance/sessions/{sessionId}/qrcode
```
### QR Code Verification
```
POST /api/attendance/verify
Content-Type: application/json
{
"token": "A1B2C3D4",
"user_id": 123,
"device_info": "iPhone 12"
}
```
### Force Rotate Token
```
POST /api/attendance/sessions/{sessionId}/rotate
```
## Halaman QR Code
```
GET /attendance/sessions/{sessionId}
```
## Cara Kerja
1. **Token Generation**: Server generate token baru setiap 60 detik
2. **Auto Refresh**: Frontend refresh token 2 detik sebelum expired
3. **Broadcast**: Server broadcast token baru ke semua client via Pusher
4. **Verification**: Ketika user scan, token divalidasi dan langsung generate token baru
5. **Anti-Replay**: Setiap token hanya bisa digunakan sekali dengan nonce
## Troubleshooting
### QR Code tidak berubah
1. Cek konfigurasi Pusher di `.env`
2. Cek console browser untuk error
3. Cek log Laravel untuk broadcast error
4. Pastikan `BROADCAST_DRIVER=pusher`
### Token tidak ter-generate
1. Cek cache driver (Redis/File)
2. Cek log Laravel
3. Test endpoint `/api/attendance/sessions/{sessionId}/qrcode`
### Broadcast tidak berfungsi
1. Cek koneksi internet
2. Cek Pusher dashboard untuk error
3. Cek browser console untuk WebSocket error
4. Pastikan channel name benar: `attendance.session.{sessionId}`
### Postman Test Gagal (409 Conflict - Replayed)
**Masalah**: Token yang digunakan sudah expired atau sudah pernah digunakan sebelumnya.
**Solusi**:
1. **Gunakan token terbaru**:
- Buka halaman test: `http://localhost:8000/test-qr`
- Klik "Get Latest Token" untuk mendapatkan token baru
- Copy token yang muncul di halaman test
- Gunakan token tersebut di Postman
2. **Test dengan endpoint khusus**:
```bash
# Dapatkan token terbaru
GET http://localhost:8000/api/attendance/sessions/1/latest-token
# Gunakan token yang didapat untuk verify
POST http://localhost:8000/api/attendance/verify
{
"token": "TOKEN_DARI_LATEST_TOKEN",
"user_id": 123,
"device_info": "Postman Test"
}
```
3. **Force rotate token**:
```bash
POST http://localhost:8000/api/attendance/sessions/1/rotate
```
**Catatan**:
- Token QR berubah setiap 60 detik
- Setiap token hanya bisa digunakan sekali (anti-replay protection)
- QR Code akan otomatis generate ulang setelah verifikasi (berhasil atau gagal)
- Gunakan halaman test untuk monitoring token terbaru
## Testing
### Manual Test QR Rotation
```bash
curl -X POST http://localhost:8000/api/attendance/sessions/1/rotate
```
### Test Verification
```bash
curl -X POST http://localhost:8000/api/attendance/verify \
-H "Content-Type: application/json" \
-d '{"token":"A1B2C3D4","user_id":123,"device_info":"test"}'
```
## Logs
Sistem akan mencatat log untuk:
- QR Token generation
- QR Token verification
- Broadcast events
- Error handling
Cek log di `storage/logs/laravel.log`

View File

@ -0,0 +1,126 @@
<?php
namespace App\Console\Commands;
use App\Models\Attendance;
use App\Models\AttendanceSetting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class SyncAttendanceDailyStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'attendance:sync-daily-status {--date= : Target date (Y-m-d), default H-1 berdasarkan timezone setting}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sinkron status absensi: isi alpha jika tidak absen, dan beri catatan jika check-out tidak ditemukan pada hari kerja efektif.';
/**
* Execute the console command.
*/
public function handle(): int
{
$setting = AttendanceSetting::query()->first();
if (!$setting) {
$this->error('Attendance setting belum tersedia. Jalankan seeder AttendanceSettingSeeder.');
return self::FAILURE;
}
$timezone = $setting->timezone ?: config('app.timezone', 'Asia/Jakarta');
$targetDate = $this->resolveTargetDate($timezone);
if (!$targetDate) {
return self::FAILURE;
}
$effectiveWorkdays = collect($setting->effective_workdays ?? [])
->map(fn($day) => (int) $day)
->unique()
->values();
if ($effectiveWorkdays->isNotEmpty() && !$effectiveWorkdays->contains($targetDate->isoWeekday())) {
$this->info("Skip {$targetDate->toDateString()}: bukan hari kerja efektif.");
return self::SUCCESS;
}
$users = User::query()
->where('status', 'aktif')
->select('id', 'name')
->get();
$createdAlpha = 0;
$notedMissingCheckout = 0;
foreach ($users as $user) {
$attendance = Attendance::query()
->where('user_id', $user->id)
->whereDate('date', $targetDate->toDateString())
->first();
if (!$attendance) {
Attendance::create([
'user_id' => $user->id,
'date' => $targetDate->toDateString(),
'status' => 'alpha',
'notes' => 'Tidak melakukan absensi pada hari kerja efektif.',
]);
$createdAlpha++;
continue;
}
if ($setting->require_checkout && $attendance->check_in && !$attendance->check_out) {
$attendance->update([
'notes' => $this->appendNote($attendance->notes, 'Absensi tidak lengkap: check-out tidak ditemukan.'),
]);
$notedMissingCheckout++;
}
}
$this->info("Sinkronisasi {$targetDate->toDateString()} selesai.");
$this->line("Alpha dibuat: {$createdAlpha}");
$this->line("Catatan check-out kosong: {$notedMissingCheckout}");
return self::SUCCESS;
}
private function resolveTargetDate(string $timezone): ?Carbon
{
$dateOption = $this->option('date');
if (!$dateOption) {
return Carbon::now($timezone)->subDay()->startOfDay();
}
try {
return Carbon::createFromFormat('Y-m-d', (string) $dateOption, $timezone)->startOfDay();
} catch (\Throwable $e) {
$this->error('Format --date tidak valid. Gunakan Y-m-d, contoh: 2026-02-19');
return null;
}
}
private function appendNote(?string $existing, string $note): string
{
$current = trim((string) $existing);
if ($current === '') {
return $note;
}
if (str_contains($current, $note)) {
return $current;
}
return "{$current} | {$note}";
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AttendanceUpdated implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(
public int $user_id,
public string $status,
public ?string $check_in = null,
public ?string $check_out = null
) {}
public function broadcastOn(): Channel
{
// Global channel untuk semua perubahan absensi
return new Channel('attendance.global');
}
// Supaya JS bisa listen('.attendance.updated')
public function broadcastAs(): string
{
return 'attendance.updated';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->user_id,
'status' => $this->status,
'check_in' => $this->check_in,
'check_out' => $this->check_out,
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class QrTokenIssued implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(
public int $sessionId,
public string $token,
public int $exp,
public int $ttl
) {}
public function broadcastOn(): Channel
{
// Harus sama dengan yg didengarkan di JS: 'attendance.session.' + SESSION_ID
return new Channel('attendance.session.' . $this->sessionId);
}
// Supaya JS bisa listen('.qr.token')
public function broadcastAs(): string
{
return 'qr.token';
}
public function broadcastWith(): array
{
return [
'token' => $this->token,
'exp' => $this->exp,
'ttl' => $this->ttl,
];
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,286 @@
<?php
namespace App\Http\Controllers\Controllers;
use Illuminate\Http\Request;
use App\Events\QrTokenIssued;
use App\Services\DynamicQrService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Cache;
use App\Models\Attendance;
use App\Models\AttendanceSetting;
use App\Models\User;
use Carbon\Carbon;
class AttendanceController extends Controller
{
private function qrTtl(): int
{
return 30;
}
private function defaultSettingAttributes(): array
{
return [
'checkin_start' => '07:00:00',
'checkin_end' => '09:00:00',
'checkout_start' => '15:00:00',
'late_grace_minutes' => 0,
'effective_workdays' => [1, 2, 3, 4, 5],
'timezone' => 'Asia/Jakarta',
'allow_checkin_after_end' => true,
'require_checkout' => true,
'office_latitude' => -7.5992153,
'office_longitude' => 112.1035051,
'attendance_radius_meters' => 90,
];
}
private function firstSettingOrDefault(): AttendanceSetting
{
return AttendanceSetting::query()->first()
?? new AttendanceSetting($this->defaultSettingAttributes());
}
// Tampilkan page QR
public function showPage(Request $req, int $sessionId)
{
$employees = User::where('status', 'aktif')
->with(['attendances' => function ($query) {
$query->today();
}])
->get()
->map(function ($user) {
$todayAttendance = $user->todayAttendance();
$checkIn = ($todayAttendance && $todayAttendance->check_in)
? Carbon::parse($todayAttendance->check_in)->format('h:i A')
: '-';
$checkOut = ($todayAttendance && $todayAttendance->check_out)
? Carbon::parse($todayAttendance->check_out)->format('h:i A')
: '-';
return [
'id' => $user->id,
'name' => $user->name,
'role' => $user->role,
'phone' => $user->phone,
'date' => Carbon::today()->format('d F Y'),
'checkin' => $checkIn,
'checkout' => $checkOut, // baru
'status' => $todayAttendance ? $todayAttendance->status : 'Belum Absen',
];
});
return view('qrcode.qr-page', [
'sessionId' => $sessionId,
'members' => $employees,
]);
}
public function currentToken(Request $req, int $sessionId, DynamicQrService $svc)
{
$ttl = $this->qrTtl();
$lead = 2;
$cacheKey = "qr:session:$sessionId:aktif";
$data = Cache::get($cacheKey);
$shouldRotate = true;
if ($data && isset($data['payload']['exp'])) {
$remain = (int)($data['payload']['exp'] - now()->timestamp);
$shouldRotate = $remain <= $lead; // rotate lebih awal
}
if ($shouldRotate) {
$data = $this->generateNewToken($svc, $sessionId, $ttl);
}
return response()->json([
'token' => $data['token'] ?? null,
'exp' => $data['payload']['exp'] ?? (now()->addSeconds($ttl)->timestamp),
'ttl' => $ttl,
]);
}
private function generateNewToken(\App\Services\DynamicQrService $svc, int $sessionId, int $ttl): array
{
$data = $svc->issueShortToken($sessionId, $ttl);
Cache::put("qr:session:$sessionId:aktif", $data, $ttl);
try {
broadcast(new QrTokenIssued(
$sessionId,
$data['token'],
$data['payload']['exp'],
$ttl
));
} catch (\Throwable $e) {
Log::warning('QR broadcast failed (generateNewToken): ' . $e->getMessage(), [
'session_id' => $sessionId,
]);
}
return $data;
}
// Method untuk force rotate token (bisa dipanggil dari scheduler atau manual)
public function forceRotate(Request $req, int $sessionId, DynamicQrService $svc)
{
$ttl = $this->qrTtl();
$data = $this->generateNewToken($svc, $sessionId, $ttl);
return response()->json([
'ok' => true,
'message' => 'Token rotated successfully',
'token' => $data['token'],
'exp' => $data['payload']['exp'],
'ttl' => $ttl
]);
}
public function show()
{
$setting = $this->firstSettingOrDefault();
return view('admin.features.attendance.setting-attendance', compact('setting'));
}
public function store(Request $request)
{
$validated = $request->validate([
'checkin_start' => ['required', 'date_format:H:i'],
'checkin_end' => ['required', 'date_format:H:i', 'after:checkin_start'],
'checkout_start' => ['required', 'date_format:H:i', 'after:checkin_start'],
'late_grace_minutes' => ['required', 'integer', 'min:0', 'max:1440'],
'effective_workdays' => ['nullable', 'array'],
'effective_workdays.*' => ['integer', 'between:1,7'],
'office_latitude' => ['required', 'numeric', 'between:-90,90'],
'office_longitude' => ['required', 'numeric', 'between:-180,180'],
'attendance_radius_meters' => ['required', 'integer', 'min:10', 'max:5000'],
]);
$workdays = collect($validated['effective_workdays'] ?? [])
->map(fn($day) => (int) $day)
->unique()
->sort()
->values()
->all();
$setting = AttendanceSetting::query()->first();
$payload = [
'checkin_start' => $validated['checkin_start'],
'checkin_end' => $validated['checkin_end'],
'checkout_start' => $validated['checkout_start'],
'late_grace_minutes' => $validated['late_grace_minutes'],
'effective_workdays' => empty($workdays) ? null : $workdays,
'timezone' => $setting?->timezone ?? $this->defaultSettingAttributes()['timezone'],
'allow_checkin_after_end' => $request->boolean('allow_checkin_after_end'),
'require_checkout' => $request->boolean('require_checkout'),
'office_latitude' => round((float) $validated['office_latitude'], 7),
'office_longitude' => round((float) $validated['office_longitude'], 7),
'attendance_radius_meters' => (int) $validated['attendance_radius_meters'],
];
if ($setting) {
$setting->update($payload);
} else {
AttendanceSetting::create($payload);
}
return redirect()
->route('admin.attendance.setting.show')
->with('success', 'Pengaturan absensi berhasil disimpan.');
}
public function updateAttendance(Request $request, Attendance $attendance)
{
$validator = Validator::make($request->all(), [
'status' => ['required', 'in:hadir,izin,sakit,alpha'],
'notes' => ['required', 'string', 'max:1000'],
'month' => ['nullable', 'integer', 'between:1,12'],
'year' => ['nullable', 'integer', 'between:2000,2100'],
], [
'status.required' => 'Status absensi wajib dipilih.',
'status.in' => 'Status absensi tidak valid.',
'notes.required' => 'Catatan wajib diisi.',
'notes.string' => 'Catatan harus berupa teks.',
'notes.max' => 'Catatan maksimal 1000 karakter.',
'month.integer' => 'Bulan tidak valid.',
'month.between' => 'Bulan harus antara 1 sampai 12.',
'year.integer' => 'Tahun tidak valid.',
'year.between' => 'Tahun harus antara 2000 sampai 2100.',
]);
if ($validator->fails()) {
return redirect()
->back()
->withErrors($validator)
->withInput();
}
$validated = $validator->validated();
$notes = trim($validated['notes']);
if ($notes === '') {
return redirect()
->back()
->withErrors(['notes' => 'Catatan wajib diisi.'])
->withInput()
->with('error', 'Form absensi belum lengkap atau ada data yang tidak valid.');
}
$attendance->update([
'status' => $validated['status'],
'notes' => $notes,
]);
return redirect()
->route('admin.laporan.detail', [
'user' => $attendance->user_id,
'month' => $validated['month'] ?? null,
'year' => $validated['year'] ?? null,
])
->with('success', 'Data absensi berhasil diperbarui.');
}
public function members($sessionId, Request $request)
{
// Kalau mau, di sini kamu bisa validasi sessionId benar2 ada.
$today = Carbon::today();
// Eager load absensi hari ini (ambil yang terbaru per user)
$users = User::query()
->where('status', 'aktif')
->with(['attendances' => function ($q) use ($today) {
// SESUAIKAN kolom tanggal di tabel absensi:
// pakai salah satu yang kamu punya: 'date' / 'attendance_date' / 'created_at'
$q->whereDate('date', $today) // <-- ganti 'date' jika berbeda
->latest('id');
}])
->select('id', 'name', 'role', 'phone', 'employee_id')
->get();
$rows = $users->map(function ($u) {
$a = $u->attendances->first(); // hasil eager load di atas (bisa null)
return [
'id' => $u->id,
'name' => $u->name,
'role' => $u->role,
'phone' => $u->phone,
'employee_id' => $u->employee_id,
'check_in' => $a?->check_in,
'check_out' => $a?->check_out,
'status' => $a?->status, // 'hadir' | 'izin' | 'sakit' | 'alpha' | null
];
})->values();
return response()->json($rows);
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Mail\ForgotPasswordMail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class AuthController extends Controller
{
public function showLoginForm()
{
if (Auth::check()) {
return redirect()->route('admin.dashboard');
}
return view('auth.login');
}
public function showForgetPasswordForm()
{
return view('auth.forgot-password');
}
public function showResetPasswordForm($token, Request $request)
{
return view('auth.reset-password', ['token' => $token, 'email' => $request->email]);
}
public function login(Request $request)
{
try {
$credentials = request()->validate([
'email' => 'required|email',
'password' => 'required|min:6'
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
// Hanya izinkan Kepala Desa & Sekretaris
$user = Auth::user();
$allowed = ['admin'];
if (!in_array($user->role, $allowed, true)) {
// Langsung keluarkan & beri pesan
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'email' => 'Akun Anda tidak berwenang masuk ke Admin Dashboard.',
])->withInput($request->only('email'));
}
// Lolos semua cek
return redirect()->intended(route('admin.dashboard'));
}
return back()->withErrors([
'email' => 'Email atau password salah.',
])->withInput($request->only('email'));
} catch (\Exception $e) {
//throw $th;
Log::error('Login error: ' . $e->getMessage());
return back()->withErrors([
'error' => 'Terjadi kesalahan pada server harap coba lagi nanti.',
]);
}
}
public function forgotPassword(Request $request)
{
// Validate email
$data = $request->validate(['email' => 'required|email|exists:users,email'], [
'email.required' => 'Kolom email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.exists' => 'Maaf, email ini tidak terdaftar di sistem kami.',
]);
// Generate reset password link
$token = Str::random(64);
DB::table('password_resets')->updateOrInsert(
['email' => $data['email']],
[
'token' => $token,
'created_at' => now()
]
);
$url = url("/reset-password/{$token}?email=" . urlencode($data['email']));
// Send email
Mail::to($data['email'])->send(new ForgotPasswordMail($url));
return back()->with('status', 'Link reset password telah dikirim ke email Anda.');
}
public function resetPassword(Request $request)
{
// 1. Validasi input
$data = $request->validate(
[
'email' => 'required|email|exists:users,email',
'password' => 'required|string|min:8|confirmed',
'token' => 'required|string',
],
[
'email.required' => 'Kolom email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.exists' => 'Email tidak terdaftar di sistem kami.',
'password.required' => 'Kolom kata sandi wajib diisi.',
'password.min' => 'Kata sandi minimal :min karakter.',
'password.confirmed' => 'Konfirmasi kata sandi tidak cocok.',
'token.required' => 'Token reset tidak ditemukan.',
]
);
// 2. Cek token di tabel password_resets
$reset = DB::table('password_resets')
->where('email', $data['email'])
->where('token', $data['token'])
->first();
if (!$reset) {
return back()->withErrors([
'token' => 'Token reset tidak valid atau sudah kedaluwarsa.',
]);
}
if (now()->diffInMinutes($reset->created_at) > 60) {
DB::table('password_resets')->where('email', $data['email'])->delete();
return back()->withErrors([
'token' => 'Token reset telah kedaluwarsa.',
]);
}
// 3. Update password user
$user = User::where('email', $data['email'])->first();
$user->password = Hash::make($data['password']);
$user->save();
// 4. Hapus record password_resets
DB::table('password_resets')->where('email', $data['email'])->delete();
return redirect()
->route('login')
->with('status', 'Password Anda berhasil direset. Silakan login dengan kata sandi baru.');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\User;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
$data = Attendance::with('user')->where('date', now()->format('Y-m-d'))->get();
$lengthAttendance = $data->count();
$lengthEmployee = User::count();
$izin = Attendance::where('status', 'izin')->where('date', now()->format('Y-m-d'))->get();
return view('admin.features.dashboard.dashboard', compact('data', 'lengthAttendance', 'lengthEmployee', 'izin'));
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Models\User;
use App\Models\News;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use App\Http\Controllers\Controller;
class LandingPageController extends Controller
{
public function index()
{
$employees = User::where('status', 'aktif')
->with(['attendances' => function ($q) {
$q->today();
}])
->get()
->map(function ($user) {
$today = $user->todayAttendance();
return [
'id' => $user->id,
'name' => $user->name,
'role' => $user->role,
'phone' => $user->phone,
'date' => Carbon::today()->format('d F Y'),
'checkin' => $today && $today->check_in
? Carbon::parse($today->check_in)->format('H:i')
: '-',
'checkout' => $today && $today->check_out
? Carbon::parse($today->check_out)->format('H:i')
: '-',
'status' => $today ? $today->status : '',
];
});
// Get news categories for dynamic loading
$newsCategories = News::published()
->select('category')
->distinct()
->pluck('category');
return view('welcome', [ // sesuaikan nama view landingmu
'members' => $employees,
'newsCategories' => $newsCategories,
]);
}
}

View File

@ -0,0 +1,292 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\User;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LaporanController extends Controller
{
public function showLaporan(Request $request)
{
$month = (int) ($request->get('month', now()->month));
$year = (int) ($request->get('year', now()->year));
$month = max(1, min(12, $month));
$year = max(2000, min(2100, $year));
$start = Carbon::create($year, $month, 1)->startOfMonth();
$end = Carbon::create($year, $month, 1)->endOfMonth();
// ✅ kalau bulan berjalan, end cukup sampai hari ini (biar “per tanggal hari ini”)
if ($year === now()->year && $month === now()->month) {
$end = now();
}
$startDate = $start->toDateString();
$endDate = $end->toDateString();
$rows = User::query()
->where('users.status', 'aktif')
->select('users.id', 'users.name', 'users.jabatan', 'users.url_photo')
->withCount([
'attendances as hadir' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'hadir'),
'attendances as izin' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'izin'),
'attendances as sakit' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'sakit'),
'attendances as alpha' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'alpha'),
])
->orderBy('users.name', 'asc')
->get();
$monthLabel = Carbon::create($year, $month, 1)->translatedFormat('F Y');
return view('admin.features.laporan.laporan', [
'rows' => $rows,
'month' => $month,
'year' => $year,
'monthLabel' => $monthLabel,
'start' => $startDate,
'end' => $endDate,
]);
}
public function showDetail(Request $request, User $user)
{
$month = (int) ($request->get('month', now()->month));
$year = (int) ($request->get('year', now()->year));
$month = max(1, min(12, $month));
$year = max(2000, min(2100, $year));
$start = Carbon::create($year, $month, 1)->startOfMonth();
$end = Carbon::create($year, $month, 1)->endOfMonth();
// optional: jika bulan berjalan, sampai hari ini
if ($year === now()->year && $month === now()->month) {
$end = now();
}
$startDate = $start->toDateString();
$endDate = $end->toDateString();
// ambil riwayat attendances bulan tsb
$items = Attendance::query()
->where('user_id', $user->id)
->whereBetween('date', [$startDate, $endDate])
->orderBy('date', 'desc')
->get();
// rekap status (hadir/izin/sakit/alpha)
$counts = Attendance::query()
->where('user_id', $user->id)
->whereBetween('date', [$startDate, $endDate])
->select('status', DB::raw('COUNT(*) as total'))
->groupBy('status')
->pluck('total', 'status');
$summary = [
'hadir' => (int) ($counts['hadir'] ?? 0),
'izin' => (int) ($counts['izin'] ?? 0),
'sakit' => (int) ($counts['sakit'] ?? 0),
'alpha' => (int) ($counts['alpha'] ?? 0),
];
// hitung durasi di PHP (lebih fleksibel)
$normalizeDT = function ($date, $t) {
if (!$t) return null;
// kalau sudah Carbon instance
if ($t instanceof \Carbon\CarbonInterface) return $t;
$t = (string) $t;
// kalau sudah mengandung tanggal (YYYY-MM-DD ...)
if (preg_match('/\d{4}-\d{2}-\d{2}/', $t)) {
return Carbon::parse($t);
}
// kalau cuma jam (HH:MM:SS) -> gabungkan dengan tanggal dari $date
$dateOnly = Carbon::parse($date)->toDateString(); // aman walau $date "2026-02-20 00:00:00"
return Carbon::parse($dateOnly . ' ' . $t);
};
$items->transform(function ($a) use ($normalizeDT) {
$a->date_label = Carbon::parse($a->date)->translatedFormat('d F Y');
$inDT = $normalizeDT($a->date, $a->check_in);
$outDT = $normalizeDT($a->date, $a->check_out);
$a->check_in_label = $inDT ? $inDT->format('H:i') : '-';
$a->check_out_label = $outDT ? $outDT->format('H:i') : '-';
$a->duration_label = '-';
if ($inDT && $outDT) {
if ($outDT->lt($inDT)) $outDT = $outDT->copy()->addDay();
$sec = $inDT->diffInSeconds($outDT);
$a->duration_label = gmdate('H:i', $sec);
}
$a->notes_label = $a->notes ?? '-';
$a->location_label = $a->location ?? '-';
$a->location_lat = null;
$a->location_lng = null;
$a->osm_url = null;
$a->osm_embed_url = null;
$location = trim((string) ($a->location ?? ''));
$parts = array_map('trim', explode(',', $location));
if (count($parts) === 2 && is_numeric($parts[0]) && is_numeric($parts[1])) {
$lat = (float) $parts[0];
$lng = (float) $parts[1];
if ($lat >= -90 && $lat <= 90 && $lng >= -180 && $lng <= 180) {
$a->location_lat = $lat;
$a->location_lng = $lng;
$a->osm_url = "https://www.openstreetmap.org/?mlat={$lat}&mlon={$lng}#map=18/{$lat}/{$lng}";
$delta = 0.005;
$minLng = $lng - $delta;
$minLat = $lat - $delta;
$maxLng = $lng + $delta;
$maxLat = $lat + $delta;
$a->osm_embed_url = "https://www.openstreetmap.org/export/embed.html?bbox={$minLng}%2C{$minLat}%2C{$maxLng}%2C{$maxLat}&layer=mapnik&marker={$lat}%2C{$lng}";
}
}
return $a;
});
$monthLabel = Carbon::create($year, $month, 1)->translatedFormat('F Y');
// persentase hadir (dari total record bulan tsb)
$total = max(1, array_sum($summary));
$presentPercent = (int) round(($summary['hadir'] / $total) * 100);
return view('admin.features.laporan.detail-laporan', [
'user' => $user,
'items' => $items,
'summary' => $summary,
'month' => $month,
'year' => $year,
'monthLabel' => $monthLabel,
'start' => $startDate,
'end' => $endDate,
'presentPercent' => $presentPercent,
]);
}
// Perubahan pada LaporanController.php method export
public function export(Request $request)
{
$month = (int) ($request->get('month', now()->month));
$year = (int) ($request->get('year', now()->year));
$month = max(1, min(12, $month));
$year = max(2000, min(2100, $year));
$start = Carbon::create($year, $month, 1)->startOfMonth();
$end = Carbon::create($year, $month, 1)->endOfMonth();
// opsional: kalau bulan berjalan -> sampai hari ini
if ($year === now()->year && $month === now()->month) {
$end = now();
}
$startDate = $start->toDateString();
$endDate = $end->toDateString();
// ✅ semua role ikut tampil (tidak filter role)
$rows = User::query()
// ->where('users.status', 'aktif') // kalau mau semua status, biarkan comment
->select('users.id', 'users.name', 'users.jabatan')
->withCount([
'attendances as hadir' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'hadir'),
'attendances as izin' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'izin'),
'attendances as sakit' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'sakit'),
'attendances as alpha' => fn($q) => $q->whereBetween('date', [$startDate, $endDate])->where('status', 'alpha'),
])
->orderBy('users.name', 'asc')
->get();
$monthLabel = Carbon::create($year, $month, 1)->translatedFormat('F Y');
$totals = [
'hadir' => (int) $rows->sum('hadir'),
'izin' => (int) $rows->sum('izin'),
'sakit' => (int) $rows->sum('sakit'),
'alpha' => (int) $rows->sum('alpha'),
];
$pdf = Pdf::loadView('admin.features.laporan.export.pdf', [
'rows' => $rows,
'monthLabel' => $monthLabel,
'start' => $startDate,
'end' => $endDate,
'totals' => $totals,
])->setPaper('a4', 'landscape');
$filename = 'Rekap_Absensi_' . str_replace(' ', '_', $monthLabel) . '.pdf';
return $pdf->download($filename);
}
public function exportDetail(Request $request, User $user)
{
$month = (int) ($request->get('month', now()->month));
$year = (int) ($request->get('year', now()->year));
$month = max(1, min(12, $month));
$year = max(2000, min(2100, $year));
$start = Carbon::create($year, $month, 1)->startOfMonth();
$end = Carbon::create($year, $month, 1)->endOfMonth();
if ($year === now()->year && $month === now()->month) {
$end = now();
}
$startDate = $start->toDateString();
$endDate = $end->toDateString();
$items = Attendance::where('user_id', $user->id)
->whereBetween('date', [$startDate, $endDate])
->orderBy('date', 'asc')
->get();
$counts = Attendance::where('user_id', $user->id)
->whereBetween('date', [$startDate, $endDate])
->select('status', DB::raw('COUNT(*) as total'))
->groupBy('status')
->pluck('total', 'status');
$summary = [
'hadir' => (int) ($counts['hadir'] ?? 0),
'izin' => (int) ($counts['izin'] ?? 0),
'sakit' => (int) ($counts['sakit'] ?? 0),
'alpha' => (int) ($counts['alpha'] ?? 0),
];
$monthLabel = Carbon::create($year, $month, 1)->translatedFormat('F Y');
$pdf = Pdf::loadView('admin.features.laporan.export.pdf-detail', [
'user' => $user,
'items' => $items,
'summary' => $summary,
'monthLabel' => $monthLabel,
'start' => $startDate,
'end' => $endDate,
])->setPaper('a4', 'portrait');
return $pdf->download('Detail_Absensi_' . str_replace(' ', '_', $monthLabel) . '_' . $user->name . '.pdf');
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\News;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
class NewsAdminController extends Controller
{
public function index()
{
// Get all news for the table
$news = News::orderBy('created_at', 'desc')->get();
return view('admin.features.berita.show-berita', compact('news'));
}
public function create()
{
return view('admin.features.berita.tambah-berita');
}
public function store(Request $request)
{
// Validate request
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:news,slug',
'content' => 'required|string',
'category' => 'required|string|max:100',
'status' => 'required|in:draft,published,archived',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
// Generate slug if not provided
if (empty($request->slug)) {
$request->merge(['slug' => Str::slug($request->title)]);
}
// Handle file upload
$image = null;
if ($request->hasFile('image')) {
$image = $request->file('image')->store('news', 'public');
}
// Create news
News::create([
'title' => $request->title,
'slug' => $request->slug,
'content' => $request->content,
'category' => $request->category,
'status' => $request->status,
'image' => $image,
]);
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil ditambahkan!');
}
public function edit($id)
{
$news = News::findOrFail($id);
return view('admin.features.berita.edit-berita', compact('news'));
}
public function update(Request $request, $id)
{
$news = News::findOrFail($id);
// Validate request
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:news,slug,' . $id,
'content' => 'required|string',
'category' => 'required|string|max:100',
'status' => 'required|in:draft,published,archived',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
// Generate slug if not provided
if (empty($request->slug)) {
$request->merge(['slug' => Str::slug($request->title)]);
}
// Handle file upload
$image = $news->image;
if ($request->hasFile('image')) {
// Delete old image if exists
if ($news->image) {
Storage::disk('public')->delete($news->image);
}
$image = $request->file('image')->store('news', 'public');
}
// Update news
$news->update([
'title' => $request->title,
'slug' => $request->slug,
'content' => $request->content,
'category' => $request->category,
'status' => $request->status,
'image' => $image,
]);
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil diperbarui!');
}
public function destroy($id)
{
$news = News::findOrFail($id);
// Delete image if exists
if ($news->image) {
Storage::disk('public')->delete($news->image);
}
$news->delete();
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil dihapus!');
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\News;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
class NewsController extends Controller
{
public function index()
{
// Get all news for the table
$news = News::orderBy('created_at', 'desc')->get();
return view('admin.features.berita.show-berita', compact('news'));
}
public function create()
{
return view('admin.features.berita.add-berita');
}
public function store(Request $request)
{
// Validate request
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:news,slug',
'content' => 'required|string',
'category' => 'required|string|max:100',
'status' => 'required|in:draft,published,archived',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
// Generate slug if not provided
if (empty($request->slug)) {
$request->merge(['slug' => Str::slug($request->title)]);
}
// Handle file upload
$image = null;
if ($request->hasFile('image')) {
$image = $request->file('image')->store('news', 'public');
}
// Create news
News::create([
'title' => $request->title,
'slug' => $request->slug,
'content' => $request->content,
'category' => $request->category,
'status' => $request->status,
'image' => $image,
]);
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil ditambahkan!');
}
public function show($id)
{
$news = News::findOrFail($id);
return view('admin.features.berita.detail-information', compact('news'));
}
public function edit($id)
{
$news = News::findOrFail($id);
return view('admin.features.berita.edit-berita', compact('news'));
}
public function update(Request $request, $id)
{
$news = News::findOrFail($id);
// Validate request
$request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:news,slug,' . $id,
'content' => 'required|string',
'category' => 'required|string|max:100',
'status' => 'required|in:draft,published,archived',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
// Generate slug if not provided
if (empty($request->slug)) {
$request->merge(['slug' => Str::slug($request->title)]);
}
// Handle file upload
$image = $news->image;
if ($request->hasFile('image')) {
// Delete old image if exists
if ($news->image) {
Storage::disk('public')->delete($news->image);
}
$image = $request->file('image')->store('news', 'public');
}
// Update news
$news->update([
'title' => $request->title,
'slug' => $request->slug,
'content' => $request->content,
'category' => $request->category,
'status' => $request->status,
'image' => $image,
]);
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil diperbarui!');
}
public function destroy($id)
{
$news = News::findOrFail($id);
// Delete image if exists
if ($news->image) {
Storage::disk('public')->delete($news->image);
}
$news->delete();
return redirect()->route('admin.berita.index')
->with('success', 'Berita berhasil dihapus!');
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\Pengajuan;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PengajuanController extends Controller
{
// show pengajuan
public function showPengajuan()
{
// Logic to show pengajuan
$data = Pengajuan::with('user', 'attendance')->get();
return view('admin.features.pengajuan.pengajuan', compact('data'));
}
// Detail Pengajuan
public function detailPengajuan($id)
{
// Logic to show detail pengajuan
$data = Pengajuan::with('user', 'attendance')->findOrFail($id);
return view('admin.features.pengajuan.detail-pengajuan', compact('data'));
}
// Update status pengajuan (disetujui/ditolak)
public function updateStatus(Request $request, $id)
{
$request->validate([
'aksi' => 'required|in:disetujui,ditolak',
'alasan' => 'nullable|string|max:500',
'jenis_pengajuan' => 'required_if:aksi,disetujui|in:izin,sakit',
]);
$pengajuan = Pengajuan::with('user')->findOrFail($id);
return DB::transaction(function () use ($request, $pengajuan) {
$isApproval = $request->input('aksi') === 'disetujui';
// Jika disetujui: status mengikuti input admin (izin/sakit)
// Jika ditolak: status menjadi alpha
$attendanceStatus = $isApproval ? $request->input('jenis_pengajuan') : 'alpha';
$attendance = $pengajuan->attendance;
if (!$attendance) {
$attendance = Attendance::query()->create([
'user_id' => $pengajuan->id_user,
'date' => $pengajuan->date,
'check_in' => null,
'check_out' => null,
'status' => 'alpha',
'notes' => null,
'device_info' => null,
'location' => null,
]);
}
$attendance->update([
'status' => $attendanceStatus,
'notes' => $isApproval ? ($pengajuan->keterangan ?? null) : ($request->input('alasan') ?? 'Ditolak oleh admin'),
]);
// Hubungkan attendance ke pengajuan
$pengajuan->id_attendance = $attendance->id;
$pengajuan->status = $isApproval ? 'disetujui' : 'ditolak';
$pengajuan->save();
return redirect()
->route('admin.pengajuan')
->with('success', 'Status pengajuan ' . ($isApproval ? 'disetujui' : 'ditolak') . '.');
});
}
}

View File

@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Controllers;
use Exception;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class PerangkatKontroller extends Controller
{
//show daftar perangkat
public function index()
{
$data = User::orderBy('name', 'asc')->get();
return view('admin.features.perangkat.perangkat', compact('data'));
}
// show form tambah perangkat
public function create()
{
return view('admin.features.perangkat.add-perangkat');
}
// show form edit perangkat
public function edit($id)
{
$data = User::findOrFail($id);
return view('admin.features.perangkat.edit-perangkat', compact('data'));
}
// simpan perangkat baru
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'nik' => 'required|string|unique:users,nik',
'role' => ['required', Rule::in(['admin', 'user'])],
'jabatan' => 'required|string',
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'employee_id' => 'required|string|unique:users,employee_id',
'phone' => 'required|string|max:20',
'jenis_kelamin' => 'required|in:Laki-laki,Perempuan',
'tempat_lahir' => 'required|string|max:255',
'tanggal_lahir' => 'required|date_format:d/m/Y',
'hire_date' => 'required|date_format:d/m/Y',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:6|confirmed',
'foto' => 'nullable|image|max:2048',
], [
'nik.required' => 'NIK wajib diisi.',
'nik.unique' => 'NIK sudah terdaftar.',
'role.required' => 'Role wajib dipilih.',
'role.in' => 'Role yang dipilih tidak valid.',
'jabatan.required' => 'Jabatan wajib dipilih.',
'name.required' => 'Nama wajib diisi.',
'address.required' => 'Alamat wajib diisi.',
'employee_id.required' => 'ID pegawai wajib diisi.',
'employee_id.unique' => 'ID pegawai sudah digunakan.',
'phone.required' => 'No. telepon wajib diisi.',
'jenis_kelamin.required' => 'Jenis kelamin wajib dipilih.',
'tempat_lahir.required' => 'Tempat lahir wajib diisi.',
'tanggal_lahir.required' => 'Tanggal lahir wajib diisi.',
'tanggal_lahir.date_format' => 'Format tanggal lahir harus dd/mm/yyyy.',
'hire_date.required' => 'Tanggal diterima kerja wajib diisi.',
'hire_date.date_format' => 'Format tanggal diterima kerja harus dd/mm/yyyy.',
'email.required' => 'E-mail wajib diisi.',
'email.email' => 'Format e-mail tidak valid.',
'email.unique' => 'E-mail sudah terdaftar.',
'password.required' => 'Kata sandi wajib diisi.',
'password.min' => 'Kata sandi minimal 6 karakter.',
'password.confirmed' => 'Konfirmasi kata sandi tidak cocok.',
'foto.image' => 'Foto harus berupa file gambar.',
'foto.max' => 'Ukuran foto maksimal 2MB.',
]);
if ($validator->fails()) {
return redirect()
->back()
->withErrors($validator)
->withInput();
}
try {
$validated = $validator->validated();
// 2. Konversi format tanggal ke Y-m-d
$validated['tanggal_lahir'] = Carbon::createFromFormat('d/m/Y', $validated['tanggal_lahir'])->toDateString();
$validated['hire_date'] = Carbon::createFromFormat('d/m/Y', $validated['hire_date'])->toDateString();
// 3. Hash password
$validated['password'] = Hash::make($validated['password']);
// 4. Normalisasi payload
$validated['status'] = 'aktif';
$validated['alamat'] = $validated['address'];
$validated['no_telepon'] = $validated['phone'];
// 5. Upload foto jika ada
if ($request->hasFile('foto')) {
$file = $request->file('foto');
// buat nama unik agar tidak bentrok
$filename = Str::slug($validated['name']) . '-' . time() . '.' . $file->getClientOriginalExtension();
// simpan di storage/app/public/pegawai
$path = $file->storeAs('pegawai', $filename, 'public');
// simpan path relatif ke database
$validated['url_photo'] = $path; // misal "pegawai/namafile.jpg"
}
// 6. Buat user baru
User::create($validated);
// 7. Redirect dengan pesan sukses
return redirect()
->route('admin.perangkat')
->with('success', 'Anggota perangkat desa berhasil ditambahkan.');
} catch (Exception $e) {
Log::error('Error adding perangkat desa: ' . $e->getMessage());
return redirect()
->back()
->withInput()
->with('error', 'Terjadi kesalahan saat menambahkan anggota perangkat desa.');
}
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validator = Validator::make($request->all(), [
'nik' => ['required', 'min:16', 'string', Rule::unique('users', 'nik')->ignore($user->id)],
'role' => ['required', Rule::in(['admin', 'user'])],
'jabatan' => 'required|string',
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'employee_id' => ['required', 'string', Rule::unique('users', 'employee_id')->ignore($user->id)],
'phone' => 'required|string|max:20',
'jenis_kelamin' => ['required', Rule::in(['Laki-laki', 'Perempuan'])],
'tempat_lahir' => 'required|string|max:255',
'tanggal_lahir' => 'required|date_format:d/m/Y',
'hire_date' => 'required|date_format:d/m/Y',
'email' => ['required', 'email', Rule::unique('users', 'email')->ignore($user->id)],
'password' => ['nullable', 'string', 'min:6', 'confirmed'],
'foto' => 'nullable|image|max:2048',
], [
'nik.required' => 'NIK wajib diisi.',
'nik.unique' => 'NIK sudah terdaftar.',
'nik.min' => 'NIK harus 16 digit',
'role.required' => 'Role wajib dipilih.',
'role.in' => 'Role yang dipilih tidak valid.',
'jabatan.required' => 'Jabatan wajib dipilih.',
'name.required' => 'Nama wajib diisi.',
'address.required' => 'Alamat wajib diisi.',
'employee_id.required' => 'ID pegawai wajib diisi.',
'employee_id.unique' => 'ID pegawai sudah digunakan.',
'phone.required' => 'No. telepon wajib diisi.',
'jenis_kelamin.required' => 'Jenis kelamin wajib dipilih.',
'tempat_lahir.required' => 'Tempat lahir wajib diisi.',
'tanggal_lahir.required' => 'Tanggal lahir wajib diisi.',
'tanggal_lahir.date_format' => 'Format tanggal lahir harus dd/mm/yyyy.',
'hire_date.required' => 'Tanggal diterima kerja wajib diisi.',
'hire_date.date_format' => 'Format tanggal diterima kerja harus dd/mm/yyyy.',
'email.required' => 'E-mail wajib diisi.',
'email.email' => 'Format e-mail tidak valid.',
'email.unique' => 'E-mail sudah terdaftar.',
'password.min' => 'Kata sandi minimal 6 karakter.',
'password.confirmed' => 'Konfirmasi kata sandi tidak cocok.',
'foto.image' => 'Foto harus berupa file gambar.',
'foto.max' => 'Ukuran foto maksimal 2MB.',
]);
if ($validator->fails()) {
return redirect()
->back()
->withErrors($validator)
->withInput()
;
}
try {
$validated = $validator->validated();
// 2) Siapkan payload update
$payload = $validated;
// Konversi tanggal ke Y-m-d
$payload['tanggal_lahir'] = Carbon::createFromFormat('d/m/Y', $validated['tanggal_lahir'])->toDateString();
$payload['hire_date'] = Carbon::createFromFormat('d/m/Y', $validated['hire_date'])->toDateString();
$payload['role'] = $validated['role'];
$payload['jabatan'] = $validated['jabatan'];
$payload['alamat'] = $validated['address'];
$payload['no_telepon'] = $validated['phone'];
// Password hanya jika diisi
if (!empty($validated['password'])) {
$payload['password'] = Hash::make($validated['password']);
} else {
unset($payload['password']);
}
// Handle foto baru (hapus foto lama bila ada)
if ($request->hasFile('foto')) {
$file = $request->file('foto');
$filename = Str::slug($validated['name'] ?? $user->name) . '-' . time() . '.' . $file->getClientOriginalExtension();
$path = $file->storeAs('pegawai', $filename, 'public');
if (!empty($user->url_photo)) {
Storage::disk('public')->delete($user->url_photo);
}
$payload['url_photo'] = $path;
}
// 'foto' bukan kolom tabel
unset($payload['foto']);
// 3) Update
$user->update($payload);
// 4) Redirect
return redirect()
->route('admin.perangkat')
->with('success', 'Data anggota perangkat desa berhasil diperbarui.');
} catch (Exception $e) {
Log::error('Error updating perangkat desa: ' . $e->getMessage());
return redirect()
->back()
->withInput()
->with('error', 'Terjadi kesalahan saat memperbarui anggota perangkat desa.');
}
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class EmployeeController extends Controller
{
/**
* Display a listing of employees.
*/
public function index()
{
$employees = User::where('status', 'aktif')
->orderBy('name')
->get();
return response()->json([
'success' => true,
'data' => $employees
]);
}
/**
* Store a newly created employee.
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'role' => 'required|string|max:255',
'phone' => 'required|string|max:20',
'employee_id' => 'required|string|max:50|unique:users',
'address' => 'nullable|string',
'hire_date' => 'nullable|date',
'password' => 'required|string|min:8',
]);
$employee = User::create([
'name' => $request->name,
'email' => $request->email,
'role' => $request->role,
'phone' => $request->phone,
'employee_id' => $request->employee_id,
'address' => $request->address,
'hire_date' => $request->hire_date,
'password' => Hash::make($request->password),
'status' => 'aktif',
]);
return response()->json([
'success' => true,
'message' => 'Employee created successfully',
'data' => $employee
], 201);
}
/**
* Display the specified employee.
*/
public function show(User $employee)
{
return response()->json([
'success' => true,
'data' => $employee
]);
}
/**
* Update the specified employee.
*/
public function update(Request $request, User $employee)
{
$request->validate([
'name' => 'required|string|max:255',
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($employee->id)],
'role' => 'required|string|max:255',
'phone' => 'required|string|max:20',
'employee_id' => ['required', 'string', 'max:50', Rule::unique('users')->ignore($employee->id)],
'address' => 'nullable|string',
'hire_date' => 'nullable|date',
'status' => 'required|in:aktif,tidak aktif',
]);
$employee->update($request->only([
'name',
'email',
'role',
'phone',
'employee_id',
'address',
'hire_date',
'status'
]));
return response()->json([
'success' => true,
'message' => 'Employee updated successfully',
'data' => $employee
]);
}
/**
* Remove the specified employee.
*/
public function destroy(User $employee)
{
// Soft delete by setting status to tidak aktif
$employee->update(['status' => 'tidak aktif']);
return response()->json([
'success' => true,
'message' => 'Employee deactivated successfully'
]);
}
/**
* Get employee attendance history.
*/
public function attendanceHistory(User $employee, Request $request)
{
$request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
$query = $employee->attendances()->orderBy('date', 'desc');
if ($request->start_date) {
$query->whereDate('date', '>=', $request->start_date);
}
if ($request->end_date) {
$query->whereDate('date', '<=', $request->end_date);
}
$attendances = $query->paginate(30);
return response()->json([
'success' => true,
'data' => $attendances
]);
}
}

View File

@ -0,0 +1,379 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Events\AttendanceUpdated;
use App\Events\QrTokenIssued;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\AttendanceSetting;
use App\Models\User;
use App\Services\DynamicQrService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AbsenController extends Controller
{
private function qrTtl(): int
{
return 60;
}
public function verify(Request $req, DynamicQrService $svc)
{
$req->validate([
'token' => 'required|string',
'user_id' => 'required|integer',
'device_info' => 'nullable|string',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]);
$lat = $req->input('latitude');
$lng = $req->input('longitude');
$location = (is_numeric($lat) && is_numeric($lng))
? sprintf('%.6f,%.6f', $lat, $lng)
: null;
$check = $svc->verifyShortToken($req->token);
if (!$check['ok']) {
// Token invalid/expired - tetap generate token baru untuk keamanan
$payload = $check['payload'] ?? null;
$sessionId = $payload ? (int) ($payload['session_id'] ?? 0) : 0;
if ($sessionId > 0) {
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
}
return response()->json(['ok' => false, 'reason' => $check['reason']], 422);
}
$payload = $check['payload'];
$sessionId = (int) ($payload['session_id'] ?? 0);
$nonce = $payload['nonce'];
$ttlRemaining = max(1, (int)($payload['exp'] - now()->timestamp));
if (!Cache::add("used_nonce:$nonce", 1, $ttlRemaining)) {
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => false, 'reason' => 'replayed'], 409);
}
// Simpan data absensi ke database
$userId = $req->integer('user_id');
$user = User::find($userId);
if (!$user) {
return response()->json(['ok' => false, 'reason' => 'User not found'], 404);
}
$setting = AttendanceSetting::query()->first();
if (!$setting) {
return response()->json([
'ok' => false,
'reason' => 'attendance_setting_not_found',
'message' => 'Pengaturan absensi belum tersedia. Jalankan seeder terlebih dahulu.',
], 500);
}
$timezone = $setting->timezone ?: config('app.timezone', 'Asia/Jakarta');
$now = Carbon::now($timezone);
$today = $now->toDateString();
$officeLat = $setting->office_latitude;
$officeLng = $setting->office_longitude;
$radiusMeters = (int) ($setting->attendance_radius_meters ?? 0);
if ($officeLat !== null && $officeLng !== null && $radiusMeters > 0) {
if (!is_numeric($lat) || !is_numeric($lng)) {
return response()->json([
'ok' => false,
'reason' => 'location_required',
'message' => 'Lokasi wajib dikirim untuk melakukan absensi.',
], 422);
}
$distanceMeters = $this->distanceInMeters(
(float) $officeLat,
(float) $officeLng,
(float) $lat,
(float) $lng
);
if ($distanceMeters > $radiusMeters) {
return response()->json([
'ok' => false,
'reason' => 'outside_attendance_radius',
'message' => 'Anda berada di luar radius absensi yang diizinkan.',
'meta' => [
'distance_meters' => round($distanceMeters, 2),
'allowed_radius_meters' => $radiusMeters,
],
], 422);
}
}
$effectiveWorkdays = collect($setting->effective_workdays ?? [])
->map(fn($day) => (int) $day)
->unique()
->values();
if ($effectiveWorkdays->isNotEmpty() && !$effectiveWorkdays->contains($now->isoWeekday())) {
return response()->json([
'ok' => false,
'reason' => 'not_effective_workday',
'message' => 'Hari ini bukan hari kerja efektif untuk absensi.',
], 422);
}
// Cek apakah sudah absen hari ini
$existingAttendance = Attendance::where('user_id', $userId)
->whereDate('date', $today)
->first();
$checkoutStart = $this->todayAt($setting->checkout_start, $today, $timezone);
if ($existingAttendance) {
// Update check_out jika hari ini belum punya check_out
if (!$existingAttendance->check_out) {
if ($now->lt($checkoutStart)) {
return response()->json([
'ok' => false,
'reason' => 'too_early_checkout',
'message' => 'Belum masuk jam check-out.',
], 422);
}
$isCheckoutOnly = !$existingAttendance->check_in;
$notes = $existingAttendance->notes;
if ($isCheckoutOnly) {
$checkoutOnlyNote = 'Check-out saja: check-in tidak tercatat.';
$notes = $notes
? (str_contains($notes, $checkoutOnlyNote) ? $notes : "{$notes} | {$checkoutOnlyNote}")
: $checkoutOnlyNote;
}
$existingAttendance->update([
'check_out' => $now->format('H:i:s'),
'status' => $isCheckoutOnly ? 'hadir' : $existingAttendance->status,
'notes' => $notes,
'device_info' => $req->string('device_info'),
'location' => $location ?? $existingAttendance->location,
]);
Log::info('ATTENDANCE_CHECKOUT', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_out' => $now->toIso8601String(),
]);
// Broadcast attendance update
try {
broadcast(new AttendanceUpdated(
$userId,
$existingAttendance->status,
$existingAttendance->check_in?->toIso8601String(),
$now->toIso8601String()
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkout): ' . $e->getMessage());
}
} else {
return response()->json(['ok' => false, 'reason' => 'Already attended today'], 409);
}
} else {
if ($now->gte($checkoutStart)) {
$attendance = Attendance::create([
'user_id' => $userId,
'date' => $today,
'check_in' => null,
'check_out' => $now->format('H:i:s'),
'status' => 'hadir',
'lates_minutes' => null,
'notes' => 'Check-out saja: check-in tidak tercatat.',
'device_info' => $req->string('device_info'),
'location' => $location,
]);
Log::info('ATTENDANCE_CHECKOUT_ONLY', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_out' => $now->toIso8601String(),
'attendance_id' => $attendance->id,
]);
try {
broadcast(new AttendanceUpdated(
$userId,
'hadir',
null,
$now->toIso8601String()
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkout-only): ' . $e->getMessage());
}
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => true, 'session_id' => $sessionId, 'mode' => 'checkout_only']);
}
$checkinStart = $this->todayAt($setting->checkin_start, $today, $timezone);
$checkinEnd = $this->todayAt($setting->checkin_end, $today, $timezone);
$lateGraceMinutes = max(0, (int) ($setting->late_grace_minutes ?? 0));
$lateLimit = $checkinEnd->copy()->addMinutes($lateGraceMinutes);
if ($now->lt($checkinStart)) {
return response()->json([
'ok' => false,
'reason' => 'too_early_checkin',
'message' => 'Belum masuk jam check-in.',
], 422);
}
if (!$setting->allow_checkin_after_end && $now->gt($lateLimit)) {
return response()->json([
'ok' => false,
'reason' => 'checkin_closed',
'message' => 'Jam check-in sudah ditutup.',
], 422);
}
$lateDiff = max(0, $checkinEnd->diffInMinutes($now, false));
$lateMinutes = $lateDiff > $lateGraceMinutes ? (string) ($lateDiff - $lateGraceMinutes) : null;
// Buat record absensi baru
$attendance = Attendance::create([
'user_id' => $userId,
'date' => $today,
'check_in' => $now->format('H:i:s'),
'status' => 'hadir',
'lates_minutes' => $lateMinutes,
'notes' => $lateMinutes ? "Terlambat {$lateMinutes} menit" : null,
'device_info' => $req->string('device_info'),
'location' => $location,
]);
Log::info('ATTENDANCE_CHECKIN', [
'user_id' => $userId,
'user_name' => $user->name,
'session_id' => $sessionId,
'check_in' => $now->toIso8601String(),
'lates_minutes' => $lateMinutes,
]);
// Broadcast attendance update
try {
broadcast(new AttendanceUpdated(
$userId,
'hadir',
$now->toIso8601String(),
null
));
} catch (\Throwable $e) {
Log::warning('Attendance broadcast failed (checkin): ' . $e->getMessage());
}
}
// berhasil → SELALU ganti token baru
$this->generateNewToken($svc, $sessionId, $this->qrTtl());
return response()->json(['ok' => true, 'session_id' => $sessionId]);
}
public function daily(Request $req)
{
$req->validate([
'user_id' => 'required|integer|exists:users,id',
'date' => 'nullable|date_format:Y-m-d',
]);
$userId = (int) $req->user_id;
$date = $req->filled('date')
? Carbon::createFromFormat('Y-m-d', $req->date)->toDateString()
: now()->toDateString();
$attendance = Attendance::where('user_id', $userId)
->whereDate('date', $date)
->first();
if (!$attendance) {
return response()->json([
'ok' => true,
'data' => null,
'meta' => [
'user_id' => $userId,
'date' => $date,
'message' => 'Belum ada absensi pada tanggal ini',
],
]);
}
return response()->json([
'ok' => true,
'data' => [
'id' => $attendance->id,
'user_id' => $attendance->user_id,
'date' => $attendance->date, // Y-m-d
'check_in' => $attendance->check_in, // HH:MM:SS / null
'check_out' => $attendance->check_out, // HH:MM:SS / null
'status' => $attendance->status,
'lates_minutes' => $attendance->lates_minutes,
'notes' => $attendance->notes,
'device_info' => $attendance->device_info,
'location' => $attendance->location,
'created_at' => $attendance->created_at?->toIso8601String(),
'updated_at' => $attendance->updated_at?->toIso8601String(),
],
'meta' => [
'user_id' => $userId,
'date' => $date,
],
]);
}
private function generateNewToken(DynamicQrService $svc, int $sessionId, int $ttl): array
{
$data = $svc->issueShortToken($sessionId, $ttl);
Cache::put("qr:session:$sessionId:aktif", $data, $ttl);
try {
broadcast(new QrTokenIssued(
$sessionId,
$data['token'],
$data['payload']['exp'],
$ttl
));
} catch (\Throwable $e) {
Log::warning('QR broadcast failed (generateNewToken): ' . $e->getMessage(), [
'session_id' => $sessionId,
]);
}
return $data;
}
private function todayAt(?string $time, string $date, string $timezone): Carbon
{
$value = (string) ($time ?: '00:00:00');
if (strlen($value) === 5) {
$value .= ':00';
}
return Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$value}", $timezone);
}
private function distanceInMeters(float $lat1, float $lng1, float $lat2, float $lng2): float
{
$earthRadius = 6371000;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) ** 2
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
return 2 * $earthRadius * asin(min(1, sqrt($a)));
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class IzinController extends Controller
{
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'user_id' => 'required|integer|exists:users,id',
'tanggal' => 'required|date',
'jenis' => 'required|in:Izin,Sakit,Cuti',
'keterangan' => 'required|string',
'bukti' => 'required|image|mimes:jpg,jpeg,png|max:2048'
]);
if ($validator->fails()) {
return response()->json([
'ok' => false,
'message' => $validator->errors()->first()
], 422);
}
try {
DB::beginTransaction();
// ==========================
// 1⃣ Upload bukti
// ==========================
$file = $request->file('bukti');
$path = $file->store('bukti_izin', 'public');
// ==========================
// 2⃣ INSERT KE ATTENDANCES DULU
// ==========================
$attendanceId = DB::table('attendances')->insertGetId([
'user_id' => $request->user_id,
'date' => $request->tanggal,
'check_in' => null,
'check_out' => null,
'status' => strtolower($request->jenis), // izin/sakit/cuti
'lates_minutes' => null,
'notes' => null,
'device_info' => null,
'location' => null,
'created_at' => now(),
'updated_at' => now(),
]);
// ==========================
// 3⃣ INSERT KE PENGAJUAN
// ==========================
$pengajuanId = DB::table('pengajuan')->insertGetId([
'id_user' => $request->user_id,
'id_attendance' => $attendanceId, // 🔥 relasi ke attendance
'date' => $request->tanggal,
'jenis' => $request->jenis,
'keterangan' => $request->keterangan,
'url_bukti' => $path,
'status' => 'diajukan',
'created_at' => now(),
'updated_at' => now(),
]);
DB::commit();
return response()->json([
'ok' => true,
'message' => 'Pengajuan berhasil dikirim',
'data' => [
'attendance_id' => $attendanceId,
'pengajuan_id' => $pengajuanId,
'status' => 'diproses'
]
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'ok' => false,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
public function validasi(Request $request, $id)
{
$validator = Validator::make($request->all(), [
'status' => 'required|in:diterima,ditolak'
]);
if ($validator->fails()) {
return response()->json([
'ok' => false,
'message' => $validator->errors()->first()
], 422);
}
$attendance = DB::table('attendances')->where('id', $id)->first();
if (!$attendance) {
return response()->json([
'ok' => false,
'message' => 'Data tidak ditemukan'
], 404);
}
DB::table('attendances')
->where('id', $id)
->update([
'status' => $request->status,
'updated_at' => now()
]);
return response()->json([
'ok' => true,
'message' => 'Status berhasil diperbarui menjadi ' . $request->status
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use App\Mail\OtpMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
class OtpController extends Controller
{
public function sendOtp(Request $request)
{
$request->validate([
'email' => 'required|email'
]);
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['message' => 'Email tidak terdaftar'], 404);
}
// OTP 5 digit string (bisa ada nol di depan)
$otp = str_pad(rand(0, 99999), 5, '0', STR_PAD_LEFT);
$user->otp = $otp;
$user->otp_expires_at = now()->addMinutes(10);
$user->save();
\Log::info("OTP DB: $otp");
try {
Mail::to($user->email)->send(new OtpMail($otp));
} catch (\Exception $e) {
\Log::error($e->getMessage());
return response()->json(['message' => 'Gagal kirim OTP'], 500);
}
return response()->json(['message' => 'OTP dikirim'], 200);
}
public function verifyOtp(Request $request)
{
$request->validate([
'email' => 'required|email',
'otp' => 'required'
]);
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['message' => 'Email tidak terdaftar'], 404);
}
// DEBUG LOG
\Log::info("INPUT OTP: ".$request->otp);
\Log::info("DB OTP: ".$user->otp);
\Log::info("EXPIRED AT: ".$user->otp_expires_at);
if (!$user->otp || !$user->otp_expires_at) {
return response()->json(['message' => 'OTP belum dibuat'], 400);
}
if (now()->greaterThan($user->otp_expires_at)) {
return response()->json(['message' => 'OTP kedaluwarsa'], 400);
}
// 🔥 PENTING: SAMAKAN TIPE JADI STRING
if ((string)$user->otp !== (string)$request->otp) {
return response()->json(['message' => 'OTP salah'], 400);
}
$user->otp = null;
$user->otp_expires_at = null;
$user->save();
return response()->json(['message' => 'OTP valid'], 200);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\DB;
class RiwayatController extends Controller
{
public function index($userId)
{
// ========================
// DATA ABSENSI
// ========================
$absensi = DB::table('attendances')
->where('user_id', $userId)
->select(
DB::raw("
CASE
WHEN check_in IS NOT NULL THEN 'Hadir'
ELSE 'Tidak Hadir'
END as jenis
"),
DB::raw("DATE_FORMAT(date, '%d %b %Y') as tanggal"),
DB::raw("
CONCAT(
IFNULL(TIME_FORMAT(check_in, '%H:%i'), '-'),
' - ',
IFNULL(TIME_FORMAT(check_out, '%H:%i'), '-')
) as jam
"),
DB::raw("'hadir' as icon"),
DB::raw("status as status_validasi"),
'date as sort_date'
);
// ========================
// DATA PENGAJUAN (IZIN / SAKIT / DLL)
// ========================
$pengajuan = DB::table('pengajuan')
->where('id_user', $userId)
->select(
'jenis',
DB::raw("DATE_FORMAT(date, '%d %b %Y') as tanggal"),
DB::raw("'-' as jam"),
DB::raw("'izin' as icon"),
'status as status_validasi',
'date as sort_date'
);
// ========================
// UNION DATA
// ========================
$riwayat = $absensi
->unionAll($pengajuan)
->orderBy('sort_date', 'desc')
->get();
return response()->json([
'ok' => true,
'data' => $riwayat
]);
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Mail;
use App\Mail\OtpMail;
class UserController extends Controller
{
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'ok' => false,
'message' => 'Validasi gagal',
'errors' => $validator->errors()
], 422);
}
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'ok' => false,
'message' => 'Email atau password salah'
], 401);
}
// Optional: buat token sanctum jika pakai
$token = $user->createToken('mobile-token')->plainTextToken ?? null;
return response()->json([
'ok' => true,
'message' => 'Login berhasil',
'token' => $token,
'data' => [
'id' => $user->id,
'nik' => $user->nik,
'name' => $user->name,
'tempat_lahir' => $user->tempat_lahir,
'tanggal_lahir' => optional($user->tanggal_lahir)->toDateString(),
'email' => $user->email,
'role' => $user->role,
'jabatan' => $user->jabatan,
'phone' => $user->phone,
'no_telepon' => $user->no_telepon,
'employee_id' => $user->employee_id,
'status' => $user->status,
'address' => $user->address,
'alamat' => $user->alamat,
'hire_date' => optional($user->hire_date)->toDateString(),
'jenis_kelamin' => $user->jenis_kelamin,
'url_photo' => $user->url_photo,
]
]);
}
public function lupaKataSandi(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|string|email|max:255',
]);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first()], 422);
}
$type = $request->input('type');
if ($type == 'email') {
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json([
'status' => false,
'message' => 'Email tidak terdaftar'
], 404);
}
// Generate OTP (6-digit random number)
$otp = rand(10000, 99999);
// Save OTP to the user record
$user->otp = $otp;
$user->otp_expires_at = now()->addMinutes(10); // OTP valid for 10 minutes
$user->save();
// Send OTP via email
try {
Mail::to($user->email)->send(new OtpMail($otp));
\Log::info("OTP email sent to {$user->email}");
} catch (\Exception $e) {
\Log::error('Gagal mengirim email: ' . $e->getMessage());
return response()->json(['error' => 'Gagal mengirim email OTP'], 500);
}
return response()->json([
'status' => true,
'message' => 'Kode OTP telah dikirim ke email Anda',
], 200);
} elseif ($type == 'password') {
$validator = Validator::make($request->all(), [
'email' => 'required|string|email|max:255',
'otp' => 'required|integer',
'password' => 'required|string|min:8|confirmed',
]);
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first()], 422);
}
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['error' => 'Email tidak terdaftar'], 404);
}
// Verify OTP
if ($user->otp !== (int) $request->otp || now()->greaterThan($user->otp_expires_at)) {
return response()->json(['error' => 'Kode OTP tidak valid atau telah kedaluwarsa'], 400);
}
// Update password
$user->password = bcrypt($request->password);
$user->otp = null; // Clear OTP after successful password reset
$user->otp_expires_at = null;
$user->save();
return response()->json([
'status' => 'success',
'message' => 'Password berhasil diperbarui',
], 200);
} else {
return response()->json(['error' => 'Tipe tidak valid'], 400);
}
}
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
'password' => 'required|min:6|confirmed',
]);
if ($validator->fails()) {
return response()->json([
'status' => false,
'message' => $validator->errors()->first()
], 422);
}
try {
$user = User::where('email', $request->email)->first();
$user->password = Hash::make($request->password);
$user->save();
return response()->json([
'status' => true,
'message' => 'Password berhasil dibuat'
], 200);
} catch (\Exception $e) {
return response()->json([
'status' => false,
'message' => 'Gagal membuat password'
], 500);
}
}
public function riwayatHariIni(Request $request)
{
$userId = $request->user_id;
$date = $request->date ?? now()->toDateString();
$absen = Absensi::where('user_id', $userId)
->whereDate('tanggal', $date)
->first();
return response()->json([
'ok' => true,
'data' => $absen ? [
'check_in' => $absen->check_in,
'check_out' => $absen->check_out,
] : null
]);
}
public function getUser($id)
{
$user = User::find($id);
if (!$user) {
return response()->json([
'ok' => false,
'message' => 'User tidak ditemukan'
]);
}
return response()->json([
'ok' => true,
'data' => [
'id' => $user->id,
'nama' => $user->nama,
'foto' => $user->foto
? url('storage/pegawai/' . $user->foto)
: null,
]
]);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\News;
use Exception;
use Illuminate\Http\Request;
class NewsController extends Controller
{
public function index(Request $request)
{
$category = $request->get('category', 'all');
$query = News::published()->orderBy('created_at', 'desc');
if ($category !== 'all') {
$query->byCategory($category);
}
$news = $query->paginate(6);
$categories = News::published()
->select('category')
->distinct()
->pluck('category');
// Handle AJAX requests
if ($request->ajax() || $request->has('ajax')) {
try {
$html = view('landing.components.news-grid', compact('news'))->render();
return response()->json([
'html' => $html,
'hasMore' => $news->hasMorePages(),
'currentPage' => $news->currentPage(),
'lastPage' => $news->lastPage(),
'debug' => [
'category' => $category,
'count' => $news->count(),
'total' => $news->total()
]
]);
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage(),
'debug' => [
'category' => $category,
'count' => $news->count(),
'total' => $news->total()
]
], 500);
}
}
return view('landing.components.news-list', compact('news', 'categories', 'category'));
}
public function show($slug)
{
$news = News::published()->where('slug', $slug)->firstOrFail();
// Increment view count
$news->incrementViews();
// Get related news from same category
$relatedNews = News::published()
->where('category', $news->category)
->where('id', '!=', $news->id)
->orderBy('created_at', 'desc')
->limit(3)
->get();
return view('landing.components.detail-information', compact('news', 'relatedNews'));
}
public function getCategories()
{
$categories = News::published()
->select('category')
->distinct()
->pluck('category');
return response()->json($categories);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureRole
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, ...$roles)
{
$user = Auth::user();
if (!$user) {
return redirect()->route('login');
}
$current = mb_strtolower($user->role ?? '');
$allowed = array_map('mb_strtolower', $roles);
if (!in_array($current, $allowed, true)) {
abort(403, 'Anda tidak memiliki izin untuk mengakses halaman ini.');
}
return $next($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
'api/attendance/verify',
'api/attendance/sessions/*/rotate',
];
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ForgotPasswordMail extends Mailable
{
use Queueable, SerializesModels;
public string $url;
public ?string $userName;
public function __construct(string $url, ?string $userName = null)
{
$this->url = $url;
$this->userName = $userName;
}
public function build()
{
$app = config('app.name');
$expires = config('auth.passwords.' . config('auth.defaults.passwords') . '.expire');
$logoName = 'logo_sidakdesa'; // Content-ID yang kita tentukan sendiri
$logoPath = public_path('assets/images/sidak-desa.jpg');
$hasLogo = is_file($logoPath);
if ($hasLogo) {
$this->withSymfonyMessage(function ($message) use ($logoPath, $logoName) {
// Pastikan content-type benar (jpeg/png sesuai filemu)
$message->embedFromPath($logoPath, $logoName, 'image/jpeg');
});
}
// Fallback URL (untuk produksi HARUS https & publik)
$logoUrl = asset('assets/images/sidak-desa.jpg');
return $this->subject("Reset Kata Sandi - {$app}")
->view('emails.forgot-password', [
'url' => $this->url,
'userName' => $this->userName,
'appName' => $app,
'expires' => $expires,
'hasLogo' => $hasLogo,
'logoSrc' => $hasLogo ? "cid:{$logoName}" : $logoUrl, // <-- langsung bentuk "cid:..."
'brand' => [
'primary' => '#1f7a8c',
'text' => '#2b2b2b',
'muted' => '#6b7280',
'bg' => '#edf2f7',
'card' => '#ffffff',
],
]);
}
}

74
app/Models/Attendance.php Normal file
View File

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Attendance extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'user_id',
'date',
'check_in',
'check_out',
'status',
'lates_minutes',
'notes',
'device_info',
'location',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
'check_in' => 'datetime',
'check_out' => 'datetime',
];
}
/**
* Get the user that owns the attendance.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get pengajuans linked to this attendance.
*/
public function pengajuan()
{
return $this->hasOne(Pengajuan::class, 'id_attendance');
}
/**
* Scope a query to only include today's attendance.
*/
public function scopeToday($query)
{
return $query->whereDate('date', today());
}
/**
* Scope a query to only include attendance for a specific date.
*/
public function scopeForDate($query, $date)
{
return $query->whereDate('date', $date);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AttendanceSetting extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'checkin_start',
'checkin_end',
'checkout_start',
'late_grace_minutes',
'effective_workdays',
'timezone',
'allow_checkin_after_end',
'require_checkout',
'office_latitude',
'office_longitude',
'attendance_radius_meters',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'late_grace_minutes' => 'integer',
'effective_workdays' => 'array',
'allow_checkin_after_end' => 'boolean',
'require_checkout' => 'boolean',
'office_latitude' => 'float',
'office_longitude' => 'float',
'attendance_radius_meters' => 'integer',
];
}
}

60
app/Models/News.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class News extends Model
{
use HasFactory;
protected $fillable = [
'title',
'slug',
'content',
'category',
'image',
'status',
'views',
];
protected $casts = [
'views' => 'integer',
];
protected static function boot()
{
parent::boot();
static::creating(function ($news) {
if (empty($news->slug)) {
$news->slug = Str::slug($news->title);
}
});
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeByCategory($query, $category)
{
return $query->where('category', $category);
}
public function incrementViews()
{
$this->increment('views');
}
public function getImageUrlAttribute()
{
if ($this->image) {
return asset('storage/' . $this->image);
}
return null;
}
}

60
app/Models/Pengajuan.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Pengajuan extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'pengajuan';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'id_user',
'id_attendance',
'date',
'keterangan',
'url_bukti',
'status',
];
/**
* The attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'date' => 'date',
];
}
/**
* Get the user that owns this pengajuan.
*/
public function user()
{
return $this->belongsTo(User::class, 'id_user');
}
/**
* Get the attendance associated with this pengajuan.
*/
public function attendance()
{
return $this->belongsTo(Attendance::class, 'id_attendance');
}
}

67
app/Models/User.php Normal file
View File

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; // ✅ TAMBAHKAN INI
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable; // ✅ TAMBAHKAN HasApiTokens
protected $fillable = [
'name',
'nik',
'email',
'password',
'device_id',
'role',
'jabatan',
'phone',
'no_telepon',
'employee_id',
'status',
'jenis_kelamin',
'tempat_lahir',
'tanggal_lahir',
'address',
'alamat',
'url_photo',
'hire_date',
'otp',
'otp_expires_at',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'hire_date' => 'date',
'tanggal_lahir' => 'date',
'otp_expires_at' => 'datetime',
];
}
public function attendances()
{
return $this->hasMany(Attendance::class);
}
public function pengajuans()
{
return $this->hasMany(Pengajuan::class, 'id_user');
}
public function todayAttendance()
{
return $this->attendances()->whereDate('date', today())->first();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VillageProfile extends Model
{
//
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Register broadcast routes
Broadcast::routes();
Paginator::useBootstrapFive();
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
class DynamicQrService
{
public function issueShortToken(int $sessionId, int $ttlSeconds = 15): array
{
$nonce = strtoupper(Str::random(32)); // contoh: "A1B2C3D4"
$payload = [
'session_id' => $sessionId,
'nonce' => $nonce,
'exp' => now()->addSeconds($ttlSeconds)->timestamp,
'iat' => now()->timestamp,
];
// Simpan payload di cache
Cache::put("qr_payload:$nonce", $payload, $ttlSeconds);
return [
'token' => $nonce,
'payload' => $payload
];
}
public function verifyShortToken(string $nonce): array
{
$payload = Cache::get("qr_payload:$nonce");
if (!$payload) {
return ['ok' => false, 'reason' => 'invalid_or_expired'];
}
if (now()->timestamp > ($payload['exp'] ?? 0)) {
return ['ok' => false, 'reason' => 'expired', 'payload' => $payload];
}
return ['ok' => true, 'payload' => $payload];
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

71
attendance.json Normal file
View File

@ -0,0 +1,71 @@
{
"info": {
"name": "SIDAKDesa Attendance API",
"_postman_id": "12345678-abcd-efgh-ijkl-1234567890ab",
"description": "Koleksi API untuk menguji absensi SIDAKDesa",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Get QR Token (session 1)",
"request": {
"method": "GET",
"url": {
"raw": "http://127.0.0.1:8000/api/attendance/sessions/1/qrcode",
"protocol": "http",
"host": ["127", "0", "0", "1"],
"port": "8000",
"path": ["api", "attendance", "sessions", "1", "qrcode"]
}
}
},
{
"name": "Verify Attendance",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"token\": \"{{token}}\",\n \"user_id\": {{user_id}},\n \"device_info\": \"Chrome on Windows\"\n}"
},
"url": {
"raw": "http://127.0.0.1:8000/api/attendance/verify",
"protocol": "http",
"host": ["127", "0", "0", "1"],
"port": "8000",
"path": ["api", "attendance", "verify"]
}
}
},
{
"name": "Today Attendance",
"request": {
"method": "GET",
"url": {
"raw": "http://127.0.0.1:8000/api/attendance/today",
"protocol": "http",
"host": ["127", "0", "0", "1"],
"port": "8000",
"path": ["api", "attendance", "today"]
}
}
},
{
"name": "Force Rotate Token (session 1)",
"request": {
"method": "POST",
"url": {
"raw": "http://127.0.0.1:8000/api/attendance/sessions/1/rotate",
"protocol": "http",
"host": ["127", "0", "0", "1"],
"port": "8000",
"path": ["api", "attendance", "sessions", "1", "rotate"]
}
}
}
],
"variable": [
{ "key": "token", "value": "" },
{ "key": "user_id", "value": "3" }
]
}

25
bootstrap/app.php Normal file
View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
channels: __DIR__ . '/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'role' => \App\Http\Middleware\EnsureRole::class,
// alias lain bawaan/Anda sendiri boleh tetap di sini
// 'auth' => \App\Http\Middleware\Authenticate::class, // biasanya sudah ada
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

5
bootstrap/providers.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

82
composer.json Normal file
View File

@ -0,0 +1,82 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"doctrine/dbal": "^4.3",
"laravel/framework": "^12.0",
"laravel/reverb": "^1.5",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"mews/purifier": "^3.4",
"pusher/pusher-php-server": "^7.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.13",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"@php artisan config:clear",
"@php artisan clear-compiled",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9949
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'Asia/Jakarta',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View File

@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

83
config/broadcasting.php Normal file
View File

@ -0,0 +1,83 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "pusher", "ably", "reverb", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_DRIVER', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over websockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
// Pakai cluster hosted Pusher (contoh: ap1)
'cluster' => env('PUSHER_APP_CLUSTER'),
// Force TLS utk hosted Pusher
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
// Opsi custom host/port (kosongkan jika pakai hosted Pusher)
'host' => env('PUSHER_HOST'),
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST', 'localhost'),
'port' => env('REVERB_PORT', 8080),
'scheme' => env('REVERB_SCHEME', 'http'),
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

108
config/cache.php Normal file
View File

@ -0,0 +1,108 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'file'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

174
config/database.php Normal file
View File

@ -0,0 +1,174 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'sidak_desa'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

112
config/queue.php Normal file
View File

@ -0,0 +1,112 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

94
config/reverb.php Normal file
View File

@ -0,0 +1,94 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['http://localhost', 'http://127.0.0.1:8000', '*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
],
],
],
];

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

38
config/services.php Normal file
View File

@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('device_id')->nullable();
$table->enum('role', ['admin', 'user'])->default('user');
$table->enum('jabatan', [
'Kepala Desa',
'Sekretaris',
'Kasi 1',
'Kasi 2',
'Kasi 3',
'Kaur 1',
'Kaur 2',
'Kadus 1',
'Staf',
])->default('Staf');
$table->string('otp')->nullable();
$table->timestamp('otp_expires_at')->nullable();
$table->string('phone', 255)->nullable();
$table->string('employee_id')->nullable();
$table->enum('status', ['aktif', 'tidak aktif'])->default('aktif');
$table->text('address')->nullable();
$table->date('hire_date')->nullable();
$table->string('nik', 16)->unique();
$table->string('tempat_lahir', 255);
$table->date('tanggal_lahir');
$table->string('alamat', 500)->nullable();
$table->string('no_telepon', 13)->nullable();
$table->enum('jenis_kelamin', ['Laki-laki', 'Perempuan']);
$table->string('url_photo', 1000)->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->time('check_in')->nullable();
$table->time('check_out')->nullable();
$table->enum('status', ['hadir', 'izin', 'sakit', 'alpha'])->default('hadir');
$table->string('lates_minutes')->nullable(); // Menit keterlambatan (jika ada)
$table->text('notes')->nullable(); // Catatan tambahan
$table->string('device_info')->nullable(); // Info device saat scan QR
$table->string('location')->nullable(); // Lokasi absensi (opsional)
$table->timestamps();
// Index untuk optimasi query
$table->index(['user_id', 'date']);
$table->unique(['user_id', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attendances');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('news', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->string('category');
$table->string('image')->nullable();
$table->string('slug')->unique();
$table->enum('status', ['published', 'draft'])->default('published');
$table->integer('views')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('news');
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attendances', function (Blueprint $table) {
$table->dropUnique('attendances_user_id_date_unique');
});
Schema::create('pengajuan', function (Blueprint $table) {
$table->id();
// FK ke users.id
$table->foreignId('id_user')
->constrained('users')
->onDelete('cascade');
$table->foreignId('id_attendance')
->constrained('attendances')
->onDelete('cascade');
$table->date('date');
$table->string('keterangan', 255);
$table->string('url_bukti', 255);
$table->enum('status', ['diajukan', 'disetujui', 'ditolak'])
->default('diajukan');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('pengajuan');
Schema::table('attendances', function (Blueprint $table) {
$table->unique(['user_id', 'date']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->index();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_resets');
}
};

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attendance_settings', function (Blueprint $table) {
$table->id();
// Jam kerja
$table->time('checkin_start')->default('07:00:00'); // mulai boleh absen masuk
$table->time('checkin_end')->default('09:00:00'); // lewat ini dianggap telat (atau bisa tetap masuk telat)
$table->time('checkout_start')->default('15:00:00'); // mulai boleh absen pulang
// Opsional: toleransi telat (menit), misal 0 jika tidak ada toleransi
$table->unsignedSmallInteger('late_grace_minutes')->default(0);
// Hari kerja efektif: simpan sebagai JSON array angka 1-7 (ISO weekday)
// 1=Senin ... 7=Minggu. Contoh: [1,2,3,4,5]
$table->json('effective_workdays')->nullable();
// Opsional: timezone untuk perhitungan server-side (kalau perlu)
$table->string('timezone', 50)->default('Asia/Jakarta');
// Untuk audit/aturan tambahan (opsional)
$table->boolean('allow_checkin_after_end')->default(true); // jika true: 09:00-14:59 tetap boleh checkin tapi status telat
$table->boolean('require_checkout')->default(true); // jika true: checkout missing jadi "hadir_tidak_lengkap"
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attendance_settings');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('village_profiles', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable(); // nama desa
$table->string('tagline')->nullable(); // tagline desa
$table->longText('description')->nullable(); // deskripsi desa
$table->unsignedBigInteger('population')->nullable(); // jumlah penduduk
$table->decimal('area_km2', 10, 2)->nullable(); // luas wilayah (km2)
$table->string('address')->nullable(); // alamat desa
$table->unsignedSmallInteger('hamlet_count')->nullable(); // berapa dusun
// Video pengenalan (YouTube URL)
$table->text('youtube_url')->nullable();
// Kontak desa
$table->string('phone')->nullable(); // nomor desa
$table->string('email')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('village_profiles');
}
};

View File

@ -0,0 +1,22 @@
<?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('pengajuan', function (Blueprint $table) {
$table->string('jenis', 50)->after('date');
});
}
public function down(): void
{
Schema::table('pengajuan', function (Blueprint $table) {
$table->dropColumn('jenis');
});
}
};

View File

@ -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::table('attendance_settings', function (Blueprint $table) {
$table->decimal('office_latitude', 10, 7)->nullable()->after('timezone');
$table->decimal('office_longitude', 10, 7)->nullable()->after('office_latitude');
$table->unsignedInteger('attendance_radius_meters')->default(100)->after('office_longitude');
});
}
public function down(): void
{
Schema::table('attendance_settings', function (Blueprint $table) {
$table->dropColumn([
'office_latitude',
'office_longitude',
'attendance_radius_meters',
]);
});
}
};

View File

@ -0,0 +1,111 @@
<?php
namespace Database\Seeders;
use App\Models\Attendance;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Http;
class AttendanceSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Ambil user aktif agar tidak hard-coded id
$userIds = User::query()
->where('status', 'aktif')
->limit(3)
->pluck('id')
->all();
if (empty($userIds)) {
return;
}
// Waktu
$startDate = Carbon::now()->subMonths(2)->startOfMonth();
$endDate = Carbon::now();
// Ambil tanggal libur nasional
$holidays = $this->getIndonesianHolidays($startDate, $endDate);
foreach ($userIds as $userId) {
$statuses = ['hadir', 'izin', 'alpha'];
// Seed data per user
for ($date = $startDate->copy(); $date->lte($endDate); $date->addDay()) {
// Lewati Sabtu, Minggu dan hari libur nasional
if ($date->isWeekend() || in_array($date->toDateString(), $holidays)) {
continue;
}
// Buat variasi berdasarkan user_id (acak + offset)
$randomSeed = crc32($userId . $date->toDateString());
srand($randomSeed);
$status = $statuses[array_rand($statuses)];
$checkIn = $status === 'hadir'
? $date->copy()->setTime(rand(7, 8), rand(0, 59), rand(0, 59))
: null;
$checkOut = $status === 'hadir'
? $date->copy()->setTime(rand(16, 17), rand(0, 59), rand(0, 59))
: null;
$notes = match ($status) {
'izin' => 'Izin keperluan mendadak',
'alpha' => 'Tidak hadir tanpa keterangan',
default => null,
};
Attendance::create([
'user_id' => $userId,
'date' => $date->toDateString(),
'check_in' => $checkIn,
'check_out' => $checkOut,
'status' => $status,
'notes' => $notes,
'device_info' => $status === 'hadir' ? 'Android Pixel 7' : null,
'location' => null,
]);
}
}
}
/**
* Ambil daftar hari libur nasional Indonesia dari API
*/
private function getIndonesianHolidays(Carbon $startDate, Carbon $endDate): array
{
$years = range($startDate->year, $endDate->year);
$holidays = [];
foreach ($years as $year) {
try {
$response = Http::get("https://api-harilibur.vercel.app/api?year=$year");
if ($response->successful()) {
$data = $response->json();
foreach ($data as $item) {
if (isset($item['holiday_date'])) {
$holidayDate = Carbon::parse($item['holiday_date']);
if ($holidayDate->between($startDate, $endDate)) {
$holidays[] = $holidayDate->toDateString();
}
}
}
}
} catch (\Exception $e) {
// Lewatkan jika gagal ambil API
continue;
}
}
return $holidays;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\AttendanceSetting;
use Illuminate\Database\Seeder;
class AttendanceSettingSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
AttendanceSetting::query()->firstOrCreate([], [
'checkin_start' => '07:00:00',
'checkin_end' => '09:00:00',
'checkout_start' => '15:00:00',
'late_grace_minutes' => 0,
'effective_workdays' => [1, 2, 3, 4, 5],
'timezone' => 'Asia/Jakarta',
'allow_checkin_after_end' => true,
'require_checkout' => true,
'office_latitude' => -7.5992153,
'office_longitude' => 112.1035051,
'attendance_radius_meters' => 90,
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// Call EmployeeSeeder to populate employee data
$this->call([
AttendanceSettingSeeder::class,
EmployeeSeeder::class,
// NewsSeeder::class,
// PengajuanSeeder::class,
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class EmployeeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$employees = [
[
'nik' => '3301041980051043',
'name' => 'Imam Hidayat',
'tempat_lahir' => 'Ponorogo',
'tanggal_lahir' => '1980-05-10',
'email' => 'imam.hidayat@desapelem.com',
'role' => 'admin',
'jabatan' => 'Kepala Desa',
'phone' => '085637567652',
'no_telepon' => '085637567652',
'employee_id' => 'EMP001',
'status' => 'aktif',
'address' => 'Desa Pelem, Kecamatan Pelem, Kabupaten Ponorogo',
'alamat' => 'Desa Pelem, Kecamatan Pelem, Kabupaten Ponorogo',
'hire_date' => '2020-01-15',
'jenis_kelamin' => 'Laki-laki',
'url_photo' => null,
'password' => Hash::make('password123'),
],
];
foreach ($employees as $employee) {
User::create($employee);
}
$this->command->info('Employee data seeded successfully!');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\News;
class NewsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$newsData = [
[
'title' => 'Pembangunan Jembatan Desa Pelem Selesai',
'content' => 'Pembangunan jembatan penghubung antar dusun di Desa Pelem telah selesai dan siap digunakan. Jembatan ini akan memudahkan akses warga untuk beraktivitas sehari-hari.',
'category' => 'Infrastruktur',
'image' => null,
'status' => 'published',
'views' => 150,
],
[
'title' => 'Pelatihan UMKM untuk Warga Desa',
'content' => 'Pemerintah Desa Pelem mengadakan pelatihan UMKM untuk meningkatkan ekonomi warga. Pelatihan ini diikuti oleh 50 peserta dari berbagai dusun.',
'category' => 'Ekonomi',
'image' => null,
'status' => 'published',
'views' => 89,
],
[
'title' => 'Posyandu Balita Desa Pelem',
'content' => 'Kegiatan Posyandu balita rutin bulanan di Desa Pelem akan dilaksanakan pada tanggal 15 setiap bulannya. Semua balita wajib hadir untuk pemeriksaan kesehatan.',
'category' => 'Kesehatan',
'image' => null,
'status' => 'published',
'views' => 234,
],
[
'title' => 'Festival Budaya Desa Pelem 2024',
'content' => 'Festival budaya tahunan Desa Pelem akan digelar pada bulan Oktober mendatang. Acara ini akan menampilkan berbagai kesenian tradisional dan kuliner lokal.',
'category' => 'Budaya',
'image' => null,
'status' => 'published',
'views' => 312,
],
[
'title' => 'Pemilihan Ketua RT Periode 2024-2027',
'content' => 'Pemilihan ketua RT untuk periode 2024-2027 akan dilaksanakan pada bulan September. Semua warga yang memenuhi syarat dapat mencalonkan diri.',
'category' => 'Pemerintahan',
'image' => null,
'status' => 'published',
'views' => 178,
],
[
'title' => 'Program Beasiswa untuk Pelajar Desa',
'content' => 'Pemerintah Desa Pelem memberikan beasiswa untuk pelajar berprestasi. Program ini bertujuan untuk meningkatkan kualitas pendidikan di desa.',
'category' => 'Pendidikan',
'image' => null,
'status' => 'published',
'views' => 267,
],
[
'title' => 'Pembersihan Sungai Desa Pelem',
'content' => 'Kegiatan gotong royong pembersihan sungai akan dilaksanakan pada hari Minggu. Semua warga diharapkan berpartisipasi untuk menjaga kebersihan lingkungan.',
'category' => 'Lingkungan',
'image' => null,
'status' => 'published',
'views' => 145,
],
[
'title' => 'Peluncuran Website Desa Pelem',
'content' => 'Website resmi Desa Pelem telah diluncurkan untuk memberikan informasi yang lebih mudah diakses oleh warga. Website ini berisi berbagai informasi penting desa.',
'category' => 'Teknologi',
'image' => null,
'status' => 'published',
'views' => 423,
],
];
foreach ($newsData as $data) {
$data['slug'] = \Illuminate\Support\Str::slug($data['title']);
News::create($data);
}
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Database\Seeders;
use App\Models\Attendance;
use App\Models\Pengajuan;
use App\Models\User;
use Illuminate\Database\Seeder;
class PengajuanSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$user = User::query()->first();
if (!$user) {
$this->command->info('No users found. Skip PengajuanSeeder.');
return;
}
$attendance = Attendance::query()
->where('user_id', $user->id)
->latest('date')
->first();
if (!$attendance) {
$attendance = Attendance::query()->create([
'user_id' => $user->id,
'date' => now()->toDateString(),
'check_in' => now()->copy()->setTime(8, 0, 0),
'check_out' => now()->copy()->setTime(16, 0, 0),
'status' => 'hadir',
'notes' => null,
'device_info' => 'Seeder',
'location' => null,
]);
}
Pengajuan::query()->insert([
[
'id_user' => $user->id,
'id_attendance' => $attendance->id,
'date' => now()->toDateString(),
'keterangan' => 'Izin pulang lebih awal untuk keperluan keluarga',
'url_bukti' => 'pengajuan/image.png',
'status' => 'diajukan',
'created_at' => now(),
'updated_at' => now(),
],
[
'id_user' => $user->id,
'id_attendance' => $attendance->id,
'date' => now()->subDay()->toDateString(),
'keterangan' => 'Pengajuan perbaikan jam masuk karena terlambat',
'url_bukti' => 'pengajuan/image.png',
'status' => 'disetujui',
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->command->info('Pengajuan data seeded successfully!');
}
}

BIN
docs/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

1763
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-echo": "^2.2.0",
"laravel-vite-plugin": "^2.0.0",
"pusher-js": "^8.4.0",
"vite": "^7.0.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.2"
}
}

34
phpunit.xml Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
div.dt-autofill-handle{position:absolute;height:8px;width:8px;z-index:10;box-sizing:border-box;background:#0d6efd;cursor:pointer}div.dtk-focus-alt div.dt-autofill-handle{background:#ff8b33}div.dt-autofill-select{position:absolute;z-index:1001;background-color:#0d6efd;background-image:repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255, 255, 255, 0.5) 5px, rgba(255, 255, 255, 0.5) 10px)}div.dt-autofill-select.top,div.dt-autofill-select.bottom{height:3px;margin-top:-1px}div.dt-autofill-select.left,div.dt-autofill-select.right{width:3px;margin-left:-1px}div.dt-autofill-list{position:fixed;top:50%;left:50%;width:500px;margin-left:-250px;background-color:white;border-radius:.75em;box-shadow:0 12px 30px rgba(0, 0, 0, 0.6);z-index:104;box-sizing:border-box;padding:2em}div.dt-autofill-list div.dtaf-popover-close{position:absolute;top:6px;right:6px;width:22px;height:22px;text-align:center;border-radius:3px;cursor:pointer;z-index:12}div.dt-autofill-list>div.dt-autofill-list-items>button{display:block;width:100%;margin:1em 0;padding:1em;border-radius:.5em;border:1px solid rgba(0, 0, 0, 0.175);background-color:#f6f6f6;text-align:left;cursor:pointer}div.dt-autofill-list>div.dt-autofill-list-items>button:hover{background-color:#ebebeb}div.dt-autofill-list>div.dt-autofill-list-items>button:first-child{margin-top:0}div.dt-autofill-list>div.dt-autofill-list-items>button:last-child{margin-bottom:0}div.dt-autofill-list>div.dt-autofill-list-items>button input[type=number]{padding:6px;width:30px;margin:-2px 0}div.dt-autofill-list>div.dt-autofill-list-items>button span{float:right}div.dtaf-popover-closeable{padding-top:2.5em}div.dt-autofill-background{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0, 0, 0, 0.7);background:radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%);z-index:103}html.dark div.dt-autofill-handle{background:#6ea8fe}html.dark div.dt-autofill-select{position:absolute;z-index:1001;background-color:#6ea8fe;background-image:repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(0, 0, 0, 0.5) 5px, rgba(0, 0, 0, 0.5) 10px)}html.dark div.dt-autofill-list{background-color:var(--dt-html-background);border:1px solid rgba(255, 255, 255, 0.15)}html.dark div.dt-autofill-list button{color:inherit;border:1px solid rgba(255, 255, 255, 0.175);background-color:#2f3438}html.dark div.dt-autofill-list button:hover{background-color:#404549}@media screen and (max-width: 767px){div.dt-autofill-handle{height:16px;width:16px}div.dt-autofill-list{width:90%;left:74.5%}}div.dt-autofill-list div.dt-autofill-question input[type=number]{padding:6px;width:60px;margin:-2px 0}div.row.dt-row>div.col-sm-12{position:relative}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.btn-group-xs>.btn,.btn-xs{padding:.35rem .4rem .25rem .4rem;font-size:.875rem;line-height:.5;border-radius:.2rem}.checkbox label .switch,.checkbox-inline .switch{margin-left:-1.25rem;margin-right:.35rem}.switch{position:relative;overflow:hidden}.switch.btn.btn-light,.switch.btn.btn-outline-light{border-color:rgba(0,0,0,.15)}.switch input[type=checkbox]{display:none}.switch-group{position:absolute;width:200%;top:0;bottom:0;left:0;transition:left .35s;-webkit-transition:left .35s;-moz-user-select:none;-webkit-user-select:none}.switch.off .switch-group{left:-100%}.switch-on{position:absolute;top:0;bottom:0;left:0;right:50%;margin:0;border:0;border-radius:0}.switch-off{position:absolute;top:0;bottom:0;left:50%;right:0;margin:0;border:0;border-radius:0;box-shadow:none}.switch-handle{position:relative;margin:0 auto;padding-top:0;padding-bottom:0;height:100%;width:0;border-width:0 1px;background-color:#fff}.switch.btn-outline-primary .switch-handle{background-color:var(--primary);border-color:var(--primary)}.switch.btn-outline-secondary .switch-handle{background-color:var(--secondary);border-color:var(--secondary)}.switch.btn-outline-success .switch-handle{background-color:var(--success);border-color:var(--success)}.switch.btn-outline-danger .switch-handle{background-color:var(--danger);border-color:var(--danger)}.switch.btn-outline-warning .switch-handle{background-color:var(--warning);border-color:var(--warning)}.switch.btn-outline-info .switch-handle{background-color:var(--info);border-color:var(--info)}.switch.btn-outline-light .switch-handle{background-color:var(--light);border-color:var(--light)}.switch.btn-outline-dark .switch-handle{background-color:var(--dark);border-color:var(--dark)}.switch[class*=btn-outline]:hover .switch-handle{background-color:var(--light);opacity:.5}.switch.btn{min-width:3.7rem;min-height:calc(1.5em + .75rem + 2px)}.switch-on.btn{padding-right:1.5rem}.switch-off.btn{padding-left:1.5rem}.switch.btn-lg{min-width:5rem;line-height:1.5;min-height:calc(1.5em + 1rem + 2px)}.switch-on.btn-lg{padding-right:2rem}.switch-off.btn-lg{padding-left:2rem}.switch-handle.btn-lg{width:2.5rem}.switch.btn-sm{min-width:3.25rem;min-height:calc(1.5em + .5rem + 2px)}.switch-on.btn-sm{padding-right:1rem}.switch-off.btn-sm{padding-left:1rem}.switch.btn-xs{min-width:3.125rem;min-height:1.375rem}.switch-on.btn-xs{padding-right:.8rem}.switch-off.btn-xs{padding-left:.8rem}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
table.DTCR_clonedTable.dataTable{position:absolute !important;background-color:rgba(255, 255, 255, 0.7);z-index:202;border-radius:4px}div.DTCR_pointer{width:1px;background-color:#0d6efd;z-index:201}html.dark table.DTCR_clonedTable.dataTable{background-color:rgba(33, 33, 33, 0.9)}html.dark div.DTCR_pointer{background-color:#0d6efd}

View File

@ -0,0 +1 @@
.croppr-container *{user-select:none;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;box-sizing:border-box;-webkit-box-sizing:border-box;-moz-box-sizing:border-box}.croppr-container img{vertical-align:middle;max-width:100%}.croppr{position:relative;display:inline-block}.croppr-handle,.croppr-imageClipped,.croppr-overlay,.croppr-region{position:absolute;top:0}.croppr-overlay{background:rgba(0,0,0,.5);right:0;bottom:0;left:0;z-index:1;cursor:crosshair}.croppr-region{border:1px dashed rgba(0,0,0,.5);z-index:3;cursor:move}.croppr-imageClipped{right:0;bottom:0;left:0;z-index:2;pointer-events:none}.croppr-handle{border:1px solid #000;background-color:#fff;width:10px;height:10px;z-index:4}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.datepicker{width:-moz-min-content;width:min-content}.datepicker:not(.active){display:none}.datepicker-dropdown{padding-top:4px;position:absolute;z-index:1000}.datepicker-dropdown.datepicker-orient-top{padding-bottom:4px;padding-top:0}.datepicker-picker{background-color:#fff;border-radius:.375rem;display:flex;flex-direction:column}.datepicker-dropdown .datepicker-picker{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)}.datepicker-main{flex:auto;padding:2px}.datepicker-footer{background-color:#f8f9fa;box-shadow:inset 0 1px 1px rgba(0,0,0,.1)}.datepicker-title{background-color:#f8f9fa;box-shadow:inset 0 -1px 1px rgba(0,0,0,.1);font-weight:700;padding:.375rem .75rem;text-align:center}.datepicker-controls{display:flex}.datepicker-header .datepicker-controls{padding:2px 2px 0}.datepicker-controls .btn{background-color:#fff;border-color:#f8f9fa}.datepicker-controls .btn:focus,.datepicker-controls .btn:hover{background-color:#e2e6ea;border-color:#dbe0e5;color:#000}.datepicker-controls .btn:focus{box-shadow:0 0 0 .25rem hsla(210,2%,83%,.5)}.datepicker-controls .btn:disabled{background-color:#f8f9fa;border-color:#f8f9fa;color:#000}.datepicker-controls .btn:not(:disabled):active{background-color:#dbe0e5;border-color:#d4dae0;color:#000}.datepicker-controls .btn:not(:disabled):active:focus{box-shadow:0 0 0 .25rem hsla(210,2%,83%,.5)}.datepicker-header .datepicker-controls .btn{border-color:transparent;font-weight:700}.datepicker-footer .datepicker-controls .btn{border-radius:.25rem;flex:auto;font-size:.875rem;margin:calc(.375rem - 1px) .375rem}.datepicker-controls .view-switch{flex:auto}.datepicker-controls .next-button,.datepicker-controls .prev-button{flex:0 0 14.2857142857%;padding-left:.375rem;padding-right:.375rem}.datepicker-controls .next-button.disabled,.datepicker-controls .prev-button.disabled{visibility:hidden}.datepicker-grid,.datepicker-view{display:flex}.datepicker-view{align-items:stretch;width:15.75rem}.datepicker-grid{flex:auto;flex-wrap:wrap}.datepicker .days{display:flex;flex:auto;flex-direction:column}.datepicker .days-of-week{display:flex}.datepicker .week-numbers{display:flex;flex:0 0 9.6774193548%;flex-direction:column}.datepicker .weeks{align-items:stretch;display:flex;flex:auto;flex-direction:column}.datepicker span{-webkit-touch-callout:none;align-items:center;border-radius:.375rem;cursor:default;display:flex;justify-content:center;-webkit-user-select:none;-moz-user-select:none;user-select:none}.datepicker .dow{font-size:.9375rem;font-weight:700;height:1.5rem}.datepicker .week{color:#dee2e6;flex:auto;font-size:.875rem}.datepicker .days .dow,.datepicker-cell{flex-basis:14.2857142857%}.datepicker-cell{height:2.25rem}.datepicker-cell:not(.day){flex-basis:25%;height:4.5rem}.datepicker-cell:not(.disabled):hover{background-color:#f9f9f9;cursor:pointer}.datepicker-cell.focused:not(.selected){background-color:#e2e6ea}.datepicker-cell.selected,.datepicker-cell.selected:hover{background-color:#0d6efd;color:#fff;font-weight:600}.datepicker-cell.disabled{color:rgba(33,37,41,.5)}.datepicker-cell.next:not(.disabled),.datepicker-cell.prev:not(.disabled){color:#6c757d}.datepicker-cell.next.selected,.datepicker-cell.prev.selected{color:#e6e6e6}.datepicker-cell.highlighted:not(.selected):not(.range):not(.today){background-color:#f8f9fa;border-radius:0}.datepicker-cell.highlighted:not(.selected):not(.range):not(.today):not(.disabled):hover{background-color:#f1f3f5}.datepicker-cell.highlighted:not(.selected):not(.range):not(.today).focused{background-color:#e2e6ea}.datepicker-cell.today:not(.selected){background-color:#20c997}.datepicker-cell.today:not(.selected):not(.disabled){color:#fff}.datepicker-cell.today.focused:not(.selected){background-color:#1fc493}.datepicker-cell.range-end:not(.selected),.datepicker-cell.range-start:not(.selected){background-color:#6c757d;color:#fff}.datepicker-cell.range-end.focused:not(.selected),.datepicker-cell.range-start.focused:not(.selected){background-color:#69727a}.datepicker-cell.range-start:not(.range-end){border-radius:.375rem 0 0 .375rem}.datepicker-cell.range-end:not(.range-start){border-radius:0 .375rem .375rem 0}.datepicker-cell.range{background-color:#e9ecef;border-radius:0}.datepicker-cell.range:not(.disabled):not(.focused):not(.today):hover{background-color:#e2e6ea}.datepicker-cell.range.disabled{color:#ced4db}.datepicker-cell.range.focused{background-color:#dbe0e5}.datepicker-input.in-edit{border-color:#73acfe}.datepicker-input.in-edit:active,.datepicker-input.in-edit:focus{box-shadow:0 0 .25em .25em rgba(115,172,254,.2)}

View File

@ -0,0 +1 @@
.gu-mirror{position:fixed!important;margin:0!important;z-index:9999!important;opacity:.8}.gu-hide{display:none!important}.gu-unselectable{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.gu-transit{opacity:.2}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
table.dataTable thead tr>.dtfc-fixed-left,table.dataTable thead tr>.dtfc-fixed-right,table.dataTable tfoot tr>.dtfc-fixed-left,table.dataTable tfoot tr>.dtfc-fixed-right{top:0;bottom:0;z-index:3;background-color:white}table.dataTable tbody tr>.dtfc-fixed-left,table.dataTable tbody tr>.dtfc-fixed-right{z-index:1;background-color:white}div.dtfc-left-top-blocker,div.dtfc-right-top-blocker{background-color:white}html.dark table.dataTable thead tr>.dtfc-fixed-left,html.dark table.dataTable thead tr>.dtfc-fixed-right,html.dark table.dataTable tfoot tr>.dtfc-fixed-left,html.dark table.dataTable tfoot tr>.dtfc-fixed-right{background-color:var(--dt-html-background)}html.dark table.dataTable tbody tr>.dtfc-fixed-left,html.dark table.dataTable tbody tr>.dtfc-fixed-right{background-color:var(--dt-html-background)}html.dark div.dtfc-left-top-blocker,html.dark div.dtfc-right-top-blocker{background-color:var(--dt-html-background)}div.dtfc-right-top-blocker,div.dtfc-left-top-blocker{margin-top:6px;border-bottom:0px solid #ddd !important}table.dataTable.table-bordered.dtfc-has-left{border-left:none}div.dataTables_scroll.dtfc-has-left table.table-bordered{border-left:none}div.dataTables_scrollBody{border-left:1px solid #ddd !important}div.dataTables_scrollFootInner table.table-bordered tr th:first-child,div.dataTables_scrollHeadInner table.table-bordered tr th:first-child{border-left:1px solid #ddd !important}html[data-bs-theme=dark] table.dataTable thead tr>.dtfc-fixed-left,html[data-bs-theme=dark] table.dataTable thead tr>.dtfc-fixed-right,html[data-bs-theme=dark] table.dataTable tfoot tr>.dtfc-fixed-left,html[data-bs-theme=dark] table.dataTable tfoot tr>.dtfc-fixed-right{background-color:var(--bs-body-bg)}html[data-bs-theme=dark] table.dataTable tbody tr>.dtfc-fixed-left,html[data-bs-theme=dark] table.dataTable tbody tr>.dtfc-fixed-right{background-color:var(--bs-body-bg)}html[data-bs-theme=dark] div.dtfc-left-top-blocker,html[data-bs-theme=dark] div.dtfc-right-top-blocker{background-color:var(--bs-body-bg)}html[data-bs-theme=dark] div.dataTables_scrollBody{border-left-color:var(--bs-border-color) !important}html[data-bs-theme=dark] div.dataTables_scrollFootInner table.table-bordered tr th:first-child,html[data-bs-theme=dark] div.dataTables_scrollHeadInner table.table-bordered tr th:first-child{border-left-color:var(--bs-border-color) !important}

View File

@ -0,0 +1 @@
table.dataTable.fixedHeader-floating,table.dataTable.fixedHeader-locked{background-color:white;margin-top:0 !important;margin-bottom:0 !important}table.dataTable.fixedHeader-locked{position:absolute !important}@media print{table.fixedHeader-floating{display:none}}html[data-bs-theme=dark] table.dataTable.fixedHeader-floating,html[data-bs-theme=dark] table.dataTable.fixedHeader-locked{background-color:var(--bs-body-bg)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
svg{-ms-touch-action:none;touch-action:none}image,text,.jvm-zoomin,.jvm-zoomout{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.jvm-container{-ms-touch-action:none;touch-action:none;position:relative;overflow:hidden;height:100%;width:100%}.jvm-tooltip{border-radius:3px;background-color:#5c5cff;font-family:sans-serif,Verdana;font-size:smaller;box-shadow:1px 2px 12px rgba(0,0,0,0.2);padding:3px 5px;white-space:nowrap;position:absolute;display:none;color:#FFF}.jvm-tooltip.active{display:block}.jvm-zoom-btn{border-radius:3px;background-color:#292929;padding:3px;box-sizing:border-box;position:absolute;line-height:10px;cursor:pointer;color:#FFF;height:15px;width:15px;left:10px}.jvm-zoom-btn.jvm-zoomout{top:30px}.jvm-zoom-btn.jvm-zoomin{top:10px}.jvm-series-container{right:15px;position:absolute}.jvm-series-container.jvm-series-h{bottom:15px}.jvm-series-container.jvm-series-v{top:15px}.jvm-series-container .jvm-legend{background-color:#fff;border:1px solid #e5e7eb;margin-left:.75rem;border-radius:.25rem;border-color:#e5e7eb;padding:.6rem;box-shadow:0 1px 2px 0 rgba(0,0,0,0.05);float:left}.jvm-series-container .jvm-legend .jvm-legend-title{line-height:1;border-bottom:1px solid #e5e7eb;padding-bottom:.5rem;margin-bottom:.575rem;text-align:left}.jvm-series-container .jvm-legend .jvm-legend-inner{overflow:hidden}.jvm-series-container .jvm-legend .jvm-legend-inner .jvm-legend-tick{overflow:hidden;min-width:40px}.jvm-series-container .jvm-legend .jvm-legend-inner .jvm-legend-tick:not(:first-child){margin-top:.575rem}.jvm-series-container .jvm-legend .jvm-legend-inner .jvm-legend-tick .jvm-legend-tick-sample{border-radius:4px;margin-right:.65rem;height:16px;width:16px;float:left}.jvm-series-container .jvm-legend .jvm-legend-inner .jvm-legend-tick .jvm-legend-tick-text{font-size:12px;text-align:center;float:left}.jvm-line[animation="true"]{-webkit-animation:jvm-line-animation 10s linear forwards infinite;animation:jvm-line-animation 10s linear forwards infinite}@-webkit-keyframes jvm-line-animation{from{stroke-dashoffset:250}}@keyframes jvm-line-animation{from{stroke-dashoffset:250}}

View File

@ -0,0 +1 @@
table.dataTable tbody th.focus,table.dataTable tbody td.focus{outline:2px solid #0d6efd;outline-offset:-2px}table.dataTable tbody tr.selected th.focus,table.dataTable tbody tr.selected td.focus{outline-color:#0143a3}div.dtk-focus-alt table.dataTable tbody th.focus,div.dtk-focus-alt table.dataTable tbody td.focus{outline:2px solid #ff8b33;outline-offset:-2px}html.dark table.dataTable tbody th.focus,html.dark table.dataTable tbody td.focus{outline-color:#0d6efd}html.dark table.dataTable tbody tr.selected th.focus,html.dark table.dataTable tbody tr.selected td.focus{outline-color:#0143a3}html.dark div.dtk-focus-alt table.dataTable tbody th.focus,html.dark div.dtk-focus-alt table.dataTable tbody td.focus{outline-color:#ff8b33}

Some files were not shown because too many files have changed in this diff Show More