Compare commits

..

No commits in common. "bc02af2a66f7255e2a0401364a5e5448a66aebdf" and "6ebcbc24c0733c1602a9ac8b4dbb95d2bd32cd67" have entirely different histories.

41 changed files with 789 additions and 1117 deletions

View File

@ -10,16 +10,16 @@ enum CashTransactionType: string
case DEPOSIT = 'deposit'; case DEPOSIT = 'deposit';
case EXPENSE = 'expense'; case EXPENSE = 'expense';
case TRANSFER = 'transfer';
case WITHDRAWAL = 'withdrawal'; case WITHDRAWAL = 'withdrawal';
case EMPLOYEE_ADVANCE = 'employee_advance';
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
self::DEPOSIT => 'Deposit', self::DEPOSIT => 'Setoran',
self::EXPENSE => 'Pengeluaran', self::EXPENSE => 'Pengeluaran',
self::WITHDRAWAL => 'Withdrawal', self::TRANSFER => 'Transfer',
self::EMPLOYEE_ADVANCE => 'Kasbon', self::WITHDRAWAL => 'Penarikan',
}; };
} }
} }

View File

@ -71,8 +71,9 @@ enum Permission: string
case PRODUCTS_UPDATE = 'products.update'; case PRODUCTS_UPDATE = 'products.update';
case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status';
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations'; // Stocks
case STOCKS_VIEW = 'stocks.view';
// Orders // Orders
case ORDERS_VIEW = 'orders.view'; case ORDERS_VIEW = 'orders.view';
@ -112,7 +113,6 @@ enum Permission: string
case EMPLOYEE_ADVANCES_UPDATE = 'employee_advances.update'; case EMPLOYEE_ADVANCES_UPDATE = 'employee_advances.update';
case EMPLOYEE_ADVANCES_DELETE = 'employee_advances.delete'; case EMPLOYEE_ADVANCES_DELETE = 'employee_advances.delete';
case EMPLOYEE_ADVANCES_PAY = 'employee_advances.pay'; case EMPLOYEE_ADVANCES_PAY = 'employee_advances.pay';
case EMPLOYEE_ADVANCES_VIEW_PAYMENTS = 'employee_advances.view_payments';
case EMPLOYEE_ADVANCES_VERIFY = 'employee_advances.verify'; case EMPLOYEE_ADVANCES_VERIFY = 'employee_advances.verify';
// Payroll // Payroll

View File

@ -22,38 +22,19 @@ public function index(Request $request): Response
{ {
$year = $request->integer('year', now()->year); $year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month); $month = $request->integer('month', now()->month);
$hrSettings = app(HRSettings::class); $hrSettings = app(HRSettings::class);
$user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']);
if ($isAdmin) {
return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month),
'todayAttendance' => null,
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isAdmin' => true,
]);
}
$employeeId = $user->employee?->id;
return Inertia::render('admin/hr/attendance/index', [ return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month, $employeeId), 'attendances' => $this->service->getByMonth($year, $month),
'todayAttendance' => $this->service->getToday(), 'todayAttendance' => $this->service->getToday(),
'currentYear' => $year, 'currentYear' => $year,
'currentMonth' => $month, 'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month, $employeeId), 'monthStats' => $this->service->getMonthStats($year, $month),
'hrSettings' => [ 'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time, 'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time, 'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
], ],
'isAdmin' => false,
]); ]);
} }

View File

@ -36,9 +36,7 @@ public function create(): Response
return Inertia::render('admin/hr/employee/create', [ return Inertia::render('admin/hr/employee/create', [
'roles' => $this->service->canViewAll() 'roles' => $this->service->canViewAll()
? Role::where('name', '!=', 'Developer') ? Role::where('name', '!=', 'Developer')->get(['id', 'name'])
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku'))
->get(['id', 'name'])
: Role::where('name', '=', $user->roles->first()?->name)->get(['id', 'name']), : Role::where('name', '=', $user->roles->first()?->name)->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(), 'canViewAll' => $this->service->canViewAll(),
]); ]);
@ -59,10 +57,7 @@ public function edit(User $user): Response
return Inertia::render('admin/hr/employee/edit', [ return Inertia::render('admin/hr/employee/edit', [
'employee' => $user, 'employee' => $user,
'roles' => Role::where('name', '!=', 'Developer') 'roles' => Role::where('name', '!=', 'Developer')->get(['id', 'name']),
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku'))
->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
]); ]);
} }

View File

@ -30,10 +30,10 @@ protected function casts(): array
]; ];
} }
protected function formattedAttendanceDate(): Attribute protected function attendanceDate(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn ($value) => $value ? \Carbon\Carbon::parse($value)->translatedFormat('l, d F Y') : null, get: fn ($value) => $value?->translatedFormat('l, d F Y'),
); );
} }

View File

@ -26,14 +26,14 @@ protected function casts(): array
protected function formattedAmount(): Attribute protected function formattedAmount(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), get: fn() => 'Rp ' . number_format($this->amount, 0, ',', '.'),
); );
} }
protected function formattedPaidAt(): Attribute protected function formattedPaidAt(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->paid_at?->translatedFormat('l, d F Y'), get: fn() => $this->paid_at?->translatedFormat('l, d F Y'),
); );
} }

View File

@ -34,14 +34,14 @@ protected function casts(): array
protected function formattedName(): Attribute protected function formattedName(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => ucfirst($this->name), get: fn() => ucfirst($this->name),
); );
} }
protected function formattedStock(): Attribute protected function formattedStock(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => number_format($this->stock, 0, ',', '.'), get: fn() => number_format($this->stock, 0, ',', '.'),
); );
} }

View File

@ -2,7 +2,6 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment; use App\Models\EmployeeAdvancePayment;
@ -48,9 +47,7 @@ public function create(array $data): EmployeeAdvance
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
if (! $employee) { if (! $employee) {
throw ValidationException::withMessages([ throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
'amount' => 'Anda tidak terdaftar sebagai karyawan.',
]);
} }
$employeeAdvance = EmployeeAdvance::create([ $employeeAdvance = EmployeeAdvance::create([
@ -76,44 +73,6 @@ public function create(array $data): EmployeeAdvance
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
{ {
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
throw ValidationException::withMessages([
'amount' => 'Kasbon yang sudah dibayar tidak dapat diedit.',
]);
}
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
$oldAmount = $employeeAdvance->amount;
$newAmount = $data['amount'];
if ($oldAmount !== $newAmount) {
$employeeAdvance = DB::transaction(function () use ($employeeAdvance, $data, $oldAmount, $newAmount) {
$this->creditCash(
$oldAmount,
'Pembatalan kasbon: '.$employeeAdvance->description,
CashTransactionType::DEPOSIT,
);
$cashTransaction = $this->debitCash(
$newAmount,
'Kasbon: '.$data['description'],
CashTransactionType::EMPLOYEE_ADVANCE,
);
$employeeAdvance->update([
'amount' => $newAmount,
'description' => $data['description'],
'due_date' => $data['due_date'],
'cash_transaction_id' => $cashTransaction->id,
]);
return $employeeAdvance;
});
return $employeeAdvance;
}
}
$employeeAdvance->update([ $employeeAdvance->update([
'amount' => $data['amount'], 'amount' => $data['amount'],
'description' => $data['description'], 'description' => $data['description'],
@ -125,60 +84,29 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
public function delete(EmployeeAdvance $employeeAdvance): bool public function delete(EmployeeAdvance $employeeAdvance): bool
{ {
return DB::transaction(function () use ($employeeAdvance) { if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) { $cashAccount = $this->getCashAccount();
$this->creditCash( $newBalance = $cashAccount->balance + $employeeAdvance->amount;
$employeeAdvance->amount, $cashAccount->update(['balance' => $newBalance]);
'Pembatalan kasbon: '.$employeeAdvance->description, $employeeAdvance->cashTransaction()->delete();
CashTransactionType::DEPOSIT, }
);
$employeeAdvance->cashTransaction()->delete();
}
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) { return $employeeAdvance->delete();
$this->creditCash(
$employeeAdvance->amount,
'Pembatalan kasbon: '.$employeeAdvance->description,
CashTransactionType::DEPOSIT,
);
$employeeAdvance->load('payments');
foreach ($employeeAdvance->payments as $payment) {
if ($payment->cash_transaction_id) {
$this->debitCash(
$payment->amount,
'Pembatalan pembayaran kasbon: '.$employeeAdvance->description,
CashTransactionType::EXPENSE,
);
$payment->cashTransaction()->delete();
}
}
$employeeAdvance->payments()->delete();
}
return $employeeAdvance->delete();
});
} }
public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{ {
$employeeAdvance = DB::transaction(function () use ($employeeAdvance) { $cashTransaction = $this->debitCash(
$cashTransaction = $this->debitCash( amount: $employeeAdvance->amount,
$employeeAdvance->amount, description: 'Kasbon: '.$employeeAdvance->description,
'Kasbon: '.$employeeAdvance->description, );
CashTransactionType::EMPLOYEE_ADVANCE
);
$employeeAdvance->update([ $employeeAdvance->update([
'cash_transaction_id' => $cashTransaction->id, 'cash_transaction_id' => $cashTransaction->id,
'status' => EmployeeAdvanceStatus::APPROVED, 'status' => EmployeeAdvanceStatus::APPROVED,
'verified_by_id' => auth()->id(), 'verified_by_id' => auth()->id(),
'verified_at' => now(), 'verified_at' => now(),
]); ]);
return $employeeAdvance;
});
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
@ -203,9 +131,8 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
$employeeAdvance = DB::transaction(function () use ($employeeAdvance, $amount) { $employeeAdvance = DB::transaction(function () use ($employeeAdvance, $amount) {
$cashTransaction = $this->creditCash( $cashTransaction = $this->creditCash(
$amount, amount: $amount,
'Pembayaran kasbon: '.$employeeAdvance->description, description: 'Pembayaran kasbon: '.$employeeAdvance->description,
CashTransactionType::DEPOSIT
); );
EmployeeAdvancePayment::create([ EmployeeAdvancePayment::create([

View File

@ -2,7 +2,6 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\Expense; use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -176,11 +175,10 @@ public function update(Expense $expense, array $data): Expense
public function delete(Expense $expense): bool public function delete(Expense $expense): bool
{ {
return DB::transaction(function () use ($expense) { return DB::transaction(function () use ($expense) {
$this->creditCash( $cashAccount = CashAccount::firstOrFail();
amount: $expense->amount,
description: 'Pembatalan pengeluaran: '.$expense->description, $newBalance = $cashAccount->balance + $expense->amount;
type: CashTransactionType::DEPOSIT, $cashAccount->update(['balance' => $newBalance]);
);
// Invalidate receipt cache // Invalidate receipt cache
$media = $expense->getFirstMedia('receipts'); $media = $expense->getFirstMedia('receipts');

View File

@ -2,7 +2,6 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Models\Payroll; use App\Models\Payroll;
@ -142,10 +141,9 @@ public function pay(Payroll $payroll): Payroll
} }
$payroll = DB::transaction(function () use ($payroll) { $payroll = DB::transaction(function () use ($payroll) {
$cashTransaction = $this->debitCash( $cashTransaction = $this->creditCash(
amount: $payroll->total_amount, amount: $payroll->total_amount,
description: 'Pembayaran gaji karyawan', description: 'Pembayaran gaji karyawan',
type: CashTransactionType::EXPENSE,
); );
$payroll->update([ $payroll->update([

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
@ -28,12 +27,11 @@ public function getAll(): Collection
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); ->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
} }
public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection public function getByMonth(int $year, int $month): Collection
{ {
return Attendance::with(['employee.user.userProfile', 'media']) return Attendance::with(['employee.user.userProfile', 'media'])
->whereYear('attendance_date', $year) ->whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month) ->whereMonth('attendance_date', $month)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->get() ->get()
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance)); ->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
} }
@ -53,14 +51,7 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString()); return $this->getByDate(now()->toDateString());
} }
public function getAllEmployees(): Collection public function getMonthStats(int $year, int $month): array
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get();
}
public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
{ {
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth();
@ -74,20 +65,14 @@ public function getMonthStats(int $year, int $month, ?int $employeeId = null): a
$current->addDay(); $current->addDay();
} }
$attendanceQuery = Attendance::whereYear('attendance_date', $year) $attendanceCount = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month); ->whereMonth('attendance_date', $month)
if ($employeeId) { ->count();
$attendanceQuery->where('employee_id', $employeeId);
}
$attendanceCount = $attendanceQuery->count();
$leaveQuery = LeaveRequest::approved() $leaveDays = LeaveRequest::approved()
->where('start_date', '<=', $endOfMonth) ->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth); ->where('end_date', '>=', $startOfMonth)
if ($employeeId) { ->get()
$leaveQuery->where('employee_id', $employeeId);
}
$leaveDays = $leaveQuery->get()
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) { ->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp); $leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp); $leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
@ -169,7 +154,6 @@ public function checkOut(Attendance $attendance, array $data): Attendance
private function formatAttendance(Attendance $attendance): array private function formatAttendance(Attendance $attendance): array
{ {
$toArray = $attendance->toArray(); $toArray = $attendance->toArray();
$toArray['employee_name'] = $attendance->employee?->user?->userProfile?->full_name ?? '-';
if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) { if (is_null($toArray['work_duration_minutes']) && $attendance->check_in_at) {
$checkIn = Carbon::parse($attendance->check_in_at); $checkIn = Carbon::parse($attendance->check_in_at);

View File

@ -10,18 +10,12 @@
class EmployeeService class EmployeeService
{ {
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko']; private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
private const RESTRICTED_ROLES = ['admin-toko', 'direktur'];
public function canViewAll(): bool public function canViewAll(): bool
{ {
return auth()->user()->hasAnyRole(self::ADMIN_ROLES); return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
} }
public function shouldHideAdminBahanBaku(): bool
{
return auth()->user()->hasAnyRole(self::RESTRICTED_ROLES);
}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return User::select(['id', 'email', 'username', 'is_active']) return User::select(['id', 'email', 'username', 'is_active'])
@ -35,7 +29,6 @@ public function getAll(array $filters = []): Collection
$userRoles = auth()->user()->roles->pluck('name'); $userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles)); $q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
}) })
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))) ->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender))) ->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
@ -57,7 +50,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$userRoles = auth()->user()->roles->pluck('name'); $userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles)); $q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
}) })
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))) ->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))

View File

@ -12,16 +12,10 @@
class LeaveRequestService class LeaveRequestService
{ {
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at']) return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, function ($query, $status) { ->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status); $query->where('status', $status);
}) })
@ -34,7 +28,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return LeaveRequest::query() return LeaveRequest::query()
->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at']) ->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['status'] ?? null, function ($query, $status) { ->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status); $query->where('status', $status);

View File

@ -30,14 +30,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
'restockItems' => fn ($q) => $q 'restockItems' => fn($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']) ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'), ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', 'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'restockItems.productVariant.product:id,name', 'restockItems.productVariant.product:id,name',
]) ])
->when($search, function ($q) use ($search) { ->when($search, function ($q) use ($search) {
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('restockItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('notes', 'like', "%{$search}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->orderBy($sort, $direction) ->orderBy($sort, $direction)
@ -79,11 +79,11 @@ public function getForCreate(): array
: null; : null;
$capitalPrice = $variant->productPrices $capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL); ->first(fn($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0; $variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices $rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT); ->first(fn($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0; $variant->reject_price = $rejectPrice?->price ?? 0;
}); });
}), }),
@ -93,7 +93,7 @@ public function getForCreate(): array
public function getForEdit(Restock $restock): array public function getForEdit(Restock $restock): array
{ {
$restock->load([ $restock->load([
'restockItems' => fn ($q) => $q 'restockItems' => fn($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'), ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product', 'restockItems.productVariant.product',
]); ]);
@ -108,7 +108,7 @@ public function getForEdit(Restock $restock): array
'photo_url' => $media 'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null, : null,
'items' => $restock->restockItems->map(fn (RestockItem $item) => [ 'items' => $restock->restockItems->map(fn(RestockItem $item) => [
'id' => $item->id, 'id' => $item->id,
'product_variant_id' => $item->product_variant_id, 'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
@ -145,7 +145,7 @@ public function create(array $data): Restock
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Restock Baru', title: 'Restock Baru',
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Restock ' . ($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject') . ' sebesar Rp ' . number_format($subtotal, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
url: route('admin.manage.restocks.index'), url: route('admin.manage.restocks.index'),
); );
@ -219,7 +219,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
->get() ->get()
->mapWithKeys(function (ProductVariant $variant) use ($priceType) { ->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $priceType); ->first(fn($p) => $p->type === $priceType);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });

View File

@ -53,18 +53,18 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'orderItems.productVariant.product:id,name', 'orderItems.productVariant.product:id,name',
]) ])
->when($search, function ($q) use ($search) { ->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) $q->whereHas('orderItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('order_number', 'like', "%{$search}%") ->orWhere('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel)) ->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType)) ->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId)) ->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId)) ->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById)) ->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
->when($filters['date_from'] ?? null, fn ($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom)) ->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
->when($filters['date_to'] ?? null, fn ($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo)) ->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -99,14 +99,14 @@ public function getSummary(array $filters = []): array
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount') ->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount') ->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs') ->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel)) ->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType)) ->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId)) ->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId)) ->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById)) ->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
->when($filters['date_from'] ?? null, fn ($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom)) ->when($filters['date_from'] ?? null, fn($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
->when($filters['date_to'] ?? null, fn ($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo)) ->when($filters['date_to'] ?? null, fn($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
->first(); ->first();
return [ return [
@ -134,9 +134,9 @@ public function getFilterOptions(): array
->with('userProfile:id,user_id,full_name') ->with('userProfile:id,user_id,full_name')
->orderBy('id') ->orderBy('id')
->get() ->get()
->filter(fn (User $user) => $user->userProfile?->full_name) ->filter(fn(User $user) => $user->userProfile?->full_name)
->values() ->values()
->map(fn (User $user) => [ ->map(fn(User $user) => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->userProfile->full_name, 'name' => $user->userProfile->full_name,
]), ]),
@ -162,7 +162,7 @@ public function getForCreate(): array
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null; : null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $prices = $variant->productPrices->mapWithKeys(fn($p) => [$p->type->value => $p->price]);
$variant->prices = $prices; $variant->prices = $prices;
}); });
}), }),
@ -176,11 +176,11 @@ public function getForCreate(): array
->with('userProfile:id,user_id,full_name') ->with('userProfile:id,user_id,full_name')
->orderBy('id') ->orderBy('id')
->get() ->get()
->filter(fn (User $user) => $user->userProfile?->full_name) ->filter(fn(User $user) => $user->userProfile?->full_name)
->values(), ->values(),
'channelOptions' => OrderChannel::toSelect(), 'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(), 'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(), 'priceTypeOptions' => PriceType::toSelect()->filter(fn($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]; ];
} }
@ -210,7 +210,7 @@ public function getForEdit(Order $order): array
'photo_url' => $media 'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->file_name)
: null, : null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [ 'items' => $order->orderItems->map(fn(OrderItem $item) => [
'id' => $item->id, 'id' => $item->id,
'product_variant_id' => $item->product_variant_id, 'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
@ -270,7 +270,7 @@ public function create(array $data): Order
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Transaksi Baru', title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
url: route('admin.manage.transactions.index'), url: route('admin.manage.transactions.index'),
); );
@ -379,14 +379,14 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType); ->first(fn($p) => $p->type === $resolvedPriceType);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) { $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === PriceType::CAPITAL); ->first(fn($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
@ -429,6 +429,6 @@ private function generateOrderNumber(): string
$sequence = 1; $sequence = 1;
} }
return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT); return $prefix . $date . str_pad($sequence, 4, '0', STR_PAD_LEFT);
} }
} }

View File

@ -3,58 +3,11 @@
namespace App\Services\Concerns; namespace App\Services\Concerns;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
trait RegistersMedia trait RegistersMedia
{ {
private function registerMediaFromBase64(
Model $model,
string $base64Data,
string $collectionName,
string $folder,
): void {
$dataUrl = $base64Data;
$mime = 'image/jpeg';
if (preg_match('/^data:(.+?);base64,/', $dataUrl, $matches)) {
$mime = $matches[1];
$dataUrl = preg_replace('/^data:.+?;base64,/', '', $dataUrl);
}
$binary = base64_decode($dataUrl, true);
if ($binary === false) {
return;
}
$extension = match ($mime) {
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
default => 'jpg',
};
$date = now()->format('Y/m/d');
$uuid = Str::uuid();
$key = "{$folder}/{$date}/{$uuid}/photo.{$extension}";
Storage::disk('s3')->put($key, $binary, [
'ContentType' => $mime,
'ACL' => 'public-read',
]);
$this->registerMedia(
model: $model,
s3Key: $key,
collectionName: $collectionName,
mimeType: $mime,
fileSize: strlen($binary),
orderColumn: 1,
);
}
private function registerMedia( private function registerMedia(
Model $model, Model $model,
string $s3Key, string $s3Key,

View File

@ -21,14 +21,14 @@ public function run(): void
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'], 'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
'categories' => ['view', 'create', 'update', 'delete'], 'categories' => ['view', 'create', 'update', 'delete'],
'customers' => ['view', 'create', 'update', 'delete'], 'customers' => ['view', 'create', 'update', 'delete'],
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'transfer_stock', 'view_stock_mutations'], 'products' => ['view', 'create', 'update', 'delete', 'toggle_status'],
'stocks' => ['view'], 'stocks' => ['view'],
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'], 'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'], 'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
'cash' => ['view', 'deposit', 'withdraw', 'update', 'delete'], 'cash' => ['view', 'deposit', 'withdraw', 'update', 'delete'],
'expenses' => ['view', 'create', 'update', 'delete'], 'expenses' => ['view', 'create', 'update', 'delete'],
'activity_logs' => ['view'], 'activity_logs' => ['view'],
'employee_advances' => ['view', 'create', 'update', 'delete', 'pay', 'view_payments', 'verify'], 'employee_advances' => ['view', 'create', 'update', 'delete', 'pay', 'verify'],
'payroll' => ['view', 'pay', 'cancel', 'adjust'], 'payroll' => ['view', 'pay', 'cancel', 'adjust'],
'owner_verifications' => ['view', 'verify', 'reject'], 'owner_verifications' => ['view', 'verify', 'reject'],
'restocks' => ['view', 'create', 'update', 'delete'], 'restocks' => ['view', 'create', 'update', 'delete'],
@ -60,7 +60,7 @@ public function run(): void
$developerOwnerPerms = array_values(array_filter( $developerOwnerPerms = array_values(array_filter(
$allPermissions, $allPermissions,
fn($p) => ! in_array($p, $excludedFromDeveloperOwner, true) fn ($p) => ! in_array($p, $excludedFromDeveloperOwner, true)
)); ));
$rolePermissions = [ $rolePermissions = [
@ -92,6 +92,7 @@ public function run(): void
'analysis.top_products', 'analysis.top_products',
'analysis.marketing_sales', 'analysis.marketing_sales',
'stocks.view',
'stok_opnames.view', 'stok_opnames.view',
'attendances.view', 'attendances.view',
@ -123,8 +124,6 @@ public function run(): void
'products.update', 'products.update',
'products.delete', 'products.delete',
'products.toggle_status', 'products.toggle_status',
'products.transfer_stock',
'products.view_stock_mutations',
'owner_verifications.view', 'owner_verifications.view',
@ -154,12 +153,9 @@ public function run(): void
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay', 'employee_advances.pay',
'employee_advances.view_payments',
'payroll.view', 'payroll.view',
'payroll.adjust', 'payroll.adjust',
'payroll.pay',
'payroll.cancel',
'settings.view_system', 'settings.view_system',
'settings.update_system', 'settings.update_system',
@ -224,9 +220,9 @@ public function run(): void
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay', 'employee_advances.pay',
'employee_advances.view_payments',
'payroll.view', 'payroll.view',
'payroll.adjust',
], true); ], true);
})), })),
@ -254,6 +250,7 @@ public function run(): void
'raw_materials.create', 'raw_materials.create',
'raw_materials.update', 'raw_materials.update',
'raw_materials.toggle_status', 'raw_materials.toggle_status',
'stocks.view',
'owner_verifications.view', 'owner_verifications.view',
@ -311,7 +308,6 @@ public function run(): void
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay', 'employee_advances.pay',
'employee_advances.view_payments',
'payroll.view', 'payroll.view',
], true); ], true);
@ -350,7 +346,6 @@ public function run(): void
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay', 'employee_advances.pay',
'employee_advances.view_payments',
'payroll.view', 'payroll.view',
], true); ], true);
@ -366,7 +361,6 @@ public function run(): void
'dashboard.orders_payment', 'dashboard.orders_payment',
'dashboard.orders_marketing', 'dashboard.orders_marketing',
'dashboard.orders_status', 'dashboard.orders_status',
'analysis.view', 'analysis.view',
'analysis.attendance', 'analysis.attendance',
'analysis.cash', 'analysis.cash',
@ -381,38 +375,29 @@ public function run(): void
'analysis.top_customers', 'analysis.top_customers',
'analysis.top_products', 'analysis.top_products',
'analysis.marketing_sales', 'analysis.marketing_sales',
'employees.view', 'employees.view',
'attendances.view', 'attendances.view',
'attendances.create', 'attendances.create',
'attendances.delete', 'attendances.delete',
'attendances.manage', 'attendances.manage',
'leave_requests.view', 'leave_requests.view',
'leave_requests.create', 'leave_requests.create',
'leave_requests.update', 'leave_requests.update',
'leave_requests.delete', 'leave_requests.delete',
'categories.view', 'categories.view',
'customers.view', 'customers.view',
'products.view', 'products.view',
'stocks.view',
'orders.view', 'orders.view',
'cuttings.view',
'cash.view', 'cash.view',
'expenses.view', 'expenses.view',
'activity_logs.view', 'activity_logs.view',
'employee_advances.view', 'employee_advances.view',
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'payroll.view', 'payroll.view',
], true); ], true);
})), })),
@ -420,23 +405,19 @@ public function run(): void
'non-operator' => array_values(array_filter($allPermissions, function ($p) { 'non-operator' => array_values(array_filter($allPermissions, function ($p) {
return in_array($p, [ return in_array($p, [
'dashboard.view', 'dashboard.view',
'analysis.view', 'analysis.view',
'analysis.attendance', 'analysis.attendance',
'attendances.view', 'attendances.view',
'attendances.create', 'attendances.create',
'leave_requests.view', 'leave_requests.view',
'leave_requests.create', 'leave_requests.create',
'leave_requests.update', 'leave_requests.update',
'leave_requests.delete', 'leave_requests.delete',
'employee_advances.view', 'employee_advances.view',
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'payroll.view', 'payroll.view',
], true); ], true);
})), })),
@ -444,28 +425,24 @@ public function run(): void
'stok-opname' => array_values(array_filter($allPermissions, function ($p) { 'stok-opname' => array_values(array_filter($allPermissions, function ($p) {
return in_array($p, [ return in_array($p, [
'dashboard.view', 'dashboard.view',
'attendances.view', 'attendances.view',
'attendances.create', 'attendances.create',
'leave_requests.view', 'leave_requests.view',
'leave_requests.create', 'leave_requests.create',
'leave_requests.update', 'leave_requests.update',
'leave_requests.delete', 'leave_requests.delete',
'products.view', 'products.view',
'stocks.view',
'stok_opnames.view', 'stok_opnames.view',
'stok_opnames.create', 'stok_opnames.create',
'stok_opnames.update', 'stok_opnames.update',
'stok_opnames.delete', 'stok_opnames.delete',
'stok_opnames.submit', 'stok_opnames.submit',
'employee_advances.view', 'employee_advances.view',
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'payroll.view', 'payroll.view',
], true); ], true);
})), })),

View File

@ -1,3 +1,7 @@
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';
interface LocationMapProps { interface LocationMapProps {
latitude: number; latitude: number;
longitude: number; longitude: number;
@ -9,18 +13,52 @@ export function LocationMap({
latitude, latitude,
longitude, longitude,
height = '250px', height = '250px',
zoom = 17, zoom = 15,
}: LocationMapProps) { }: LocationMapProps) {
const src = `https://maps.google.com/maps?q=${latitude},${longitude}&z=${zoom}&t=k&output=embed`; const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) {
return;
}
const map = L.map(mapRef.current, {
center: [latitude, longitude],
zoom,
zoomControl: false,
attributionControl: true,
});
L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const icon = L.divIcon({
html: `<div style="background: #ef4444; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>`,
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
});
L.marker([latitude, longitude], { icon }).addTo(map);
mapInstanceRef.current = map;
return () => {
map.remove();
mapInstanceRef.current = null;
};
}, [latitude, longitude, zoom]);
return ( return (
<iframe <div
src={src} ref={mapRef}
style={{ height, width: '100%', border: 0 }} style={{ height, width: '100%' }}
className="rounded-lg" className="rounded-lg"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Lokasi Presensi"
/> />
); );
} }

View File

@ -20,7 +20,7 @@ export function createCashAccountColumns(
): ColumnDef<CashAccount>[] { ): ColumnDef<CashAccount>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<CashAccount>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: () => <span>Nama</span>, header: () => <span>Nama</span>,
@ -39,10 +39,7 @@ export function createCashAccountColumns(
</span> </span>
), ),
}, },
]; {
if (can('cash.update') || can('cash.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -73,8 +70,6 @@ export function createCashAccountColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -1,7 +1,7 @@
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
export type CashTransaction = { export type CashTransaction = {
id: number; id: number;
@ -30,12 +30,23 @@ function getTypeLabel(type: string): string {
deposit: 'Deposit', deposit: 'Deposit',
withdrawal: 'Withdrawal', withdrawal: 'Withdrawal',
expense: 'Pengeluaran', expense: 'Pengeluaran',
employee_advance: 'Kasbon', transfer: 'Transfer',
}; };
return labels[type] ?? type; return labels[type] ?? type;
} }
function getReferenceLabel(type: string): string {
const labels: Record<string, string> = {
'App\\Models\\Expense': 'Pengeluaran',
'App\\Models\\Order': 'Penjualan Tunai',
'App\\Models\\Purchase': 'Belanja',
'App\\Models\\CashAccount': 'Transfer Kas',
};
return labels[type] ?? '-';
}
type CreateColumnsParams = { type CreateColumnsParams = {
handleEdit: (transaction: CashTransaction) => void; handleEdit: (transaction: CashTransaction) => void;
handleDeleteClick: (transaction: CashTransaction) => void; handleDeleteClick: (transaction: CashTransaction) => void;
@ -47,7 +58,7 @@ export function createTransactionColumns(
): ColumnDef<CashTransaction>[] { ): ColumnDef<CashTransaction>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<CashTransaction>[] = [ return [
{ {
accessorKey: 'formatted_created_at', accessorKey: 'formatted_created_at',
header: () => <span>Tanggal</span>, header: () => <span>Tanggal</span>,
@ -66,6 +77,11 @@ export function createTransactionColumns(
<span className="font-medium"> <span className="font-medium">
{getTypeLabel(transaction.type)} {getTypeLabel(transaction.type)}
</span> </span>
<span className="text-xs text-muted-foreground">
{getReferenceLabel(
transaction.reference?.type ?? '',
)}
</span>
</div> </div>
); );
}, },
@ -136,10 +152,7 @@ export function createTransactionColumns(
return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>; return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>;
}, },
}, },
]; {
if (can('cash.update') || can('cash.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -177,8 +190,6 @@ export function createTransactionColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -91,7 +91,7 @@ export function createEmployeeAdvanceColumns(
const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } = const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } =
params; params;
const columns: ColumnDef<EmployeeAdvance>[] = [ return [
{ {
accessorKey: 'formatted_created_at', accessorKey: 'formatted_created_at',
header: () => <span>Tanggal</span>, header: () => <span>Tanggal</span>,
@ -162,16 +162,7 @@ export function createEmployeeAdvanceColumns(
<span>{getStatusBadge(row.getValue('status') as string)}</span> <span>{getStatusBadge(row.getValue('status') as string)}</span>
), ),
}, },
]; {
if (
can('employee_advances.verify') ||
can('employee_advances.pay') ||
can('employee_advances.view_payments') ||
can('employee_advances.update') ||
can('employee_advances.delete')
) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -207,7 +198,7 @@ export function createEmployeeAdvanceColumns(
{ {
label: 'Riwayat', label: 'Riwayat',
icon: <History className="h-4 w-4" />, icon: <History className="h-4 w-4" />,
show: can('employee_advances.view_payments') && employeeAdvance.payments.length > 0, show: employeeAdvance.payments.length > 0,
onClick: () => handleShowPayments(employeeAdvance), onClick: () => handleShowPayments(employeeAdvance),
}, },
{ {
@ -235,8 +226,6 @@ export function createEmployeeAdvanceColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -30,7 +30,7 @@ export function createExpenseColumns(
): ColumnDef<Expense>[] { ): ColumnDef<Expense>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<Expense>[] = [ return [
{ {
accessorKey: 'formatted_created_at', accessorKey: 'formatted_created_at',
header: () => <span>Tanggal</span>, header: () => <span>Tanggal</span>,
@ -69,8 +69,8 @@ export function createExpenseColumns(
accessorKey: 'formatted_amount', accessorKey: 'formatted_amount',
header: () => <span>Jumlah</span>, header: () => <span>Jumlah</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium"> <span className="font-medium text-red-600">
{row.getValue('formatted_amount') as string} - {row.getValue('formatted_amount') as string}
</span> </span>
), ),
}, },
@ -83,10 +83,7 @@ export function createExpenseColumns(
return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>; return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>;
}, },
}, },
]; {
if (can('expenses.update') || can('expenses.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -117,8 +114,6 @@ export function createExpenseColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -174,57 +174,58 @@ export function createPayrollPeriodColumns(
}); });
} }
columns.push({ columns.push(
accessorKey: 'status', {
header: () => <span>Status</span>, accessorKey: 'status',
cell: ({ row }) => ( header: () => <span>Status</span>,
<span>{getStatusBadge(row.getValue('status') as string)}</span> cell: ({ row }) => (
), <span>{getStatusBadge(row.getValue('status') as string)}</span>
}); ),
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
}, },
cell: ({ row }) => { {
const period = row.original; id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const period = row.original;
return ( return (
<RowActions <RowActions
actions={[ actions={[
{ {
label: 'Lihat Detail', label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />, icon: <Eye className="h-4 w-4" />,
href: showUrl(period.id), href: showUrl(period.id),
}, },
{ {
label: 'Tutup Periode', label: 'Tutup Periode',
icon: ( icon: (
<Lock className="h-4 w-4 text-orange-600" /> <Lock className="h-4 w-4 text-orange-600" />
), ),
show: show:
can('payroll.adjust') && can('payroll.adjust') &&
period.status === 'open', period.status === 'open',
onClick: () => handleClose(period), onClick: () => handleClose(period),
}, },
{ {
label: 'Buka Periode', label: 'Buka Periode',
icon: ( icon: (
<Unlock className="h-4 w-4 text-blue-600" /> <Unlock className="h-4 w-4 text-blue-600" />
), ),
show: show:
can('payroll.adjust') && can('payroll.adjust') &&
period.status === 'closed', period.status === 'closed',
onClick: () => handleReopen(period), onClick: () => handleReopen(period),
}, },
]} ]}
/> />
); );
},
}, },
}); );
return columns; return columns;
} }

View File

@ -18,7 +18,6 @@ import { toast } from 'sonner';
import { CameraCapture } from '@/components/camera-capture'; import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map'; import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
@ -45,7 +44,6 @@ type Attendance = {
check_out_latitude: number | null; check_out_latitude: number | null;
check_out_longitude: number | null; check_out_longitude: number | null;
work_duration_minutes: number | null; work_duration_minutes: number | null;
employee_name: string;
}; };
type MonthStats = { type MonthStats = {
@ -60,12 +58,11 @@ type Props = {
todayAttendance: Attendance | null; todayAttendance: Attendance | null;
currentYear: number; currentYear: number;
currentMonth: number; currentMonth: number;
monthStats: MonthStats | null; monthStats: MonthStats;
hrSettings: { hrSettings: {
scheduled_check_in_time: string; scheduled_check_in_time: string;
scheduled_check_out_time: string; scheduled_check_out_time: string;
}; };
isAdmin: boolean;
}; };
const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab']; const WEEKDAYS = ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'];
@ -86,6 +83,16 @@ function isSameDay(d1: Date, d2: Date): boolean {
); );
} }
function checkIsToday(date: Date): boolean {
const t = new Date();
return (
date.getDate() === t.getDate() &&
date.getMonth() === t.getMonth() &&
date.getFullYear() === t.getFullYear()
);
}
function isWeekend(date: Date): boolean { function isWeekend(date: Date): boolean {
const day = date.getDay(); const day = date.getDay();
@ -98,8 +105,8 @@ function isLate(
officeMinute: number, officeMinute: number,
): boolean { ): boolean {
if (!checkInAt) { if (!checkInAt) {
return false; return false;
} }
const d = new Date(checkInAt); const d = new Date(checkInAt);
const h = d.getHours(); const h = d.getHours();
@ -114,8 +121,8 @@ function getLateMinutes(
officeMinute: number, officeMinute: number,
): number { ): number {
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) { if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
return 0; return 0;
} }
const d = new Date(checkInAt); const d = new Date(checkInAt);
const officeStart = new Date(d); const officeStart = new Date(d);
@ -126,15 +133,15 @@ function getLateMinutes(
function formatMinutes(minutes: number | null): string { function formatMinutes(minutes: number | null): string {
if (!minutes) { if (!minutes) {
return '-'; return '-';
} }
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60); const mins = Math.floor(minutes % 60);
if (mins === 0) { if (mins === 0) {
return `${hours} jam`; return `${hours} jam`;
} }
return `${hours} jam ${mins} menit`; return `${hours} jam ${mins} menit`;
} }
@ -146,7 +153,6 @@ export default function AttendanceIndex({
currentMonth, currentMonth,
monthStats, monthStats,
hrSettings, hrSettings,
isAdmin,
}: Props) { }: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
.split(':') .split(':')
@ -167,17 +173,21 @@ export default function AttendanceIndex({
const viewYear = viewDate.getFullYear(); const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1; const viewMonth = viewDate.getMonth() + 1;
const attendanceByDate = useMemo(() => { const attendanceDates = useMemo(() => {
const map = new Map<string, Attendance[]>(); const dates = new Map<string, Attendance>();
attendances.forEach((att) => { attendances.forEach((att) => {
const existing = map.get(att.attendance_date) ?? []; dates.set(att.attendance_date, att);
existing.push(att);
map.set(att.attendance_date, existing);
}); });
return map; return dates;
}, [attendances]); }, [attendances]);
const selectedAttendance = useMemo(() => {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
return attendanceDates.get(dateStr) ?? null;
}, [selectedDate, attendanceDates]);
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth); const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
@ -222,51 +232,8 @@ export default function AttendanceIndex({
return days; return days;
}, [viewYear, viewMonth]); }, [viewYear, viewMonth]);
const handlePrevMonth = () => { const handlePrevMonth = () => setViewDate((d) => subMonths(d, 1));
const newDate = subMonths(viewDate, 1); const handleNextMonth = () => setViewDate((d) => addMonths(d, 1));
setViewDate(newDate);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{
year: newDate.getFullYear(),
month: newDate.getMonth() + 1,
},
{ preserveState: true, preserveScroll: true },
);
}
};
const handleNextMonth = () => {
const newDate = addMonths(viewDate, 1);
setViewDate(newDate);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{
year: newDate.getFullYear(),
month: newDate.getMonth() + 1,
},
{ preserveState: true, preserveScroll: true },
);
}
};
const handleGoToToday = () => {
const now = new Date();
setViewDate(now);
setSelectedDate(now);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{ year: now.getFullYear(), month: now.getMonth() + 1 },
{ preserveState: true, preserveScroll: true },
);
}
};
const handleCameraCapture = (dataUrl: string) => { const handleCameraCapture = (dataUrl: string) => {
setShowCamera(false); setShowCamera(false);
@ -323,8 +290,8 @@ export default function AttendanceIndex({
function formatTime(dateStr: string | null): string { function formatTime(dateStr: string | null): string {
if (!dateStr) { if (!dateStr) {
return '-'; return '-';
} }
return format(new Date(dateStr), 'HH:mm'); return format(new Date(dateStr), 'HH:mm');
} }
@ -338,168 +305,167 @@ export default function AttendanceIndex({
<> <>
<Head title="Presensi" /> <Head title="Presensi" />
<div className="flex min-h-full flex-1 flex-col gap-6 overflow-auto p-4 md:p-6"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Presensi Presensi
</h2> </h2>
</div> </div>
{!isAdmin && ( {/* Alert Status Presensi */}
<Alert> <Alert>
<CalendarCheck className="h-4 w-4" /> <CalendarCheck className="h-4 w-4" />
<AlertTitle>Presensi Hari Ini</AlertTitle> <AlertTitle>Presensi Hari Ini</AlertTitle>
<AlertDescription> <AlertDescription>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{hasCheckedIn ? ( {hasCheckedIn ? (
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Masuk: Masuk:
</span> </span>
<span className="font-medium"> <span className="font-medium">
{formatTime( {formatTime(
todayAttendance?.check_in_at, todayAttendance?.check_in_at,
)}
</span>
</div>
<div className="flex items-center gap-1.5">
{hasCheckedOut ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_out_at,
)}
</span>
</>
) : (
<>
<Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">
Pulang:
</span>
<span className="font-medium text-orange-600">
Belum
</span>
</>
)} )}
</div> </span>
</div> </div>
) : ( <div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground"> {hasCheckedOut ? (
Anda belum melakukan presensi hari ini <>
</p> <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
)} <span className="text-muted-foreground">
</div> Pulang:
</span>
<div className="flex items-center gap-2"> <span className="font-medium">
{locationLoading && ( {formatTime(
<span className="text-xs text-muted-foreground"> todayAttendance?.check_out_at,
Mendapatkan lokasi... )}
</span> </span>
)} </>
<Button ) : (
size="sm" <>
onClick={handleCheckIn} <Clock className="h-3.5 w-3.5 text-orange-500" />
disabled={hasCheckedIn || locationLoading} <span className="text-muted-foreground">
> Pulang:
<LogIn className="mr-1.5 h-3.5 w-3.5" /> </span>
Presensi Masuk <span className="font-medium text-orange-600">
</Button> Belum
<Button </span>
size="sm" </>
variant="outline" )}
onClick={handleCheckOut} </div>
disabled={ </div>
!hasCheckedIn || ) : (
hasCheckedOut || <p className="text-sm text-muted-foreground">
locationLoading Anda belum melakukan presensi hari ini
} </p>
> )}
<LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang
</Button>
</div>
</div> </div>
</AlertDescription>
</Alert>
)}
{monthStats && ( <div className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> {locationLoading && (
<Card> <span className="text-xs text-muted-foreground">
<CardContent className="flex items-center gap-3 py-3"> Mendapatkan lokasi...
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100"> </span>
<CalendarDays className="h-5 w-5 text-blue-600" /> )}
</div> <Button
<div> size="sm"
<p className="text-xs text-muted-foreground"> onClick={handleCheckIn}
Hari Kerja disabled={hasCheckedIn || locationLoading}
</p> >
<p className="text-lg font-bold"> <LogIn className="mr-1.5 h-3.5 w-3.5" />
{monthStats.working_days} Presensi Masuk
</p> </Button>
</div> <Button
</CardContent> size="sm"
</Card> variant="outline"
<Card> onClick={handleCheckOut}
<CardContent className="flex items-center gap-3 py-3"> disabled={
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-green-100"> !hasCheckedIn ||
<CheckCircle2 className="h-5 w-5 text-green-600" /> hasCheckedOut ||
</div> locationLoading
<div> }
<p className="text-xs text-muted-foreground"> >
Hadir <LogOut className="mr-1.5 h-3.5 w-3.5" />
</p> Presensi Pulang
<p className="text-lg font-bold"> </Button>
{monthStats.present} </div>
</p> </div>
</div> </AlertDescription>
</CardContent> </Alert>
</Card>
<Card> {/* Summary Stats */}
<CardContent className="flex items-center gap-3 py-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100"> <Card>
<UserX className="h-5 w-5 text-red-600" /> <CardContent className="flex items-center gap-3 py-3">
</div> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100">
<div> <CalendarDays className="h-5 w-5 text-blue-600" />
<p className="text-xs text-muted-foreground"> </div>
Tidak Hadir <div>
</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold"> Hari Kerja
{monthStats.absent} </p>
</p> <p className="text-lg font-bold">
</div> {monthStats.working_days}
</CardContent> </p>
</Card> </div>
<Card> </CardContent>
<CardContent className="flex items-center gap-3 py-3"> </Card>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100"> <Card>
<Wallet className="h-5 w-5 text-amber-600" /> <CardContent className="flex items-center gap-3 py-3">
</div> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-green-100">
<div> <CheckCircle2 className="h-5 w-5 text-green-600" />
<p className="text-xs text-muted-foreground"> </div>
Cuti <div>
</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold"> Hadir
{monthStats.leave} </p>
</p> <p className="text-lg font-bold">
</div> {monthStats.present}
</CardContent> </p>
</Card> </div>
</div> </CardContent>
)} </Card>
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-100">
<UserX className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">
Tidak Hadir
</p>
<p className="text-lg font-bold">
{monthStats.absent}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100">
<Wallet className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">
Cuti
</p>
<p className="text-lg font-bold">
{monthStats.leave}
</p>
</div>
</CardContent>
</Card>
</div>
<Card <Card
className="overflow-hidden" className="overflow-hidden"
style={{ '--card-spacing': '0px' } as React.CSSProperties} style={{ '--card-spacing': '0px' } as React.CSSProperties}
> >
{/* Calendar Header */}
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border"> <div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
@ -537,7 +503,10 @@ export default function AttendanceIndex({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 px-3 text-xs font-medium" className="h-8 px-3 text-xs font-medium"
onClick={handleGoToToday} onClick={() => {
setViewDate(new Date());
setSelectedDate(new Date());
}}
> >
Hari ini Hari ini
</Button> </Button>
@ -552,6 +521,7 @@ export default function AttendanceIndex({
</div> </div>
</div> </div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 border-b"> <div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div <div
@ -563,10 +533,11 @@ export default function AttendanceIndex({
))} ))}
</div> </div>
{/* Calendar Grid */}
<div className="grid grid-cols-7"> <div className="grid grid-cols-7">
{calendarDays.map((cell, idx) => { {calendarDays.map((cell, idx) => {
const dateStr = format(cell.date, 'yyyy-MM-dd'); const dateStr = format(cell.date, 'yyyy-MM-dd');
const dayAttendances = attendanceByDate.get(dateStr) ?? []; const attendance = attendanceDates.get(dateStr);
const isSelected = isSameDay( const isSelected = isSameDay(
cell.date, cell.date,
selectedDate, selectedDate,
@ -576,33 +547,47 @@ export default function AttendanceIndex({
new Date(), new Date(),
); );
const todayMidnight = new Date(); const today = new Date();
todayMidnight.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const cellDate = new Date(cell.date); const cellDate = new Date(cell.date);
cellDate.setHours(0, 0, 0, 0); cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < todayMidnight; const isPastDate = cellDate < today;
const showAbsent = const showAbsent =
cell.isCurrentMonth && cell.isCurrentMonth &&
isPastDate && isPastDate &&
!isWeekend(cell.date) && !isWeekend(cell.date) &&
dayAttendances.length === 0; !attendance;
const late = attendance
? isLate(
attendance.check_in_at,
officeHour,
officeMinute,
)
: false;
const lateMins = attendance
? getLateMinutes(
attendance.check_in_at,
officeHour,
officeMinute,
)
: 0;
return ( return (
<div <button
key={idx} key={idx}
onClick={() => { onClick={() => {
setSelectedDate(cell.date); setSelectedDate(cell.date);
if (!isAdmin && dayAttendances.length > 0) {
setDetailAttendance(dayAttendances[0]); if (attendance) {
} setDetailAttendance(attendance);
}
}} }}
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${ className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
!cell.isCurrentMonth !cell.isCurrentMonth
? 'bg-muted/30 text-muted-foreground/50' ? 'bg-muted/30 text-muted-foreground/50'
: '' : ''
} ${isSelected ? 'bg-muted/50' : ''} ${
!isAdmin && dayAttendances.length > 0 ? 'cursor-pointer' : ''
}`} }`}
style={{ style={{
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
@ -622,89 +607,43 @@ export default function AttendanceIndex({
{cell.day} {cell.day}
</span> </span>
</div> </div>
<div className="mt-1 flex flex-col gap-0.5 overflow-hidden"> <div className="mt-1 flex flex-col gap-0.5">
{isAdmin ? ( {attendance && (
<> <>
{dayAttendances.map((att) => { <span
const late = isLate( className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
att.check_in_at, >
officeHour, {late
officeMinute, ? 'Terlambat'
); : 'Hadir'}
</span>
return ( <span className="text-[10px] text-muted-foreground">
<button Masuk :{' '}
key={att.id} {formatTime(
onClick={(e) => { attendance.check_in_at,
e.stopPropagation(); )}
setDetailAttendance(att); </span>
}} <span className="text-[10px] text-muted-foreground">
className="w-full" Pulang :{' '}
> {formatTime(
<Badge attendance.check_out_at,
variant={late ? 'destructive' : 'default'} )}
className="w-full justify-center cursor-pointer truncate" </span>
> {late && (
{att.employee_name} <span className="text-[10px] text-yellow-600">
</Badge> Telat :{' '}
</button> {formatMinutes(
); lateMins,
})} )}{' '}
</> menit
) : ( </span>
<> )}
{dayAttendances.length > 0 && (() => { <span className="text-[10px] text-muted-foreground">
const att = dayAttendances[0]; Jam Kerja :{' '}
const late = isLate( {formatMinutes(
att.check_in_at, attendance.work_duration_minutes,
officeHour, )}
officeMinute, </span>
);
const lateMins = getLateMinutes(
att.check_in_at,
officeHour,
officeMinute,
);
return (
<>
<span
className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
>
{late
? 'Terlambat'
: 'Hadir'}
</span>
<span className="text-[10px] text-muted-foreground">
Masuk :{' '}
{formatTime(
att.check_in_at,
)}
</span>
<span className="text-[10px] text-muted-foreground">
Pulang :{' '}
{formatTime(
att.check_out_at,
)}
</span>
{late && (
<span className="text-[10px] text-yellow-600">
Telat :{' '}
{formatMinutes(
lateMins,
)}{' '}
menit
</span>
)}
<span className="text-[10px] text-muted-foreground">
Jam Kerja :{' '}
{formatMinutes(
att.work_duration_minutes,
)}
</span>
</>
);
})()}
</> </>
)} )}
{showAbsent && ( {showAbsent && (
@ -713,7 +652,7 @@ export default function AttendanceIndex({
</span> </span>
)} )}
</div> </div>
</div> </button>
); );
})} })}
</div> </div>
@ -734,9 +673,6 @@ export default function AttendanceIndex({
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isAdmin && detailAttendance?.employee_name
? `${detailAttendance.employee_name} - `
: ''}
Detail Presensi -{' '} Detail Presensi -{' '}
{detailAttendance && {detailAttendance &&
format( format(
@ -813,37 +749,19 @@ export default function AttendanceIndex({
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground"> Lokasi Presensi
Lokasi Masuk </span>
</span> <LocationMap
<LocationMap latitude={
latitude={ detailAttendance.check_in_latitude
detailAttendance.check_in_latitude }
} longitude={
longitude={ detailAttendance.check_in_longitude
detailAttendance.check_in_longitude }
} height="200px"
height="200px" />
/>
</div>
{detailAttendance.check_out_latitude && detailAttendance.check_out_longitude && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Lokasi Pulang
</span>
<LocationMap
latitude={
detailAttendance.check_out_latitude
}
longitude={
detailAttendance.check_out_longitude
}
height="200px"
/>
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -54,7 +54,7 @@ export function createEmployeeColumns(
canViewAll, canViewAll,
} = params; } = params;
const columns: ColumnDef<Employee>[] = [ return [
{ {
accessorKey: 'user_profile.full_name', accessorKey: 'user_profile.full_name',
id: 'full_name', id: 'full_name',
@ -154,14 +154,7 @@ export function createEmployeeColumns(
); );
}, },
}, },
]; {
if (
can('employees.update') ||
can('employees.reset_password') ||
can('employees.delete')
) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -200,8 +193,6 @@ export function createEmployeeColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -161,41 +161,35 @@ export default function EmployeeEdit({ employee, roles, canViewAll }: Props) {
* *
</span> </span>
</Label> </Label>
{canViewAll ? ( <Combobox
<Combobox items={roles}
items={roles} itemToStringLabel={(r) => r.name}
itemToStringLabel={(r) => r.name} value={selectedRole}
value={selectedRole} onValueChange={(value) =>
onValueChange={(value) => setSelectedRole(value)
setSelectedRole(value) }
} >
> <ComboboxInput
<ComboboxInput placeholder="Cari role..."
placeholder="Cari role..." className="w-full"
className="w-full" />
/> <ComboboxContent>
<ComboboxContent> <ComboboxEmpty>
<ComboboxEmpty> Tidak ada role
Tidak ada role ditemukan.
ditemukan. </ComboboxEmpty>
</ComboboxEmpty> <ComboboxList>
<ComboboxList> {(role) => (
{(role) => ( <ComboboxItem
<ComboboxItem key={role.id}
key={role.id} value={role}
value={role} >
> {role.name}
{role.name} </ComboboxItem>
</ComboboxItem> )}
)} </ComboboxList>
</ComboboxList> </ComboboxContent>
</ComboboxContent> </Combobox>
</Combobox>
) : (
<div className="flex h-10 w-full items-center rounded-md border border-input bg-muted px-3 py-2 text-sm">
{selectedRole?.name ?? '-'}
</div>
)}
<InputError <InputError
message={errors.role} message={errors.role}
/> />

View File

@ -64,7 +64,7 @@ export function createLeaveRequestColumns(
const { handleEdit, handleDeleteClick, handleApprove, handleReject, can } = const { handleEdit, handleDeleteClick, handleApprove, handleReject, can } =
params; params;
const columns: ColumnDef<LeaveRequest>[] = [ return [
{ {
id: 'employee_name', id: 'employee_name',
header: () => <span>Oleh</span>, header: () => <span>Oleh</span>,
@ -112,14 +112,7 @@ export function createLeaveRequestColumns(
<span>{getStatusBadge(row.getValue('status') as string)}</span> <span>{getStatusBadge(row.getValue('status') as string)}</span>
), ),
}, },
]; {
if (
can('leave_requests.verify') ||
can('leave_requests.update') ||
can('leave_requests.delete')
) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -174,8 +167,6 @@ export function createLeaveRequestColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -139,27 +139,25 @@ export function CuttingCardRow({
)} )}
</div> </div>
{(can('cuttings.update') || can('cuttings.delete')) && ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('cuttings.update'),
show: can('cuttings.update'), onClick: () => onEdit(cutting),
onClick: () => onEdit(cutting), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('cuttings.delete'),
show: can('cuttings.delete'), onClick: () => onDelete(cutting),
onClick: () => onDelete(cutting), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -126,27 +126,25 @@ export function PurchaseCardRow({
)} )}
</div> </div>
{(can('purchases.update') || can('purchases.delete')) && ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('purchases.update'),
show: can('purchases.update'), onClick: () => onEdit(purchase),
onClick: () => onEdit(purchase), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('purchases.delete'),
show: can('purchases.delete'), onClick: () => onDelete(purchase),
onClick: () => onDelete(purchase), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -135,27 +135,25 @@ export function RestockCardRow({
</div> </div>
</div> </div>
{(can('restocks.update') || can('restocks.delete')) && ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('restocks.update'),
show: can('restocks.update'), onClick: () => onEdit(restock),
onClick: () => onEdit(restock), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('restocks.delete'),
show: can('restocks.delete'), onClick: () => onDelete(restock),
onClick: () => onDelete(restock), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -183,59 +183,57 @@ export function TransactionCardRow({
</div> </div>
</div> </div>
{(can('orders.update') || can('orders.delete')) && ( <RowActions
<RowActions actions={[
actions={[ ...(transaction.status === 'pending' && can('orders.update')
...(transaction.status === 'pending' && can('orders.update') ? [
? [ {
{ label: 'Kirim',
label: 'Kirim', icon: <Send className="h-4 w-4" />,
icon: <Send className="h-4 w-4" />, onClick: () => onUpdateStatus(transaction, 'processing'),
onClick: () => onUpdateStatus(transaction, 'processing'), },
}, ]
] : []),
: []), ...((transaction.status === 'pending' || transaction.status === 'processing') && can('orders.update')
...((transaction.status === 'pending' || transaction.status === 'processing') && can('orders.update') ? [
? [ {
{ label: 'Selesai',
label: 'Selesai', icon: <CheckCircle className="h-4 w-4" />,
icon: <CheckCircle className="h-4 w-4" />, onClick: () => onUpdateStatus(transaction, 'completed'),
onClick: () => onUpdateStatus(transaction, 'completed'), },
}, {
{ label: 'Dibatalkan',
label: 'Dibatalkan', icon: <XCircle className="h-4 w-4 text-destructive" />,
icon: <XCircle className="h-4 w-4 text-destructive" />, onClick: () => onUpdateStatus(transaction, 'cancelled'),
onClick: () => onUpdateStatus(transaction, 'cancelled'), },
}, ]
] : []),
: []), ...(transaction.status === 'completed' && can('orders.update')
...(transaction.status === 'completed' && can('orders.update') ? [
? [ {
{ label: 'Refund',
label: 'Refund', icon: <XCircle className="h-4 w-4 text-destructive" />,
icon: <XCircle className="h-4 w-4 text-destructive" />, onClick: () => onUpdateStatus(transaction, 'refunded'),
onClick: () => onUpdateStatus(transaction, 'refunded'), },
}, ]
] : []),
: []), {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('orders.update'),
show: can('orders.update'), onClick: () => onEdit(transaction),
onClick: () => onEdit(transaction), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('orders.delete'),
show: can('orders.delete'), onClick: () => onDelete(transaction),
onClick: () => onDelete(transaction), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -18,7 +18,7 @@ export function createCategoryColumns(
): ColumnDef<Category>[] { ): ColumnDef<Category>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<Category>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: () => <span>Nama</span>, header: () => <span>Nama</span>,
@ -28,10 +28,7 @@ export function createCategoryColumns(
</span> </span>
), ),
}, },
]; {
if (can('categories.update') || can('categories.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -62,8 +59,6 @@ export function createCategoryColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -20,7 +20,7 @@ export function createCustomerColumns(
): ColumnDef<Customer>[] { ): ColumnDef<Customer>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<Customer>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: () => <span>Nama</span>, header: () => <span>Nama</span>,
@ -46,10 +46,7 @@ export function createCustomerColumns(
</span> </span>
), ),
}, },
]; {
if (can('customers.update') || can('customers.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -80,8 +77,6 @@ export function createCustomerColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -90,7 +90,7 @@ export function createProductColumns(
can, can,
} = params; } = params;
const columns: ColumnDef<Product>[] = [ return [
{ {
id: 'expand', id: 'expand',
header: '', header: '',
@ -338,10 +338,7 @@ export function createProductColumns(
); );
}, },
}, },
]; {
if (can('products.update') || can('products.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -372,8 +369,6 @@ export function createProductColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -144,27 +144,25 @@ export function ProductCardRow({
</div> </div>
</div> </div>
{(can('products.update') || can('products.delete')) && ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('products.update'),
show: can('products.update'), onClick: () => onEdit(product),
onClick: () => onEdit(product), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('products.delete'),
show: can('products.delete'), onClick: () => onDelete(product),
onClick: () => onDelete(product), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -34,8 +34,6 @@ export function VariantSubRow({
variant: ProductVariant; variant: ProductVariant;
} | null>(null); } | null>(null);
const canAnyAction = can('products.transfer_stock') || can('products.view_stock_mutations') || can('products.update') || can('products.delete');
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
@ -54,18 +52,16 @@ export function VariantSubRow({
</TableHead> </TableHead>
<TableHead className="text-center">Stok Ecer</TableHead> <TableHead className="text-center">Stok Ecer</TableHead>
<TableHead>Harga</TableHead> <TableHead>Harga</TableHead>
{canAnyAction && ( <TableHead className="w-[120px] text-center">
<TableHead className="w-[120px] text-center"> Aksi
Aksi </TableHead>
</TableHead>
)}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{variants.length === 0 ? ( {variants.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={canAnyAction ? 8 : 7} colSpan={8}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
Tidak ada varian. Tidak ada varian.
@ -120,65 +116,63 @@ export function VariantSubRow({
'-' '-'
)} )}
</TableCell> </TableCell>
{canAnyAction && ( <TableCell>
<TableCell> <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Transfer Stok',
label: 'Transfer Stok', icon: (
icon: ( <ArrowRightLeft className="h-4 w-4" />
<ArrowRightLeft className="h-4 w-4" /> ),
), show: can('stocks.view'),
show: can('products.transfer_stock'), onClick: () =>
onClick: () => setTransferVariant({
setTransferVariant({ product,
product, variant,
variant, }),
},
{
label: 'Mutasi Stok',
icon: (
<ScrollText className="h-4 w-4" />
),
show: can('stocks.view'),
onClick: () => {
router.visit(
stockMutations.url({
product: product.id,
variant: variant.id,
}), }),
);
}, },
{ },
label: 'Mutasi Stok', {
icon: ( label: 'Edit',
<ScrollText className="h-4 w-4" /> icon: (
<Pencil className="h-4 w-4" />
),
show: can('products.update'),
onClick: () =>
onEditVariant(
product,
variant,
), ),
show: can('products.view_stock_mutations'), },
onClick: () => { {
router.visit( label: 'Hapus',
stockMutations.url({ icon: (
product: product.id, <Trash2 className="h-4 w-4 text-destructive" />
variant: variant.id, ),
}), show: can('products.delete'),
); onClick: () =>
}, onDeleteVariantClick(
}, product,
{ variant,
label: 'Edit',
icon: (
<Pencil className="h-4 w-4" />
), ),
show: can('products.update'), },
onClick: () => ]}
onEditVariant( />
product, </TableCell>
variant,
),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: can('products.delete'),
onClick: () =>
onDeleteVariantClick(
product,
variant,
),
},
]}
/>
</TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}

View File

@ -94,27 +94,25 @@ export function RawMaterialCardRow({
</div> </div>
</div> </div>
{(can('raw_materials.update') || can('raw_materials.delete')) && ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: <Pencil className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, show: can('raw_materials.update'),
show: can('raw_materials.update'), onClick: () => onEdit(rawMaterial),
onClick: () => onEdit(rawMaterial), },
}, {
{ label: 'Hapus',
label: 'Hapus', icon: (
icon: ( <Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="h-4 w-4 text-destructive" /> ),
), show: can('raw_materials.delete'),
show: can('raw_materials.delete'), onClick: () => onDelete(rawMaterial),
onClick: () => onDelete(rawMaterial), },
}, ]}
]} wrapperClassName="flex shrink-0 items-center gap-1"
wrapperClassName="flex shrink-0 items-center gap-1" />
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -47,8 +47,6 @@ export function RawMaterialVariantSubRow({
); );
} }
const canAnyAction = can('raw_materials.update') || can('raw_materials.delete');
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
@ -61,18 +59,16 @@ export function RawMaterialVariantSubRow({
<TableHead>Nama Varian</TableHead> <TableHead>Nama Varian</TableHead>
<TableHead>Harga</TableHead> <TableHead>Harga</TableHead>
<TableHead className="text-center">Stok</TableHead> <TableHead className="text-center">Stok</TableHead>
{canAnyAction && ( <TableHead className="w-[100px] text-center">
<TableHead className="w-[100px] text-center"> Aksi
Aksi </TableHead>
</TableHead>
)}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{variants.length === 0 ? ( {variants.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={canAnyAction ? 6 : 5} colSpan={6}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
Tidak ada varian. Tidak ada varian.
@ -105,39 +101,37 @@ export function RawMaterialVariantSubRow({
<TableCell className="text-center"> <TableCell className="text-center">
{formatNumber(variant.stock)} {formatNumber(variant.stock)}
</TableCell> </TableCell>
{canAnyAction && ( <TableCell>
<TableCell> <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Edit',
label: 'Edit', icon: (
icon: ( <Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4" /> ),
), show: can('raw_materials.update'),
show: can('raw_materials.update'), onClick: () => {
onClick: () => { router.visit(
router.visit( variantEdit.url({
variantEdit.url({ rawMaterial:
rawMaterial: rawMaterial.id,
rawMaterial.id, variant: variant.id,
variant: variant.id, }),
}), );
);
},
}, },
{ },
label: 'Hapus', {
icon: ( label: 'Hapus',
<Trash2 className="h-4 w-4 text-destructive" /> icon: (
), <Trash2 className="h-4 w-4 text-destructive" />
show: can('raw_materials.delete'), ),
onClick: () => show: can('raw_materials.delete'),
setDeletingVariant(variant), onClick: () =>
}, setDeletingVariant(variant),
]} },
/> ]}
</TableCell> />
)} </TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View File

@ -20,7 +20,7 @@ export function createSupplierColumns(
): ColumnDef<Supplier>[] { ): ColumnDef<Supplier>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<Supplier>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: () => <span>Nama</span>, header: () => <span>Nama</span>,
@ -46,10 +46,7 @@ export function createSupplierColumns(
</span> </span>
), ),
}, },
]; {
if (can('suppliers.update') || can('suppliers.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -80,8 +77,6 @@ export function createSupplierColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -19,7 +19,7 @@ export function createRoleColumns(
): ColumnDef<Role>[] { ): ColumnDef<Role>[] {
const { handleEdit, handleDeleteClick, can } = params; const { handleEdit, handleDeleteClick, can } = params;
const columns: ColumnDef<Role>[] = [ return [
{ {
accessorKey: 'name', accessorKey: 'name',
header: () => <span>Nama Role</span>, header: () => <span>Nama Role</span>,
@ -44,10 +44,7 @@ export function createRoleColumns(
</span> </span>
), ),
}, },
]; {
if (can('roles.update') || can('roles.delete')) {
columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
@ -78,8 +75,6 @@ export function createRoleColumns(
/> />
); );
}, },
}); },
} ];
return columns;
} }

View File

@ -45,8 +45,8 @@
Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy')->middleware('permission:products.delete'); Route::delete('products/{product}/variants/{variant}', [ProductVariantController::class, 'destroy'])->name('products.variants.destroy')->middleware('permission:products.delete');
Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit')->middleware('permission:products.update'); Route::get('products/{product}/variants/{variant}/edit', [ProductVariantController::class, 'edit'])->name('products.variants.edit')->middleware('permission:products.update');
Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update')->middleware('permission:products.update'); Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update')->middleware('permission:products.update');
Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock')->middleware('permission:products.transfer_stock'); Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock')->middleware('permission:products.update');
Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations')->middleware('permission:products.view_stock_mutations'); Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations')->middleware('permission:products.view');
Route::resource('raw-materials', RawMaterialController::class)->except(['show'])->middleware('permission:raw_materials.view|raw_materials.create|raw_materials.update|raw_materials.delete'); Route::resource('raw-materials', RawMaterialController::class)->except(['show'])->middleware('permission:raw_materials.view|raw_materials.create|raw_materials.update|raw_materials.delete');
Route::post('raw-materials/{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])->name('raw-materials.toggle-status')->middleware('permission:raw_materials.toggle_status'); Route::post('raw-materials/{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])->name('raw-materials.toggle-status')->middleware('permission:raw_materials.toggle_status');
@ -101,8 +101,8 @@
Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password')->middleware('permission:employees.reset_password'); Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password')->middleware('permission:employees.reset_password');
Route::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index')->middleware('permission:attendances.view'); Route::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index')->middleware('permission:attendances.view');
Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store')->middleware('permission:attendances.create'); Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store')->middleware('permission:attendances.manage');
Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update')->middleware('permission:attendances.create'); Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update')->middleware('permission:attendances.manage');
Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date')->middleware('permission:attendances.view'); Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date')->middleware('permission:attendances.view');
Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit'])->middleware('permission:leave_requests.view|leave_requests.create|leave_requests.update|leave_requests.delete'); Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit'])->middleware('permission:leave_requests.view|leave_requests.create|leave_requests.update|leave_requests.delete');