Compare commits

...

7 Commits

Author SHA1 Message Date
Yoga Pangestu
95df2e9dc7 feat: optimize cash overview calculations for total balance, deposits, and withdrawals 2026-08-13 21:20:53 +07:00
Yoga Pangestu
083cde60f9 feat: add disabled state to toggle employee status based on permissions 2026-08-13 21:12:44 +07:00
Yoga Pangestu
8053ea2db4 feat: Enhance leave request and transaction management with user-specific data
- Added current user ID to leave request index for better context.
- Updated transaction management to include user-specific filters and summaries.
- Enhanced analysis service methods to incorporate user context for attendance and revenue metrics.
- Modified dashboard service to reflect user-specific attendance and financial summaries.
- Implemented role-based access control in leave request service for update and delete actions.
- Adjusted frontend components to utilize user-specific data for leave requests and transactions.
- Removed unused permissions from role seeder for cleaner access control.
2026-08-13 21:08:39 +07:00
Yoga Pangestu
5e07a1b910 feat: update LeaveRequest status check to use LeaveRequestStatus enum 2026-08-13 18:23:00 +07:00
Yoga Pangestu
74a76168ab feat: add disabled state to toggle status and featured components based on permissions 2026-08-13 18:14:12 +07:00
Yoga Pangestu
867517f034 feat: enhance employee advances migration logic to update status values 2026-08-13 17:44:29 +07:00
Yoga Pangestu
cc227b8114 feat: update application settings by removing obsolete fields and adding new HR and homepage settings 2026-08-13 17:42:24 +07:00
26 changed files with 442 additions and 212 deletions

View File

@ -29,6 +29,7 @@ public function index(PaginatedRequest $request): Response
'filterOptions' => [
'statusOptions' => LeaveRequestStatus::toSelect(),
],
'currentUserId' => $request->user()->id,
]);
}

View File

@ -28,13 +28,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
$filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id', 'date_from', 'date_to']);
$user = $request->user();
return Inertia::render('admin/manage/transaction/index', [
'transactions' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $filters,
user: $user,
),
'summary' => $this->service->getSummary($filters),
'summary' => $this->service->getSummary($filters, $user),
'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(),
]);

View File

@ -25,25 +25,25 @@ public function index(Request $request): Response
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$attendance = $this->service->getAttendanceStats($startDate, $endDate);
$attendance = $this->service->getAttendanceStats($startDate, $endDate, $user);
$myAttendance = $this->service->getMyAttendance($user, $startDate, $endDate);
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
$rawMaterialStock = $this->service->getRawMaterialStock();
$productStock = $this->service->getProductStock();
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate);
$revenueByPaymentType = $this->service->getRevenueByPaymentType($startDate, $endDate);
$expenseSummary = $this->service->getExpenseSummary($startDate, $endDate);
$monthlyExpense = $this->service->getMonthlyExpense($startDate, $endDate);
$busyHours = $this->service->getBusyHours($startDate, $endDate);
$profitMetrics = $this->service->getProfitMetrics($startDate, $endDate);
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate, $user);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate, $user);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate, $user);
$revenueByPaymentType = $this->service->getRevenueByPaymentType($startDate, $endDate, $user);
$expenseSummary = $this->service->getExpenseSummary($startDate, $endDate, $user);
$monthlyExpense = $this->service->getMonthlyExpense($startDate, $endDate, $user);
$busyHours = $this->service->getBusyHours($startDate, $endDate, $user);
$profitMetrics = $this->service->getProfitMetrics($startDate, $endDate, $user);
$topSuppliers = $this->service->getTopSuppliers($startDate, $endDate);
$topCustomers = $this->service->getTopCustomers($startDate, $endDate);
$topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
$orderStats = $this->service->getOrderStats($startDate, $endDate);
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate);
$topCustomers = $this->service->getTopCustomers($startDate, $endDate, $user);
$topProducts = $this->service->getTopProducts($startDate, $endDate, $user);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate, $user);
$orderStats = $this->service->getOrderStats($startDate, $endDate, $user);
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate, $user);
return Inertia::render('admin/analysis/index', [
'filters' => [

View File

@ -18,10 +18,10 @@ public function __invoke(Request $request): Response
$user = $request->user();
return Inertia::render('dashboard', [
'attendance' => $this->service->getAttendanceStats(),
'revenueSummary' => $this->service->getRevenueSummary(),
'expenseSummary' => $this->service->getExpenseSummary(),
'orderStats' => $this->service->getOrderStats(),
'attendance' => $this->service->getAttendanceStats($user),
'revenueSummary' => $this->service->getRevenueSummary($user),
'expenseSummary' => $this->service->getExpenseSummary($user),
'orderStats' => $this->service->getOrderStats($user),
'todayAttendance' => $this->service->getTodayAttendance($user),
'isOnLeave' => $this->service->isOnLeave($user),
'canCheckIn' => $user->employee !== null,

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\HR;
use App\Enums\LeaveRequestStatus;
use App\Models\LeaveRequest;
use Illuminate\Foundation\Http\FormRequest;
@ -31,7 +32,7 @@ function ($attribute, $value, $fail) use ($leaveRequest) {
$query = LeaveRequest::where('employee_id', $employee->id)
->where('start_date', $value)
->where('status', '!=', LeaveRequest::cancelled());
->where('status', '!=', LeaveRequestStatus::CANCELLED);
if ($leaveRequest) {
$query->where('id', '!=', $leaveRequest->id);

View File

@ -13,7 +13,16 @@ public function migrate(): array
{
$results = [];
$results['employee_advances'] = $this->migrateTable('employee_advances');
$results['employee_advances'] = $this->migrateTable('employee_advances', function ($row) {
$row['status'] = match ($row['status'] ?? '') {
'approved' => 'disbursed',
'paid' => 'repaid',
'cancelled' => 'rejected',
default => $row['status'],
};
return $row;
});
$results['employee_advance_payments'] = $this->migrateTable('employee_advance_payments');
$results['attendances'] = $this->migrateTable('attendances');
$results['leave_requests'] = $this->migrateTable('leave_requests');

View File

@ -69,6 +69,12 @@ public function store(array $data): LeaveRequest
public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
{
if (! $this->canModify($leaveRequest)) {
throw ValidationException::withMessages([
'employee' => 'Anda tidak memiliki akses untuk mengubah cuti ini.',
]);
}
return DB::transaction(function () use ($leaveRequest, $data) {
$startDate = new Carbon($data['start_date']);
$endDate = new Carbon($data['end_date']);
@ -86,6 +92,12 @@ public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
public function destroy(LeaveRequest $leaveRequest): bool
{
if (! $this->canModify($leaveRequest)) {
throw ValidationException::withMessages([
'employee' => 'Anda tidak memiliki akses untuk menghapus cuti ini.',
]);
}
return $leaveRequest->delete();
}
@ -130,4 +142,13 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
return $leaveRequest;
}
private function canModify(LeaveRequest $leaveRequest): bool
{
if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
return true;
}
return $leaveRequest->employee->user_id === auth()->id();
}
}

View File

@ -39,7 +39,7 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?User $user = null): LengthAwarePaginator
{
$paginator = Order::query()
->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at'])
@ -53,6 +53,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'orderItems.productVariant.product:id,name',
])
->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id))
->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('order_number', 'like', "%{$search}%")
@ -103,14 +104,15 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getSummary(array $filters = []): array
public function getSummary(array $filters = [], ?User $user = null): array
{
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(discount) + SUM(COALESCE(nego_price, 0)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
@ -123,9 +125,9 @@ public function getSummary(array $filters = []): array
return [
'total_orders' => $query->total_orders,
'total_subtotal' => $query->total_subtotal,
'total_discount' => $query->total_discount,
'total_amount' => $query->total_amount,
'total_discount' => $query->total_discount,
'total_deduction' => $query->total_deduction,
'net_total' => $query->total_amount - $query->total_cogs,
];
}
@ -397,4 +399,12 @@ private function generateOrderNumber(): string
return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT);
}
private function isMarketingUser(User $user): bool
{
return $user->hasAnyRole([
Role::MARKETING_OFFLINE->value,
Role::MARKETING_ONLINE->value,
]);
}
}

View File

@ -8,6 +8,7 @@
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\RawMaterialUnit;
use App\Enums\Role;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
@ -20,15 +21,65 @@
use App\Models\RestockItem;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class AnalysisService
{
public function getAttendanceStats(?string $startDate, ?string $endDate): array
public function getAttendanceStats(?string $startDate, ?string $endDate, ?User $user = null): array
{
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
if ($user && $this->isMarketingUser($user)) {
$employee = $user->employee;
if (! $employee) {
return [
'total_employees' => 0,
'present' => 0,
'absent' => 0,
'on_leave' => 0,
'percentage' => 0,
];
}
$isWithinRange = $employee->join_date <= $end
&& (is_null($employee->resign_date) || $employee->resign_date >= $start);
if (! $isWithinRange) {
return [
'total_employees' => 0,
'present' => 0,
'absent' => 0,
'on_leave' => 0,
'percentage' => 0,
];
}
$present = Attendance::where('employee_id', $employee->id)
->whereBetween('attendance_date', [$start->toDateString(), $end->toDateString()])
->distinct('employee_id')
->count('employee_id');
$onLeave = LeaveRequest::approved()
->where('start_date', '<=', $end)
->where('end_date', '>=', $start)
->where('employee_id', $employee->id)
->count();
$workingDays = $this->countWorkingDays($start, $end);
$absent = max(0, $workingDays - $present - $onLeave);
return [
'total_employees' => 1,
'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
$employees = Employee::whereHas('user', fn ($q) => $q
->where('is_active', true)
->whereHas('roles', fn ($r) => $r
@ -83,7 +134,7 @@ public function getMyAttendance(User $user, ?string $startDate, ?string $endDate
$current = $start->copy();
while ($current->lte($end)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
if ($current->dayOfWeek !== Carbon::SUNDAY) {
$workingDays++;
}
$current->addDay();
@ -133,11 +184,14 @@ public function getCashOverview(?string $startDate, ?string $endDate): array
$transactions = $cashAccount->cashTransactions();
$this->applyDateFilter($transactions, $startDate, $endDate, 'cash_transactions.created_at');
$totalDeposit = (clone $transactions)->where('type', CashTransactionType::DEPOSIT)->sum('amount');
$totalWithdrawal = (clone $transactions)->where('type', CashTransactionType::WITHDRAWAL)->sum('amount');
return [
'total_balance' => $cashAccount->balance,
'total_balance' => (int) $totalDeposit - (int) $totalWithdrawal,
'total_transactions' => (clone $transactions)->count(),
'total_deposit' => (int) (clone $transactions)->where('type', CashTransactionType::DEPOSIT)->sum('amount'),
'total_withdrawal' => (int) (clone $transactions)->where('type', CashTransactionType::WITHDRAWAL)->sum('amount'),
'total_deposit' => (int) $totalDeposit,
'total_withdrawal' => (int) $totalWithdrawal,
];
}
@ -189,11 +243,15 @@ public function getProductStock(): array
];
}
public function getRevenueSummary(?string $startDate, ?string $endDate): array
public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -213,11 +271,15 @@ public function getRevenueSummary(?string $startDate, ?string $endDate): array
];
}
public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
public function getMonthlyRevenue(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
@ -240,11 +302,15 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
return $monthly->values()->toArray();
}
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END), 0) as store")
@ -263,11 +329,15 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate)
return $monthly->values()->toArray();
}
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
public function getRevenueByPaymentType(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$data = (clone $query)
->select('payment_type')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
@ -282,8 +352,33 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate): a
return $data->toArray();
}
public function getExpenseSummary(?string $startDate, ?string $endDate): array
public function getExpenseSummary(?string $startDate, ?string $endDate, ?User $user = null): array
{
if ($user && $this->isMarketingUser($user)) {
$employee = $user->employee;
$expenseQuery = Expense::where('created_by_id', $user->id);
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
if ($employee) {
$advanceQuery->where('employee_id', $employee->id);
} else {
$advanceQuery->whereRaw('0 = 1');
}
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
$expenseTotal = (clone $expenseQuery)->sum('amount');
$advanceTotal = (clone $advanceQuery)->sum('amount');
return [
'total' => (int) ($expenseTotal + $advanceTotal),
'purchase_total' => 0,
'expense_total' => (int) $expenseTotal,
'advance_total' => (int) $advanceTotal,
];
}
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
@ -305,8 +400,54 @@ public function getExpenseSummary(?string $startDate, ?string $endDate): array
];
}
public function getMonthlyExpense(?string $startDate, ?string $endDate): array
public function getMonthlyExpense(?string $startDate, ?string $endDate, ?User $user = null): array
{
if ($user && $this->isMarketingUser($user)) {
$employee = $user->employee;
$expenseMonthly = Expense::where('created_by_id', $user->id);
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
$expenseByMonth = (clone $expenseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
if ($employee) {
$advanceMonthly->where('employee_id', $employee->id);
} else {
$advanceMonthly->whereRaw('0 = 1');
}
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = [];
foreach ([$expenseByMonth, $advanceByMonth] as $data) {
foreach ($data as $month => $row) {
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = ['month' => $month, 'total' => 0, 'purchase' => 0, 'expense' => 0, 'advance' => 0];
}
}
}
foreach ($allMonths as $month => &$row) {
$row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0);
$row['total'] = $row['expense'] + $row['advance'];
}
return array_values($allMonths);
}
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
@ -356,11 +497,15 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
return array_values($allMonths);
}
public function getBusyHours(?string $startDate, ?string $endDate): array
public function getBusyHours(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$hours = range(0, 23);
$hourCounts = (clone $query)
->selectRaw('HOUR(created_at) as hour')
@ -377,11 +522,15 @@ public function getBusyHours(?string $startDate, ?string $endDate): array
}, $hours);
}
public function getProfitMetrics(?string $startDate, ?string $endDate): array
public function getProfitMetrics(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -427,11 +576,15 @@ public function getTopSuppliers(?string $startDate, ?string $endDate): array
->toArray();
}
public function getTopCustomers(?string $startDate, ?string $endDate): array
public function getTopCustomers(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
return (clone $query)->toBase()
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
@ -444,11 +597,15 @@ public function getTopCustomers(?string $startDate, ?string $endDate): array
->toArray();
}
public function getTopProducts(?string $startDate, ?string $endDate): array
public function getTopProducts(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
return (clone $query)->toBase()
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
@ -463,11 +620,15 @@ public function getTopProducts(?string $startDate, ?string $endDate): array
->toArray();
}
public function getRevenueTrend(?string $startDate, ?string $endDate): array
public function getRevenueTrend(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
return (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw('DATE(orders.created_at) as date')
@ -482,12 +643,16 @@ public function getRevenueTrend(?string $startDate, ?string $endDate): array
->toArray();
}
public function getMarketingSales(?string $startDate, ?string $endDate): array
public function getMarketingSales(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)
->whereNotNull('orders.marketing_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
$orders = (clone $query)
->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
@ -521,11 +686,15 @@ public function getMarketingSales(?string $startDate, ?string $endDate): array
})->toArray();
}
public function getOrderStats(?string $startDate, ?string $endDate): array
public function getOrderStats(?string $startDate, ?string $endDate, ?User $user = null): array
{
$baseQuery = Order::query();
$this->applyDateFilter($baseQuery, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$baseQuery->where('orders.marketing_id', $user->id);
}
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
@ -592,4 +761,36 @@ private function applyDateFilter($query, ?string $startDate, ?string $endDate, s
$query->whereDate($dateColumn, '<=', $endDate);
}
}
private function isMarketingUser(User $user): bool
{
return $user->hasAnyRole([
Role::MARKETING_OFFLINE->value,
Role::MARKETING_ONLINE->value,
]);
}
private function applyMarketingFilter(Builder $query, User $user, string $column = 'marketing_id'): Builder
{
if ($this->isMarketingUser($user)) {
$query->where($column, $user->id);
}
return $query;
}
private function countWorkingDays(Carbon $start, Carbon $end): int
{
$workingDays = 0;
$current = $start->copy();
while ($current->lte($end)) {
if ($current->dayOfWeek !== Carbon::SUNDAY) {
$workingDays++;
}
$current->addDay();
}
return $workingDays;
}
}

View File

@ -5,6 +5,7 @@
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\Role;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
@ -13,13 +14,49 @@
use App\Models\Order;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class DashboardService
{
public function getAttendanceStats(): array
public function getAttendanceStats(?User $user = null): array
{
$today = Carbon::now()->toDateString();
if ($user && $this->isMarketingUser($user)) {
$employee = $user->employee;
if (! $employee) {
return [
'total_employees' => 0,
'present' => 0,
'absent' => 0,
'on_leave' => 0,
'percentage' => 0,
];
}
$present = Attendance::where('employee_id', $employee->id)
->where('attendance_date', $today)
->count();
$onLeave = LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', $today)
->where('end_date', '>=', $today)
->count();
$isWorking = ! in_array(Carbon::now()->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY]);
$absent = $isWorking && $present === 0 && $onLeave === 0 ? 1 : 0;
return [
'total_employees' => 1,
'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
'percentage' => $present > 0 ? 100 : 0,
];
}
$totalEmployees = Employee::whereHas(
'user',
fn ($q) => $q
@ -80,12 +117,16 @@ public function getAttendanceStats(): array
];
}
public function getRevenueSummary(): array
public function getRevenueSummary(?User $user = null): array
{
$today = Carbon::now()->toDateString();
$baseQuery = Order::where('status', OrderStatus::COMPLETED)
->whereDate('created_at', $today);
if ($user && $this->isMarketingUser($user)) {
$baseQuery->where('marketing_id', $user->id);
}
$stats = (clone $baseQuery)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -104,10 +145,38 @@ public function getRevenueSummary(): array
];
}
public function getExpenseSummary(): array
public function getExpenseSummary(?User $user = null): array
{
$today = Carbon::now()->toDateString();
if ($user && $this->isMarketingUser($user)) {
$employee = $user->employee;
$expenseTotal = Expense::whereDate('created_at', $today)
->where('created_by_id', $user->id)
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
$advanceQuery = EmployeeAdvance::whereDate('created_at', $today)
->disbursed();
if ($employee) {
$advanceQuery->where('employee_id', $employee->id);
} else {
$advanceQuery->whereRaw('0 = 1');
}
$advanceTotal = $advanceQuery
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
return [
'total' => (int) $expenseTotal->total + (int) $advanceTotal->total,
'expense_total' => (int) $expenseTotal->total,
'advance_total' => (int) $advanceTotal->total,
];
}
$expenseTotal = Expense::whereDate('created_at', $today)
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
@ -124,11 +193,15 @@ public function getExpenseSummary(): array
];
}
public function getOrderStats(): array
public function getOrderStats(?User $user = null): array
{
$today = Carbon::now()->toDateString();
$baseQuery = Order::whereDate('created_at', $today);
if ($user && $this->isMarketingUser($user)) {
$baseQuery->where('marketing_id', $user->id);
}
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
@ -231,4 +304,21 @@ public function isOnLeave(User $user): bool
->where('end_date', '>=', now()->toDateString())
->exists();
}
private function isMarketingUser(User $user): bool
{
return $user->hasAnyRole([
Role::MARKETING_OFFLINE->value,
Role::MARKETING_ONLINE->value,
]);
}
private function applyMarketingFilter(Builder $query, User $user, string $column = 'marketing_id'): Builder
{
if ($this->isMarketingUser($user)) {
$query->where($column, $user->id);
}
return $query;
}
}

View File

@ -92,7 +92,6 @@ public function run(): void
'analysis.profit_margin',
'analysis.top_customers',
'analysis.top_products',
'analysis.top_suppliers',
'analysis.marketing_sales',
'stok_opnames.view',
@ -381,7 +380,6 @@ public function run(): void
'analysis.profit_margin',
'analysis.top_customers',
'analysis.top_products',
'analysis.top_suppliers',
'analysis.marketing_sales',
'employees.view',

View File

@ -14,8 +14,6 @@ public function up(): void
$blueprint->add('about_app', '');
$blueprint->add('email', '');
$blueprint->add('phone', '');
$blueprint->add('logo', '');
$blueprint->add('login_cover', '');
});
$this->migrator->inGroup('social_media', function (SettingsBlueprint $blueprint): void {
@ -44,5 +42,25 @@ public function up(): void
$blueprint->add('shopee_pre_order', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_live_extra', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
});
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_in_time', '08:00');
$blueprint->add('scheduled_check_out_time', '17:00');
$blueprint->add('late_penalty_amount', 0);
$blueprint->add('absent_penalty_amount', 0);
});
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
$blueprint->add('hero_image_url', 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80');
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('gallery_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
]);
});
}
};

View File

@ -1,15 +0,0 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('system', function (SettingsBlueprint $blueprint): void {
$blueprint->delete('logo');
$blueprint->delete('login_cover');
});
}
};

View File

@ -1,70 +0,0 @@
<?php
use App\Support\Marketplace\MarketplaceFeeRule;
use Illuminate\Support\Facades\DB;
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('marketplace', function (SettingsBlueprint $blueprint): void {
$oldKeys = [
'tiktok_shop_admin_fee',
'tiktok_shop_transaction_fee',
'tiktok_shop_payment_fee',
'tiktok_shop_affiliate_commission',
'tiktok_shop_shipping_subsidy',
'tiktok_shop_vat_rate',
'shopee_commission_fee',
'shopee_transaction_fee',
'shopee_service_fee',
'shopee_payment_fee',
'shopee_affiliate_commission',
'shopee_shipping_subsidy',
'shopee_voucher_fee',
];
$existing = DB::table('settings')
->where('group', 'marketplace')
->pluck('name')
->toArray();
foreach ($oldKeys as $key) {
if (in_array($key, $existing)) {
$blueprint->delete($key);
}
}
$newFields = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
$current = DB::table('settings')
->where('group', 'marketplace')
->pluck('name')
->toArray();
foreach ($newFields as $key) {
if (! in_array($key, $current)) {
$blueprint->add($key, MarketplaceFeeRule::defaultPercent(0.0)->toArray());
}
}
});
}
};

View File

@ -1,17 +0,0 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_in_time', '08:00');
$blueprint->add('scheduled_check_out_time', '17:00');
$blueprint->add('late_penalty_amount', 0);
$blueprint->add('absent_penalty_amount', 0);
});
}
};

View File

@ -1,23 +0,0 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
$blueprint->add('hero_image_url', 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80');
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('gallery_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
]);
});
}
};

View File

@ -1,18 +0,0 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
$blueprint->delete('deal_badge');
$blueprint->delete('deal_title');
$blueprint->delete('deal_description');
$blueprint->delete('deal_cta_text');
$blueprint->delete('deal_images');
});
}
};

View File

@ -6,6 +6,7 @@ type ToggleStatusProps = {
checked: boolean;
onToggle?: () => void;
label?: string;
disabled?: boolean;
wrapperClassName?: string;
};
@ -14,6 +15,7 @@ export function ToggleStatus({
checked,
onToggle,
label,
disabled = false,
wrapperClassName = 'flex items-center gap-2',
}: ToggleStatusProps) {
function handleToggle() {
@ -27,6 +29,7 @@ export function ToggleStatus({
size="sm"
checked={checked}
onCheckedChange={handleToggle}
disabled={disabled}
/>
{label && (
<span

View File

@ -105,7 +105,7 @@ const hrItems: NavMenuItem[] = [
const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_marketplace', 'settings.view_hr'] },
{ title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' },
{ title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' },
// { title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' },
];
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {

View File

@ -178,6 +178,7 @@ export function createEmployeeColumns(
url={toggleActiveUrl(employee.id)}
checked={employee.is_active}
wrapperClassName="flex items-center justify-center gap-1"
disabled={!can('employees.toggle_status')}
/>
</div>
);

View File

@ -13,6 +13,7 @@ export type LeaveRequest = {
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
created_at: string;
employee: {
user_id: number;
user: {
user_profile: {
full_name: string;
@ -56,12 +57,13 @@ type CreateColumnsParams = {
handleApprove: (leaveRequest: LeaveRequest) => void;
handleReject: (leaveRequest: LeaveRequest) => void;
can: (permission: string) => boolean;
currentUserId: number;
};
export function createLeaveRequestColumns(
params: CreateColumnsParams,
): ColumnDef<LeaveRequest>[] {
const { handleEdit, handleDeleteClick, handleApprove, handleReject, can } =
const { handleEdit, handleDeleteClick, handleApprove, handleReject, can, currentUserId } =
params;
const columns: ColumnDef<LeaveRequest>[] = [
@ -157,7 +159,8 @@ export function createLeaveRequestColumns(
icon: <Pencil className="h-4 w-4" />,
show:
can('leave_requests.update') &&
leaveRequest.status === 'pending',
leaveRequest.status === 'pending' &&
leaveRequest.employee?.user_id === currentUserId,
onClick: () => handleEdit(leaveRequest),
},
{
@ -167,7 +170,8 @@ export function createLeaveRequestColumns(
),
show:
can('leave_requests.delete') &&
leaveRequest.status === 'pending',
leaveRequest.status === 'pending' &&
leaveRequest.employee?.user_id === currentUserId,
onClick: () => handleDeleteClick(leaveRequest),
},
]}

View File

@ -50,6 +50,7 @@ type Props = {
filterOptions: {
statusOptions: StatusOption[];
};
currentUserId: number;
};
function toLocalDateString(date: Date): string {
@ -59,7 +60,7 @@ function toLocalDateString(date: Date): string {
return `${year}-${month}-${day}`;
}
export default function LeaveRequestIndex({ leaveRequests, filters, filterOptions }: Props) {
export default function LeaveRequestIndex({ leaveRequests, filters, filterOptions, currentUserId }: Props) {
const { can } = useCan();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<LeaveRequest | null>(null);
@ -151,6 +152,7 @@ export default function LeaveRequestIndex({ leaveRequests, filters, filterOption
handleApprove: (leaveRequest) => setApproving(leaveRequest),
handleReject: (leaveRequest) => setRejecting(leaveRequest),
can,
currentUserId,
});
const filterToolbar = (

View File

@ -67,9 +67,9 @@ type Props = {
};
summary: {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
total_discount: number;
total_deduction: number;
net_total: number;
};
filters: {

View File

@ -122,6 +122,15 @@ export function TransactionCardRow({
?.full_name ?? '-'}
</span>
</span>
{transaction.marketing && (
<span className="text-muted-foreground">
Marketing:{' '}
<span className="font-medium text-foreground">
{transaction.marketing.user_profile
?.full_name ?? '-'}
</span>
</span>
)}
{transaction.customer && (
<span className="text-muted-foreground">
Pelanggan:{' '}

View File

@ -2,6 +2,7 @@ import {
Banknote,
CircleDollarSign,
FileText,
Minus,
Percent,
TrendingUp,
} from 'lucide-react';
@ -10,9 +11,9 @@ import { formatCurrency } from '@/lib/utils';
type Summary = {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
total_discount: number;
total_deduction: number;
net_total: number;
};
@ -30,8 +31,8 @@ const summaryItems = [
format: (value: number) => value.toLocaleString('id-ID'),
},
{
key: 'total_subtotal',
label: 'Subtotal',
key: 'total_amount',
label: 'Total',
icon: Banknote,
color: 'bg-emerald-100',
iconColor: 'text-emerald-600',
@ -46,11 +47,11 @@ const summaryItems = [
format: formatCurrency,
},
{
key: 'total_amount',
label: 'Total Uang',
icon: CircleDollarSign,
color: 'bg-purple-100',
iconColor: 'text-purple-600',
key: 'total_deduction',
label: 'Potongan',
icon: Minus,
color: 'bg-orange-100',
iconColor: 'text-orange-600',
format: formatCurrency,
},
{

View File

@ -166,6 +166,7 @@ export function ProductCardRow({
url={toggleStatusUrl(product.id)}
checked={isChecked}
label={getStatusLabel(product.status)}
disabled={!can('products.toggle_status')}
/>
) : (
<span
@ -182,6 +183,7 @@ export function ProductCardRow({
url={toggleFeaturedUrl(product.id)}
checked={product.is_featured}
label={product.is_featured ? 'Ditampilkan di Halaman Depan' : 'Tidak Ditampilkan'}
disabled={!can('products.toggle_featured')}
/>
</div>
)}