İzin listesi,yetkilendirme vb
This commit is contained in:
@@ -6,6 +6,22 @@ import { revalidatePath } from 'next/cache'
|
|||||||
export async function addCompany(formData: FormData) {
|
export async function addCompany(formData: FormData) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return { error: 'Oturum bulunamadı.' }
|
||||||
|
|
||||||
|
// Check if user has admin/manager role somewhere
|
||||||
|
const { data: employeeRoles } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
|
const canManage = employeeRoles?.some((emp: any) => {
|
||||||
|
const roleName = Array.isArray(emp.roles) ? emp.roles[0]?.name : emp.roles?.name
|
||||||
|
return roleName === 'admin' || roleName === 'manager'
|
||||||
|
}) || false
|
||||||
|
|
||||||
|
if (!canManage) return { error: 'Şirket ekleme yetkiniz bulunmamaktadır.' }
|
||||||
|
|
||||||
const name = formData.get('name') as string
|
const name = formData.get('name') as string
|
||||||
if (!name) return { error: 'Şirket adı zorunludur.' }
|
if (!name) return { error: 'Şirket adı zorunludur.' }
|
||||||
|
|
||||||
@@ -23,6 +39,20 @@ export async function addCompany(formData: FormData) {
|
|||||||
|
|
||||||
export async function deleteCompany(id: string) {
|
export async function deleteCompany(id: string) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return { error: 'Oturum bulunamadı.' }
|
||||||
|
|
||||||
|
const { data: employeeRoles } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
|
const canManage = employeeRoles?.some((emp: any) => {
|
||||||
|
const roleName = Array.isArray(emp.roles) ? emp.roles[0]?.name : emp.roles?.name
|
||||||
|
return roleName === 'admin' || roleName === 'manager'
|
||||||
|
}) || false
|
||||||
|
|
||||||
|
if (!canManage) return { error: 'Şirket silme yetkiniz bulunmamaktadır.' }
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('companies')
|
.from('companies')
|
||||||
@@ -30,7 +60,7 @@ export async function deleteCompany(id: string) {
|
|||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return { error: 'Şirket silinirken bir hata oluştu: ' + error.message }
|
return { error: 'Şirket silünerken bir hata oluştu: ' + error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/companies')
|
revalidatePath('/companies')
|
||||||
|
|||||||
+98
-80
@@ -1,11 +1,30 @@
|
|||||||
import { createClient } from '@/utils/supabase/server'
|
import { createClient } from '@/utils/supabase/server'
|
||||||
import { deleteCompany } from './actions'
|
import { deleteCompany } from './actions'
|
||||||
import { BuildingOfficeIcon, TrashIcon } from '@heroicons/react/24/outline'
|
import { BuildingOfficeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
|
||||||
import CompanyForm from '@/components/companies/CompanyForm'
|
import { redirect } from 'next/navigation'
|
||||||
|
import AddCompanyModal from '@/components/companies/AddCompanyModal'
|
||||||
|
|
||||||
export default async function CompaniesPage() {
|
export default async function CompaniesPage() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return redirect('/login')
|
||||||
|
|
||||||
|
// Authorization Check
|
||||||
|
const { data: employeeRoles } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
|
const isAdminOrManager = employeeRoles?.some((emp: any) => {
|
||||||
|
const roleName = Array.isArray(emp.roles) ? emp.roles[0]?.name : emp.roles?.name
|
||||||
|
return roleName === 'admin' || roleName === 'manager'
|
||||||
|
}) || false
|
||||||
|
|
||||||
|
if (!isAdminOrManager) {
|
||||||
|
return redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch companies
|
// Fetch companies
|
||||||
const { data: companies } = await supabase
|
const { data: companies } = await supabase
|
||||||
.from('companies')
|
.from('companies')
|
||||||
@@ -13,90 +32,89 @@ export default async function CompaniesPage() {
|
|||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
|
<div className="p-4 sm:p-6 lg:p-8 max-w-none mx-auto space-y-8">
|
||||||
<div className="sm:flex sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||||
<div className="sm:flex-auto">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="w-1.5 h-12 bg-[#CE0515] rounded-full" />
|
||||||
<div className="w-1.5 h-10 bg-indigo-500 rounded-full" />
|
<div>
|
||||||
<div>
|
<h1 className="text-3xl font-black text-[#173363] dark:text-white tracking-tight">Şirket Yönetimi</h1>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white tracking-tight">Şirket Yönetimi</h1>
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
|
||||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
|
Sisteme kayıtlı şirketlerin listesi ve yönetim alanı.
|
||||||
Sisteme kayıtlı şirketlerin listesi ve yeni şirket ekleme alanı.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal bazlı yeni şirket ekleme */}
|
||||||
|
<AddCompanyModal />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="bg-white dark:bg-zinc-900 shadow-sm border border-slate-100 dark:border-zinc-800 rounded-[2rem] overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
{/* Add Company Form */}
|
<table className="min-w-full divide-y divide-slate-100 dark:divide-zinc-800">
|
||||||
<div className="md:col-span-1 border border-gray-200 dark:border-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-sm overflow-hidden h-fit">
|
<thead className="bg-slate-50/50 dark:bg-zinc-950/50">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<tr>
|
||||||
<h3 className="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
<th scope="col" className="py-5 pl-6 pr-3 text-left">
|
||||||
Yeni Şirket Ekle
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Şirket Adı</span>
|
||||||
</h3>
|
</th>
|
||||||
<div className="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
<th scope="col" className="px-3 py-5 text-left">
|
||||||
<p>Personelleri atayabilmek için öncelikle şirket kaydı açmalısınız.</p>
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Kayıt Tarihi</span>
|
||||||
</div>
|
</th>
|
||||||
|
<th scope="col" className="relative py-5 pl-3 pr-6 text-right">
|
||||||
<CompanyForm />
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">İşlemler</span>
|
||||||
</div>
|
</th>
|
||||||
</div>
|
</tr>
|
||||||
|
</thead>
|
||||||
{/* Company List */}
|
<tbody className="divide-y divide-slate-50 dark:divide-zinc-800 bg-white dark:bg-zinc-900">
|
||||||
<div className="md:col-span-2">
|
{companies?.map((company) => (
|
||||||
<div className="bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-gray-200 dark:ring-zinc-800 sm:rounded-xl overflow-hidden">
|
<tr key={company.id} className="hover:bg-slate-50/50 dark:hover:bg-zinc-800/50 transition-colors group">
|
||||||
<table className="min-w-full divide-y divide-slate-200 dark:divide-zinc-800">
|
<td className="whitespace-nowrap py-6 pl-6 pr-3">
|
||||||
<thead className="bg-slate-50 dark:bg-zinc-950/50">
|
<div className="flex items-center gap-3">
|
||||||
<tr>
|
<div className="p-2 bg-slate-100 dark:bg-zinc-800 rounded-xl group-hover:bg-red-50 dark:group-hover:bg-red-500/10 transition-colors">
|
||||||
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-slate-900 dark:text-white sm:pl-6">
|
<BuildingOfficeIcon className="h-5 w-5 text-[#173363] group-hover:text-[#CE0515]" />
|
||||||
Şirket Adı
|
</div>
|
||||||
</th>
|
<span className="font-bold text-[#173363] dark:text-white group-hover:text-[#CE0515] transition-colors text-base">
|
||||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">
|
{company.name}
|
||||||
Kayıt Tarihi
|
</span>
|
||||||
</th>
|
</div>
|
||||||
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
</td>
|
||||||
<span className="sr-only">İşlemler</span>
|
<td className="whitespace-nowrap px-3 py-6 text-sm">
|
||||||
</th>
|
<span className="font-bold text-slate-500 dark:text-slate-400">
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-zinc-800 bg-white dark:bg-zinc-900">
|
|
||||||
{companies?.map((company) => (
|
|
||||||
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-zinc-800/50 transition-colors">
|
|
||||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-bold text-slate-900 dark:text-white sm:pl-6">
|
|
||||||
{company.name}
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400 font-medium">
|
|
||||||
{new Date(company.created_at).toLocaleDateString('tr-TR')}
|
{new Date(company.created_at).toLocaleDateString('tr-TR')}
|
||||||
</td>
|
</span>
|
||||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
</td>
|
||||||
<form action={async () => {
|
<td className="relative whitespace-nowrap py-6 pl-3 pr-6 text-right font-medium">
|
||||||
'use server';
|
<form action={async () => {
|
||||||
const res = await deleteCompany(company.id);
|
'use server';
|
||||||
if (res.error) {
|
const res = await deleteCompany(company.id);
|
||||||
alert(res.error);
|
if (res.error) {
|
||||||
}
|
// Note: In server component we can't easily alert, this needs client-side handling ideally
|
||||||
}}>
|
console.error(res.error);
|
||||||
<button type="submit" className="text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 p-2 rounded-2xl hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-all" title="Sil">
|
}
|
||||||
<TrashIcon className="h-5 w-5" />
|
}}>
|
||||||
</button>
|
<button
|
||||||
</form>
|
type="submit"
|
||||||
</td>
|
className="bg-rose-50 text-rose-600 hover:bg-rose-600 hover:text-white p-2.5 rounded-xl transition-all active:scale-95"
|
||||||
</tr>
|
title="Sil"
|
||||||
))}
|
>
|
||||||
{(!companies || companies.length === 0) && (
|
<TrashIcon className="h-5 w-5" />
|
||||||
<tr>
|
</button>
|
||||||
<td colSpan={3} className="py-12 text-center text-sm text-slate-500 dark:text-slate-400 font-medium">
|
</form>
|
||||||
Henüz kayıtlı şirket bulunmamaktadır.
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
))}
|
||||||
)}
|
{(!companies || companies.length === 0) && (
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td colSpan={3} className="py-20 text-center">
|
||||||
</div>
|
<div className="flex flex-col items-center">
|
||||||
|
<BuildingOfficeIcon className="h-12 w-12 text-slate-200 mb-4" />
|
||||||
|
<span className="text-sm font-bold text-slate-400 uppercase tracking-widest">Henüz kayıtlı şirket bulunmamaktadır.</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-1
@@ -34,6 +34,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
// Fetch db user profile if logged in
|
// Fetch db user profile if logged in
|
||||||
let dbUser = null
|
let dbUser = null
|
||||||
|
let isAdminSomewhere = false
|
||||||
if (user) {
|
if (user) {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
@@ -41,6 +42,15 @@ export default async function RootLayout({
|
|||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single()
|
.single()
|
||||||
dbUser = data
|
dbUser = data
|
||||||
|
|
||||||
|
const { data: employeeRoles } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
|
isAdminSomewhere = employeeRoles?.some((emp: any) =>
|
||||||
|
emp.roles?.name === 'admin' || emp.roles?.name === 'manager'
|
||||||
|
) || false
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +60,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
{user ? (
|
{user ? (
|
||||||
<div>
|
<div>
|
||||||
<Sidebar />
|
<Sidebar isAdmin={isAdminSomewhere} />
|
||||||
<div className="lg:pl-72 flex flex-col min-h-screen">
|
<div className="lg:pl-72 flex flex-col min-h-screen">
|
||||||
<Header user={dbUser || user} />
|
<Header user={dbUser || user} />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
|
|||||||
@@ -12,28 +12,54 @@ export async function submitLeaveRequest(formData: FormData) {
|
|||||||
const startDate = formData.get('start_date') as string
|
const startDate = formData.get('start_date') as string
|
||||||
const endDate = formData.get('end_date') as string
|
const endDate = formData.get('end_date') as string
|
||||||
const reason = formData.get('reason') as string
|
const reason = formData.get('reason') as string
|
||||||
const companyId = formData.get('company_id') as string // which company they are requesting leave for
|
const leaveTypeId = formData.get('leave_type_id') as string
|
||||||
|
const companyId = formData.get('company_id') as string
|
||||||
|
const targetEmployeeId = formData.get('employee_id') as string // Optional: for admin use
|
||||||
|
|
||||||
if (!startDate || !endDate || !companyId || !reason) {
|
if (!startDate || !endDate || !companyId || !reason || !leaveTypeId) {
|
||||||
return { error: 'Tüm alanları doldurunuz.' }
|
return { error: 'Tüm alanları doldurunuz.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find their employee record for this specific company
|
// Find user's admin/manager status for checks
|
||||||
const { data: employeeData, error: empError } = await supabase
|
const { data: currentUserEmployee } = await supabase
|
||||||
.from('employees')
|
.from('employees')
|
||||||
.select('id')
|
.select('id, roles(name)')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single();
|
||||||
|
|
||||||
if (empError || !employeeData) {
|
const isManager = (currentUserEmployee?.roles as any)?.name === 'admin' || (currentUserEmployee?.roles as any)?.name === 'manager';
|
||||||
return { error: 'Seçili şirket için personel kaydınız bulunamadı.' }
|
|
||||||
|
let finalEmployeeId = targetEmployeeId;
|
||||||
|
|
||||||
|
if (!isManager || !targetEmployeeId) {
|
||||||
|
// If not manager or no target ID, use the current user's employee record for this company
|
||||||
|
if (!currentUserEmployee) {
|
||||||
|
return { error: 'Seçili şirket için personel kaydınız bulunamadı.' }
|
||||||
|
}
|
||||||
|
finalEmployeeId = currentUserEmployee.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this leave type is restricted to admins only
|
||||||
|
const { data: leaveType, error: typeError } = await supabase
|
||||||
|
.from('leave_types')
|
||||||
|
.select('only_admin_can_create')
|
||||||
|
.eq('id', leaveTypeId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (typeError) return { error: 'İzin türü doğrulanamadı.' };
|
||||||
|
|
||||||
|
if (leaveType.only_admin_can_create) {
|
||||||
|
if (!isManager) {
|
||||||
|
return { error: 'Bu izin türü sadece yöneticiler tarafından tanımlanabilir.' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('leave_requests')
|
.from('leave_requests')
|
||||||
.insert([{
|
.insert([{
|
||||||
employee_id: employeeData.id,
|
employee_id: finalEmployeeId,
|
||||||
|
leave_type_id: leaveTypeId,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
@@ -50,10 +76,44 @@ export async function submitLeaveRequest(formData: FormData) {
|
|||||||
|
|
||||||
export async function updateLeaveStatus(id: string, newStatus: 'approved' | 'rejected') {
|
export async function updateLeaveStatus(id: string, newStatus: 'approved' | 'rejected') {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return { error: 'Oturum bulunamadı.' }
|
||||||
|
|
||||||
|
// 1. Fetch leave request to know its company
|
||||||
|
const { data: leaveReq, error: fetchError } = await supabase
|
||||||
|
.from('leave_requests')
|
||||||
|
.select('employee_id, employees(company_id)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError || !leaveReq) return { error: 'İzin talebi bulunamadı.' }
|
||||||
|
|
||||||
|
const companyId = (leaveReq.employees as any)?.company_id
|
||||||
|
|
||||||
|
// 2. Check if current user is admin/manager in that company
|
||||||
|
const { data: currentUserEmployee } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const isManager = (currentUserEmployee?.roles as any)?.name === 'admin' || (currentUserEmployee?.roles as any)?.name === 'manager';
|
||||||
|
|
||||||
|
if (!isManager) {
|
||||||
|
return { error: 'Bu izin talebini onaylamak için yetkiniz bulunmamaktadır.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = { status: newStatus }
|
||||||
|
|
||||||
|
if (newStatus === 'approved') {
|
||||||
|
updateData.approved_by = user.id
|
||||||
|
updateData.approval_date = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('leave_requests')
|
.from('leave_requests')
|
||||||
.update({ status: newStatus })
|
.update(updateData)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -63,3 +123,95 @@ export async function updateLeaveStatus(id: string, newStatus: 'approved' | 'rej
|
|||||||
revalidatePath('/leave-requests')
|
revalidatePath('/leave-requests')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetLeaveStatus(id: string) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return { error: 'Oturum bulunamadı.' }
|
||||||
|
|
||||||
|
const { data: leaveReq } = await supabase
|
||||||
|
.from('leave_requests')
|
||||||
|
.select('employees(company_id)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!leaveReq) return { error: 'Talebe ulaşılamadı.' }
|
||||||
|
const companyId = (leaveReq.employees as any).company_id
|
||||||
|
|
||||||
|
const { data: currentUserEmployee } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const isManager = (currentUserEmployee?.roles as any)?.name === 'admin' || (currentUserEmployee?.roles as any)?.name === 'manager'
|
||||||
|
if (!isManager) return { error: 'Bu işlem için yetkiniz bulunmamaktadır.' }
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('leave_requests')
|
||||||
|
.update({
|
||||||
|
status: 'pending',
|
||||||
|
approved_by: null,
|
||||||
|
approval_date: null
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) return { error: 'Durum sıfırlanırken hata: ' + error.message }
|
||||||
|
|
||||||
|
revalidatePath('/leave-requests')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLeaveRequest(formData: FormData) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) return { error: 'Oturum bulunamadı.' }
|
||||||
|
|
||||||
|
const id = formData.get('id') as string
|
||||||
|
const startDate = formData.get('start_date') as string
|
||||||
|
const endDate = formData.get('end_date') as string
|
||||||
|
const reason = formData.get('reason') as string
|
||||||
|
const leaveTypeId = formData.get('leave_type_id') as string
|
||||||
|
|
||||||
|
// Fetch current request status and role
|
||||||
|
const { data: leaveReq } = await supabase
|
||||||
|
.from('leave_requests')
|
||||||
|
.select('status, employee_id, employees(company_id, user_id)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!leaveReq) return { error: 'Talep bulunamadı.' }
|
||||||
|
const companyId = (leaveReq.employees as any).company_id
|
||||||
|
const isOwner = (leaveReq.employees as any).user_id === user.id
|
||||||
|
|
||||||
|
const { data: currentUserEmployee } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('roles(name)')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const isManager = (currentUserEmployee?.roles as any)?.name === 'admin' || (currentUserEmployee?.roles as any)?.name === 'manager'
|
||||||
|
|
||||||
|
// Only owner (if pending) or manager can edit
|
||||||
|
if (!isManager && (!isOwner || leaveReq.status !== 'pending')) {
|
||||||
|
return { error: 'Bu talebi düzenleme yetkiniz bulunmamaktadır (Onaylanmış veya yetkisiz işlem).' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('leave_requests')
|
||||||
|
.update({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
reason: reason,
|
||||||
|
leave_type_id: leaveTypeId,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) return { error: 'Güncellenirken hata: ' + error.message }
|
||||||
|
|
||||||
|
revalidatePath('/leave-requests')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|||||||
+321
-198
@@ -1,247 +1,370 @@
|
|||||||
import { createClient } from '@/utils/supabase/server'
|
import { createClient } from '@/utils/supabase/server'
|
||||||
import { updateLeaveStatus } from './actions'
|
import { updateLeaveStatus, resetLeaveStatus } from './actions'
|
||||||
import { CalendarDaysIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
import { CalendarDaysIcon, CheckIcon, XMarkIcon, ClockIcon, ArrowPathIcon, PencilIcon } from '@heroicons/react/24/outline'
|
||||||
|
import LeaveRequestForm from '@/components/LeaveRequestForm'
|
||||||
|
|
||||||
|
export default async function LeaveRequestsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ status?: string; search?: string; type?: string }>
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const statusFilter = params.status;
|
||||||
|
const searchFilter = params.search;
|
||||||
|
const typeFilter = params.type;
|
||||||
|
|
||||||
export default async function LeaveRequestsPage() {
|
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
// Fetch user role
|
// Fetch user records with roles for each company
|
||||||
const { data: dbUser } = await supabase
|
const { data: employeeRecords } = await supabase
|
||||||
.from('users')
|
.from('employees')
|
||||||
.select('roles(name)')
|
.select('id, company_id, roles(name), companies:company_id(id, name)')
|
||||||
.eq('id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
|
||||||
|
|
||||||
const roleData: any = dbUser?.roles
|
const adminCompanyIds = employeeRecords
|
||||||
const roleName = Array.isArray(roleData) ? roleData[0]?.name : roleData?.name
|
?.filter((emp: any) => {
|
||||||
const isAdminOrManager = roleName === 'admin' || roleName === 'manager'
|
const roleName = emp.roles?.name
|
||||||
|
return roleName === 'admin' || roleName === 'manager'
|
||||||
|
})
|
||||||
|
.map((emp: any) => emp.company_id) || []
|
||||||
|
|
||||||
// Fetch leave requests
|
const isAdminSomewhere = adminCompanyIds.length > 0
|
||||||
// If admin/manager, fetch all. If employee, fetch only their own.
|
|
||||||
let query = supabase
|
// Fetch all employees ONLY for companies where the user is admin/manager
|
||||||
|
let allEmployees: any[] = []
|
||||||
|
if (isAdminSomewhere) {
|
||||||
|
const { data: empData } = await supabase
|
||||||
|
.from('employees')
|
||||||
|
.select('id, company_id, users(first_name, last_name)')
|
||||||
|
.in('company_id', adminCompanyIds)
|
||||||
|
.eq('status', 'active')
|
||||||
|
allEmployees = empData || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch leave types
|
||||||
|
const { data: leaveTypes } = await supabase
|
||||||
|
.from('leave_types')
|
||||||
|
.select('*')
|
||||||
|
.order('display_order')
|
||||||
|
|
||||||
|
// Fetch leave balances for the current user
|
||||||
|
const { data: balances } = await supabase
|
||||||
|
.from('leave_balances')
|
||||||
|
.select('*, leave_types!inner(name, display_order)')
|
||||||
|
.eq('year', new Date().getFullYear())
|
||||||
|
.eq('employee_id', employeeRecords?.[0]?.id) // Just a fallback, handled better in query
|
||||||
|
.order('display_order', { referencedTable: 'leave_types' })
|
||||||
|
|
||||||
|
// Fetch leave requests (Admins see all for their companies, users see their own)
|
||||||
|
let requestsQuery = supabase
|
||||||
.from('leave_requests')
|
.from('leave_requests')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
start_date,
|
start_date,
|
||||||
end_date,
|
end_date,
|
||||||
|
total_days,
|
||||||
status,
|
status,
|
||||||
reason,
|
reason,
|
||||||
created_at,
|
created_at,
|
||||||
|
leave_type:leave_type_id ( name, color_code ),
|
||||||
employees!inner (
|
employees!inner (
|
||||||
id,
|
id,
|
||||||
user_id,
|
user_id,
|
||||||
|
company_id,
|
||||||
companies ( name ),
|
companies ( name ),
|
||||||
users( first_name, last_name, email )
|
users( first_name, last_name, email )
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
if (!isAdminOrManager) {
|
if (!isAdminSomewhere) {
|
||||||
query = query.eq('employees.user_id', user.id)
|
requestsQuery = requestsQuery.eq('employees.user_id', user.id)
|
||||||
|
} else {
|
||||||
|
// If admin somewhere, they see their own AND all from companies they manage
|
||||||
|
requestsQuery = requestsQuery.or(`user_id.eq.${user.id},company_id.in.(${adminCompanyIds.join(',')})`, { foreignTable: 'employees' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: requests } = await query
|
// Apply filters
|
||||||
|
if (statusFilter) {
|
||||||
|
requestsQuery = requestsQuery.eq('status', statusFilter)
|
||||||
|
}
|
||||||
|
if (typeFilter) {
|
||||||
|
requestsQuery = requestsQuery.eq('leave_type_id', typeFilter)
|
||||||
|
}
|
||||||
|
|
||||||
// Also fetch companies they belong to if they want to request leave
|
const { data: requests } = await requestsQuery
|
||||||
const { data: userCompanies } = await supabase
|
|
||||||
.from('employees')
|
// Manual filtering for search (since it's a join with users)
|
||||||
.select('companies:company_id(id, name)')
|
let filteredRequests = requests || []
|
||||||
.eq('user_id', user.id)
|
if (searchFilter) {
|
||||||
|
const term = searchFilter.toLowerCase()
|
||||||
|
filteredRequests = filteredRequests.filter(req => {
|
||||||
|
const employee = Array.isArray(req.employees) ? req.employees[0] : req.employees
|
||||||
|
const user = Array.isArray(employee?.users) ? employee?.users[0] : employee?.users
|
||||||
|
return (
|
||||||
|
`${user?.first_name} ${user?.last_name}`.toLowerCase().includes(term) ||
|
||||||
|
user?.email?.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
|
<div className="p-4 sm:p-6 lg:p-8 max-w-none mx-auto space-y-8">
|
||||||
<div className="sm:flex sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||||
<div className="sm:flex-auto">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="w-1.5 h-12 bg-[#CE0515] rounded-full" />
|
||||||
<div className="w-1.5 h-10 bg-indigo-500 rounded-full" />
|
<div>
|
||||||
<div>
|
<h1 className="text-3xl font-black text-[#173363] dark:text-white tracking-tight">İzin Yönetimi</h1>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white tracking-tight">İzin Talepleri</h1>
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
|
||||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 mt-1">
|
{isAdminSomewhere
|
||||||
{isAdminOrManager
|
? 'Personel izin taleplerini yönetin.'
|
||||||
? 'Tüm personellerin izin taleplerini görüntüleyin ve onaylayın.'
|
: 'İzin taleplerinizi oluşturun ve takip edin.'}
|
||||||
: 'Kendi izin taleplerinizi oluşturun ve durumlarını takip edin.'}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LeaveRequestForm
|
||||||
|
leaveTypes={leaveTypes || []}
|
||||||
|
companies={employeeRecords || []}
|
||||||
|
allEmployees={allEmployees || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-4 gap-8">
|
{/* Modern Filtre Barı */}
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-[2.5rem] shadow-xl shadow-slate-200/50 dark:shadow-none border border-slate-100 dark:border-zinc-800 p-6">
|
||||||
{/* Request form - only show if they are actually employed somewhere */}
|
<form className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="md:col-span-1 border border-gray-200 dark:border-zinc-800 rounded-xl bg-white dark:bg-zinc-900 shadow-sm overflow-hidden h-fit">
|
<div className="relative">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<input
|
||||||
<h3 className="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
type="text"
|
||||||
Yeni İzin Talebi
|
name="search"
|
||||||
</h3>
|
defaultValue={searchFilter}
|
||||||
|
placeholder="Personel adı veya e-posta..."
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(userCompanies && userCompanies.length > 0) ? (
|
<select
|
||||||
<form action="/api/leave" method="POST" className="mt-5 space-y-4">
|
name="status"
|
||||||
{/* We use a workaround API route or action for complex auth logic with FormData above */}
|
defaultValue={statusFilter}
|
||||||
{/* For real production, we can inline the action. I'll mock the action call for now */}
|
className="rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none"
|
||||||
<div>
|
>
|
||||||
<label htmlFor="company_id" className="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300">İlgili Şirket</label>
|
<option value="">Tüm Durumlar</option>
|
||||||
<select
|
<option value="pending">Bekliyor</option>
|
||||||
id="company_id"
|
<option value="approved">Onaylandı</option>
|
||||||
name="company_id"
|
<option value="rejected">Reddedildi</option>
|
||||||
required
|
</select>
|
||||||
className="mt-2 block w-full rounded-xl border-0 py-2.5 pl-3 pr-10 text-slate-900 ring-1 ring-inset ring-slate-200 dark:ring-zinc-700 dark:bg-zinc-800 dark:text-white focus:ring-2 focus:ring-indigo-500 sm:text-sm outline-none"
|
|
||||||
>
|
|
||||||
{userCompanies?.filter((c: any) => c.companies).map((c: any) => (
|
|
||||||
<option key={c.companies.id} value={c.companies.id}>{c.companies.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<select
|
||||||
<label htmlFor="start_date" className="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300">Başlangıç Tarihi</label>
|
name="type"
|
||||||
<input
|
defaultValue={typeFilter}
|
||||||
type="date"
|
className="rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none"
|
||||||
name="start_date"
|
>
|
||||||
id="start_date"
|
<option value="">Tüm İzin Türleri</option>
|
||||||
required
|
{leaveTypes?.map(t => (
|
||||||
className="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-700 dark:bg-zinc-800 dark:text-white sm:text-sm sm:leading-6"
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
/>
|
))}
|
||||||
</div>
|
</select>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="end_date" className="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300">Bitiş Tarihi</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
name="end_date"
|
|
||||||
id="end_date"
|
|
||||||
required
|
|
||||||
className="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-700 dark:bg-zinc-800 dark:text-white sm:text-sm sm:leading-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="reason" className="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300">Açıklama</label>
|
|
||||||
<textarea
|
|
||||||
name="reason"
|
|
||||||
id="reason"
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
className="mt-2 block w-full rounded-xl border-0 py-2.5 px-3 text-slate-900 shadow-sm ring-1 ring-inset ring-slate-200 dark:ring-zinc-700 dark:bg-zinc-800 dark:text-white placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
formAction={async (formData) => {
|
className="flex-1 rounded-2xl bg-[#173363] text-white px-6 py-4 text-sm font-black uppercase tracking-widest hover:bg-[#1e4585] transition-all"
|
||||||
'use server';
|
|
||||||
const { submitLeaveRequest } = await import('./actions');
|
|
||||||
await submitLeaveRequest(formData);
|
|
||||||
}}
|
|
||||||
className="inline-flex w-full items-center justify-center rounded-2xl bg-indigo-600 px-3 py-3 text-sm font-bold text-white shadow-sm shadow-indigo-200 dark:shadow-none hover:bg-indigo-700 transition-all active:scale-95"
|
|
||||||
>
|
>
|
||||||
Talep Oluştur
|
FİLTRELE
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<a
|
||||||
) : (
|
href="/leave-requests"
|
||||||
<div className="mt-4 text-sm text-red-500">
|
className="flex items-center justify-center p-4 rounded-2xl bg-slate-100 text-slate-500 hover:bg-slate-200 transition-all"
|
||||||
Öncelikle bir şirkete atanmış olmanız gerekmektedir.
|
title="Temizle"
|
||||||
</div>
|
>
|
||||||
)}
|
<ArrowPathIcon className="h-5 w-5" />
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Balance Summary Cards - Only for regular employees or managers viewing own balance */}
|
||||||
<div className="md:col-span-3">
|
{!isAdminSomewhere && balances && balances.length > 0 && (
|
||||||
<div className="bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-gray-200 dark:ring-zinc-800 sm:rounded-xl overflow-hidden overflow-x-auto">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-800">
|
{balances.map((balance: any) => (
|
||||||
<thead className="bg-slate-50 dark:bg-zinc-950/50">
|
<div key={balance.id} className="bg-white dark:bg-zinc-900 p-6 rounded-[1.5rem] shadow-sm border border-slate-100 dark:border-zinc-800 group hover:shadow-xl transition-all duration-300">
|
||||||
<tr>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-slate-900 dark:text-white sm:pl-6">
|
<span className="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">
|
||||||
Personel
|
{balance.leave_types?.name}
|
||||||
</th>
|
</span>
|
||||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">
|
<div className="p-2 bg-slate-50 dark:bg-zinc-800 rounded-lg group-hover:bg-red-50 dark:group-hover:bg-red-500/10 transition-colors">
|
||||||
Tarih Aralığı
|
<ClockIcon className="h-4 w-4 text-[#173363] group-hover:text-[#CE0515]" />
|
||||||
</th>
|
</div>
|
||||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">
|
</div>
|
||||||
Gerekçe
|
<div className="flex items-baseline gap-2">
|
||||||
</th>
|
<span className="text-4xl font-black text-[#173363] dark:text-white tracking-tighter">{balance.remaining_days}</span>
|
||||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">
|
<span className="text-sm font-bold text-slate-400">gün kaldı</span>
|
||||||
Durum
|
</div>
|
||||||
</th>
|
<div className="mt-4 w-full bg-slate-100 dark:bg-zinc-800 rounded-full h-1.5 overflow-hidden">
|
||||||
{isAdminOrManager && (
|
<div
|
||||||
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
className="bg-[#CE0515] h-full rounded-full transition-all duration-1000"
|
||||||
<span className="sr-only">İşlemler</span>
|
style={{ width: `${Math.min(100, (balance.used_days / (balance.accrued_days || 1)) * 100)}%` }}
|
||||||
</th>
|
/>
|
||||||
)}
|
</div>
|
||||||
</tr>
|
<div className="mt-4 flex justify-between text-[10px] font-bold text-slate-400">
|
||||||
</thead>
|
<span>{balance.used_days} KULLANILDI</span>
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-zinc-800 bg-white dark:bg-zinc-900">
|
<span>{balance.accrued_days} TOPLAM</span>
|
||||||
{requests?.map((req: any) => {
|
</div>
|
||||||
const employeeData = Array.isArray(req.employees) ? req.employees[0] : req.employees;
|
</div>
|
||||||
const userData = Array.isArray(employeeData?.users) ? employeeData.users[0] : employeeData?.users;
|
))}
|
||||||
const companyData = Array.isArray(employeeData?.companies) ? employeeData.companies[0] : employeeData?.companies;
|
</div>
|
||||||
|
)}
|
||||||
return (
|
|
||||||
<tr key={req.id} className="hover:bg-slate-50 dark:hover:bg-zinc-800/50 transition-colors">
|
{/* Requests List - Now Full Width */}
|
||||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6">
|
<div className="bg-white dark:bg-zinc-900 shadow-sm border border-slate-100 dark:border-zinc-800 rounded-[2rem] overflow-hidden">
|
||||||
<div className="font-semibold text-slate-900 dark:text-white">
|
<div className="overflow-x-auto">
|
||||||
{userData?.first_name} {userData?.last_name}
|
<table className="min-w-full divide-y divide-slate-100 dark:divide-zinc-800">
|
||||||
</div>
|
<thead className="bg-slate-50/50 dark:bg-zinc-950/50">
|
||||||
<div className="text-slate-500 dark:text-slate-400">{companyData?.name}</div>
|
<tr>
|
||||||
</td>
|
<th scope="col" className="py-5 pl-6 pr-3 text-left">
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Personel / Şirket</span>
|
||||||
<div>{new Date(req.start_date).toLocaleDateString('tr-TR')}</div>
|
</th>
|
||||||
<div>{new Date(req.end_date).toLocaleDateString('tr-TR')}</div>
|
<th scope="col" className="px-3 py-5 text-left">
|
||||||
</td>
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">İzin Türü / Süre</span>
|
||||||
<td className="px-3 py-4 text-sm text-slate-500 dark:text-slate-400 max-w-xs truncate font-medium">
|
</th>
|
||||||
{req.reason}
|
<th scope="col" className="px-3 py-5 text-left">
|
||||||
</td>
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Tarih Aralığı</span>
|
||||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
</th>
|
||||||
<span className={`inline-flex items-center rounded-lg px-2.5 py-1 text-xs font-bold ${
|
<th scope="col" className="px-3 py-5 text-left">
|
||||||
req.status === 'approved' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' :
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">Durum</span>
|
||||||
req.status === 'rejected' ? 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400' :
|
</th>
|
||||||
'bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-slate-400'
|
<th scope="col" className="relative py-5 pl-3 pr-6 text-right">
|
||||||
}`}>
|
<span className="text-xs font-black text-slate-400 uppercase tracking-widest">İşlemler</span>
|
||||||
{req.status === 'approved' ? 'Onaylandı' : req.status === 'rejected' ? 'Reddedildi' : 'Bekliyor'}
|
</th>
|
||||||
</span>
|
</tr>
|
||||||
</td>
|
</thead>
|
||||||
{isAdminOrManager && (
|
<tbody className="divide-y divide-slate-50 dark:divide-zinc-800 bg-white dark:bg-zinc-900">
|
||||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
{requests?.map((req: any) => {
|
||||||
{req.status === 'pending' && (
|
const employeeData = Array.isArray(req.employees) ? req.employees[0] : req.employees;
|
||||||
<form className="flex justify-end gap-2">
|
const userData = Array.isArray(employeeData?.users) ? employeeData.users[0] : employeeData?.users;
|
||||||
|
const companyData = Array.isArray(employeeData?.companies) ? employeeData.companies[0] : employeeData?.companies;
|
||||||
|
const leaveType = (req.leave_type as any);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={req.id} className="hover:bg-slate-50/50 dark:hover:bg-zinc-800/50 transition-colors group">
|
||||||
|
<td className="whitespace-nowrap py-6 pl-6 pr-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-[#173363] dark:text-white group-hover:text-[#CE0515] transition-colors">
|
||||||
|
{userData?.first_name} {userData?.last_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 mt-0.5">{companyData?.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: leaveType?.color_code || '#cbd5e1' }} />
|
||||||
|
<span className="font-bold text-slate-700 dark:text-slate-200">{leaveType?.name || 'Belirsiz'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-black mt-1 uppercase">
|
||||||
|
{req.total_days || Math.ceil((new Date(req.end_date).getTime() - new Date(req.start_date).getTime()) / (1000 * 60 * 60 * 24)) + 1} GÜN
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-6 text-sm">
|
||||||
|
<div className="font-bold text-[#173363] dark:text-slate-300">{new Date(req.start_date).toLocaleDateString('tr-TR')}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-medium">Bitiş: {new Date(req.end_date).toLocaleDateString('tr-TR')}</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-6">
|
||||||
|
<span className={`inline-flex items-center rounded-xl px-3 py-1.5 text-[10px] font-black uppercase tracking-widest ${
|
||||||
|
req.status === 'approved' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' :
|
||||||
|
req.status === 'rejected' ? 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400' :
|
||||||
|
'bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-slate-500'
|
||||||
|
}`}>
|
||||||
|
{req.status === 'approved' ? 'Onaylandı' : req.status === 'rejected' ? 'Reddedildi' : 'Bekliyor'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="relative whitespace-nowrap py-6 pl-3 pr-6 text-right font-medium">
|
||||||
|
<div className="flex justify-end gap-2 flex-wrap max-w-xs ml-auto">
|
||||||
|
<LeaveRequestForm
|
||||||
|
leaveTypes={leaveTypes || []}
|
||||||
|
companies={employeeRecords || []}
|
||||||
|
allEmployees={allEmployees || []}
|
||||||
|
initialData={req}
|
||||||
|
readOnly={req.status !== 'pending'}
|
||||||
|
trigger={
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-zinc-800 text-slate-500 hover:text-[#173363] dark:hover:text-white border border-slate-100 dark:border-zinc-700 transition-all active:scale-95 text-[10px] font-black uppercase tracking-widest" title="Detay / Düzenle">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
{req.status === 'pending' ? 'DÜZENLE' : 'DETAY'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdminSomewhere && req.status === 'pending' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<form>
|
||||||
<button
|
<button
|
||||||
formAction={async () => {
|
formAction={async () => {
|
||||||
'use server';
|
'use server';
|
||||||
await updateLeaveStatus(req.id, 'approved')
|
await updateLeaveStatus(req.id, 'approved')
|
||||||
}}
|
}}
|
||||||
className="text-emerald-600 hover:text-emerald-900 dark:text-emerald-500 dark:hover:text-emerald-400 p-2 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 transition-all" title="Onayla">
|
className="flex items-center gap-2 bg-emerald-50 text-emerald-600 hover:bg-emerald-600 hover:text-white px-4 py-2.5 rounded-xl border border-emerald-100 transition-all active:scale-95 text-[10px] font-black uppercase tracking-widest" title="Onayla">
|
||||||
<CheckIcon className="h-5 w-5" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</button>
|
ONAYLA
|
||||||
<button
|
|
||||||
formAction={async () => {
|
|
||||||
'use server';
|
|
||||||
await updateLeaveStatus(req.id, 'rejected')
|
|
||||||
}}
|
|
||||||
className="text-rose-600 hover:text-rose-900 dark:text-rose-500 dark:hover:text-rose-400 p-2 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-all" title="Reddet">
|
|
||||||
<XMarkIcon className="h-5 w-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
<form>
|
||||||
</td>
|
<button
|
||||||
)}
|
formAction={async () => {
|
||||||
</tr>
|
'use server';
|
||||||
)})}
|
await updateLeaveStatus(req.id, 'rejected')
|
||||||
{(!requests || requests.length === 0) && (
|
}}
|
||||||
<tr>
|
className="flex items-center gap-2 bg-rose-50 text-rose-600 hover:bg-rose-600 hover:text-white px-4 py-2.5 rounded-xl border border-rose-100 transition-all active:scale-95 text-[10px] font-black uppercase tracking-widest" title="Reddet">
|
||||||
<td colSpan={isAdminOrManager ? 5 : 4} className="py-12 text-center text-sm text-slate-500 dark:text-slate-400">
|
<XMarkIcon className="h-4 w-4" />
|
||||||
Henüz bir izin talebi bulunmamaktadır.
|
REDDET
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</form>
|
||||||
)}
|
</div>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{isAdminSomewhere && (req.status === 'approved' || req.status === 'rejected') && (
|
||||||
|
<form>
|
||||||
|
<button
|
||||||
|
formAction={async () => {
|
||||||
|
'use server';
|
||||||
|
await resetLeaveStatus(req.id)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 bg-amber-50 text-amber-600 hover:bg-amber-700 hover:text-white px-4 py-2.5 rounded-xl border border-amber-200 transition-all active:scale-95 text-[10px] font-black uppercase tracking-widest"
|
||||||
|
title={req.status === 'approved' ? 'Onayı Kaldır' : 'Reddi Kaldır'}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="h-4 w-4" />
|
||||||
|
{req.status === 'approved' ? 'ONAYI KALDIR' : 'REDDİ KALDIR'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdminSomewhere && req.status !== 'pending' && (
|
||||||
|
<div className="flex items-center px-4 py-2.5">
|
||||||
|
<span className="text-[10px] font-black text-slate-300 uppercase tracking-widest italic flex items-center gap-2">
|
||||||
|
<ClockIcon className="h-4 w-4" /> TAMAMLANDI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)})}
|
||||||
|
{(!filteredRequests || filteredRequests.length === 0) && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-20 text-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<CalendarDaysIcon className="h-12 w-12 text-slate-200 mb-4" />
|
||||||
|
<span className="text-sm font-bold text-slate-400 uppercase tracking-widest">Henüz bir izin talebi bulunmamaktadır.</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+152
-72
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/utils/supabase/server'
|
import { createClient } from '@/utils/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { UsersIcon, CalendarDaysIcon, BuildingOfficeIcon } from '@heroicons/react/24/outline'
|
import Link from 'next/link'
|
||||||
|
import { UsersIcon, CalendarDaysIcon, BuildingOfficeIcon, ClockIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -11,119 +12,198 @@ export default async function Home() {
|
|||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep debug: Fetch users and employees separately
|
// Fetch users and employees
|
||||||
const { data: dbUser, error: dbUserError } = await supabase
|
const { data: dbUser } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const { data: dbEmployees, error: dbEmpError } = await supabase
|
const { data: dbEmployees } = await supabase
|
||||||
.from('employees')
|
.from('employees')
|
||||||
.select('*, roles(*)')
|
.select('*, roles(*)')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
const userData = dbUser
|
const isAdminOrManager = dbEmployees?.some((emp: any) => {
|
||||||
const role = dbEmployees?.[0]?.roles || null
|
const roleName = Array.isArray(emp.roles) ? emp.roles[0]?.name : emp.roles?.name
|
||||||
const displayName = userData?.first_name
|
return roleName === 'admin' || roleName === 'manager'
|
||||||
? `${userData.first_name}${userData.last_name ? ' ' + userData.last_name : ''}`
|
}) || false
|
||||||
|
|
||||||
|
const role = dbEmployees?.[0]?.roles || null // Display first role as default label
|
||||||
|
|
||||||
|
const displayName = dbUser?.first_name
|
||||||
|
? `${dbUser.first_name}${dbUser.last_name ? ' ' + dbUser.last_name : ''}`
|
||||||
: user.email?.split('@')[0]
|
: user.email?.split('@')[0]
|
||||||
|
|
||||||
|
// Data fetching for stats
|
||||||
|
const { count: employeeCount } = await supabase.from('employees').select('*', { count: 'exact', head: true })
|
||||||
|
const { count: companyCount } = await supabase.from('companies').select('*', { count: 'exact', head: true })
|
||||||
|
|
||||||
|
let pendingQuery = supabase.from('leave_requests').select('*', { count: 'exact', head: true }).eq('status', 'pending')
|
||||||
|
if (!isAdminOrManager) {
|
||||||
|
pendingQuery = pendingQuery.eq('employee_id', dbEmployees?.[0]?.id)
|
||||||
|
}
|
||||||
|
const { count: pendingLeaveCount } = await pendingQuery
|
||||||
|
|
||||||
|
// Fetch remaining annual leave for current user
|
||||||
|
let remainingAnnualLeave = 0
|
||||||
|
if (!isAdminOrManager && dbEmployees?.[0]) {
|
||||||
|
const { data: balanceData } = await supabase
|
||||||
|
.from('leave_balances')
|
||||||
|
.select('remaining_days, leave_types!inner(name)')
|
||||||
|
.eq('employee_id', dbEmployees[0].id)
|
||||||
|
.eq('year', new Date().getFullYear())
|
||||||
|
.eq('leave_types.name', 'Yıllık İzin')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
remainingAnnualLeave = balanceData?.remaining_days || 0
|
||||||
|
}
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ name: 'Toplam Personel', stat: '0', icon: UsersIcon, change: '12%', changeType: 'increase' },
|
{
|
||||||
{ name: 'Bekleyen İzinler', stat: '0', icon: CalendarDaysIcon, change: '3', changeType: 'increase' },
|
name: isAdminOrManager ? 'Toplam Personel' : 'Şirket Personelleri',
|
||||||
{ name: 'Aktif Şirketler', stat: '1', icon: BuildingOfficeIcon, change: '0%', changeType: 'none' },
|
stat: isAdminOrManager ? (employeeCount || 0).toString() : (employeeCount || 0).toString(),
|
||||||
|
icon: UsersIcon,
|
||||||
|
href: '/employees',
|
||||||
|
change: '',
|
||||||
|
changeType: 'none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: isAdminOrManager ? 'Bekleyen İzinler' : 'Bekleyen Taleplerim',
|
||||||
|
stat: (pendingLeaveCount || 0).toString(),
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
href: '/leave-requests',
|
||||||
|
change: '',
|
||||||
|
changeType: 'none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: isAdminOrManager ? 'Aktif Şirketler' : 'Kalan Yıllık İzin',
|
||||||
|
stat: isAdminOrManager ? (companyCount || 0).toString() : `${remainingAnnualLeave} Gün`,
|
||||||
|
icon: isAdminOrManager ? BuildingOfficeIcon : ClockIcon,
|
||||||
|
href: isAdminOrManager ? '/companies' : '/leave-requests',
|
||||||
|
change: '',
|
||||||
|
changeType: 'none'
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8 space-y-8 max-w-7xl mx-auto">
|
<div className="p-4 sm:p-6 lg:p-8 space-y-8 max-w-7xl mx-auto">
|
||||||
|
|
||||||
{/* Welcome Section - Modern & Clean */}
|
{/* Welcome Section */}
|
||||||
<div className="relative overflow-hidden rounded-[2rem] bg-[#173363] p-10 shadow-xl shadow-blue-900/10">
|
<div className="relative overflow-hidden rounded-[2.5rem] bg-[#173363] p-10 shadow-2xl shadow-blue-900/20">
|
||||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div>
|
<div className="animate-in fade-in slide-in-from-left duration-700">
|
||||||
<h1 className="text-4xl font-black tracking-tight text-white mb-3">
|
<h1 className="text-4xl md:text-5xl font-black tracking-tighter text-white mb-4">
|
||||||
Hoş Geldiniz, {displayName} 👋
|
Hoş Geldiniz, {displayName} 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-blue-100/80 font-medium leading-relaxed text-lg">
|
<p className="max-w-xl text-blue-100/70 font-medium leading-relaxed text-lg">
|
||||||
IK Yönetim Sistemine tekrar hoş geldiniz. Bugün sisteme
|
HRMS Projesi kapsamında bugün sisteme
|
||||||
<span className="text-white font-black px-3 py-1 bg-[#CE0515] rounded-full mx-2 text-sm uppercase tracking-wider shadow-lg shadow-red-900/20">
|
<span className="text-white font-black px-4 py-1.5 bg-[#CE0515] rounded-full mx-2 text-xs uppercase tracking-widest shadow-lg shadow-red-900/30">
|
||||||
{role?.name || 'Kullanıcı'}
|
{(role as any)?.name || 'Kullanıcı'}
|
||||||
</span>
|
</span>
|
||||||
yetkileriyle bağlısınız.
|
yetkileriyle erişim sağladınız.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex -space-x-3">
|
<div className="hidden lg:flex -space-x-4 items-center animate-in fade-in slide-in-from-right duration-1000">
|
||||||
{[1,2,3,4].map(i => (
|
<div className="px-6 py-3 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 text-white font-bold text-sm tracking-widest uppercase">
|
||||||
<div key={i} className="w-14 h-14 rounded-full border-4 border-[#1e40af] bg-blue-800 shadow-inner" />
|
{new Date().toLocaleDateString('tr-TR', { weekday: 'long' })}
|
||||||
))}
|
</div>
|
||||||
<div className="w-14 h-14 rounded-full border-4 border-[#1e40af] bg-[#CE0515] flex items-center justify-center text-sm font-black text-white shadow-lg shadow-red-900/20 tracking-tighter">+12</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Corporate decorative element */}
|
{/* Corporate decorative element */}
|
||||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
|
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full -mr-48 -mt-48 blur-3xl opacity-50" />
|
||||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-[#CE0515]/10 rounded-full -ml-24 -mb-24 blur-2xl" />
|
<div className="absolute bottom-0 left-0 w-64 h-64 bg-[#CE0515]/10 rounded-full -ml-32 -mb-32 blur-2xl opacity-40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid - abisena.tr Style */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{stats.map((item) => (
|
{stats.map((item, idx) => (
|
||||||
<div
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className="group relative flex flex-col justify-between overflow-hidden rounded-3xl bg-white border border-slate-100 p-8 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-500"
|
href={item.href}
|
||||||
|
className="group relative flex flex-col justify-between overflow-hidden rounded-[2rem] bg-white border border-slate-100 p-8 shadow-sm hover:shadow-2xl hover:-translate-y-2 transition-all duration-500 animate-in fade-in slide-in-from-bottom"
|
||||||
|
style={{ animationDelay: `${idx * 150}ms` }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="rounded-2xl bg-slate-50 p-4 text-[#173363] group-hover:bg-[#CE0515] group-hover:text-white transition-all duration-500 rotate-3 group-hover:rotate-0 shadow-inner">
|
<div className="rounded-2xl bg-slate-50 p-4 text-[#173363] group-hover:bg-[#CE0515] group-hover:text-white transition-all duration-500 group-hover:rotate-12 shadow-sm">
|
||||||
<item.icon className="h-7 w-7" aria-hidden="true" />
|
<item.icon className="h-8 w-8" aria-hidden="true" />
|
||||||
</div>
|
|
||||||
<div className={`text-xs font-black leading-5 px-3 py-1.5 rounded-full tracking-wider uppercase ${
|
|
||||||
item.changeType === 'increase' ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-50 text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{item.changeType === 'increase' ? '↑' : ''} {item.change}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-black text-slate-400 uppercase tracking-[0.2em] mb-1">{item.name}</p>
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.25em] mb-2">{item.name}</p>
|
||||||
<p className="text-4xl font-black text-[#173363] tracking-tighter">{item.stat}</p>
|
<p className="text-5xl font-black text-[#173363] tracking-tighter group-hover:text-[#CE0515] transition-colors duration-500">
|
||||||
|
{item.stat}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Subtle progress bar */}
|
|
||||||
<div className="mt-6 w-full h-1.5 bg-slate-50 rounded-full overflow-hidden">
|
<div className="mt-8 w-full h-1.5 bg-slate-50 rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-[#173363] group-hover:bg-[#CE0515] transition-all duration-700 w-2/3" />
|
<div
|
||||||
|
className="h-full bg-[#173363] group-hover:bg-[#CE0515] transition-all duration-1000 ease-out fill-mode-both"
|
||||||
|
style={{ width: '40%' }} // Placeholder progress
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile & Quick Actions section */}
|
{/* Account Info Card */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="bg-white border border-slate-100 rounded-[2rem] p-10 shadow-sm hover:shadow-md transition-shadow duration-500">
|
<div className="lg:col-span-2 bg-white border border-slate-100 rounded-[2.5rem] p-10 shadow-sm hover:shadow-xl transition-all duration-500">
|
||||||
<h2 className="text-xl font-black mb-8 text-[#173363] flex items-center gap-3">
|
<div className="flex items-center justify-between mb-10">
|
||||||
<div className="w-2 h-8 bg-[#CE0515] rounded-full" />
|
<h2 className="text-2xl font-black text-[#173363] flex items-center gap-4">
|
||||||
Sistem Bilgileri
|
<div className="w-2.5 h-10 bg-[#CE0515] rounded-full shadow-lg shadow-red-500/20" />
|
||||||
</h2>
|
Sistem Bilgileri
|
||||||
<ul className="space-y-6 text-sm">
|
</h2>
|
||||||
<li className="flex items-center justify-between border-b border-slate-50 pb-6">
|
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<span className="font-bold text-slate-400 uppercase tracking-widest text-[10px]">E-posta Adresi</span>
|
ID: {user.id.slice(0, 8)}...
|
||||||
<span className="text-[#173363] font-black">{user.email}</span>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li className="flex items-center justify-between border-b border-slate-50 pb-6">
|
|
||||||
<span className="font-bold text-slate-400 uppercase tracking-widest text-[10px]">Hesap Yetkisi</span>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
<span className="inline-flex items-center gap-2 text-[#CE0515] font-black bg-rose-50 px-4 py-2 rounded-full text-xs shadow-sm shadow-rose-100 transition-transform hover:scale-105 cursor-default">
|
<div className="space-y-8">
|
||||||
{role?.description || 'Standart Erişim'}
|
<div className="group cursor-default">
|
||||||
</span>
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 group-hover:text-[#CE0515] transition-colors">E-posta Adresi</p>
|
||||||
</li>
|
<p className="text-[#173363] font-black text-lg">{user.email}</p>
|
||||||
<li className="flex items-center justify-between pt-2">
|
</div>
|
||||||
<span className="font-bold text-slate-400 uppercase tracking-widest text-[10px]">Sistem Tarihi</span>
|
<div className="group cursor-default">
|
||||||
<span className="text-[#173363] font-black">{new Date().toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 group-hover:text-[#CE0515] transition-colors">Hesap Yetkisi</p>
|
||||||
</li>
|
<span className="inline-flex items-center gap-3 text-[#CE0515] font-black bg-rose-50 px-5 py-2.5 rounded-2xl text-xs shadow-sm shadow-rose-100 transition-all hover:bg-[#CE0515] hover:text-white hover:scale-105">
|
||||||
</ul>
|
<div className="w-2 h-2 rounded-full bg-current animate-pulse" />
|
||||||
|
{(role as any)?.description || 'Sistem Erişimi'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="group cursor-default">
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 group-hover:text-[#CE0515] transition-colors">Son Giriş</p>
|
||||||
|
<p className="text-[#173363] font-black text-lg">
|
||||||
|
{new Date(user.last_sign_in_at!).toLocaleDateString('tr-TR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="group cursor-default">
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 group-hover:text-[#CE0515] transition-colors">Sistem Tarihi</p>
|
||||||
|
<p className="text-[#173363] font-black text-lg">
|
||||||
|
{new Date().toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links Placeholder */}
|
||||||
|
<div className="bg-slate-50 border border-slate-100 rounded-[2.5rem] p-10 flex flex-col justify-center items-center text-center">
|
||||||
|
<div className="w-16 h-16 bg-white rounded-2xl shadow-sm text-[#173363] flex items-center justify-center mb-6">
|
||||||
|
<CalendarDaysIcon className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-[#173363] mb-2">Hızlı İşlemler</h3>
|
||||||
|
<p className="text-sm text-slate-500 font-medium mb-8">İzin talebi oluşturmak veya yönetmek için tıklayın.</p>
|
||||||
|
<a href="/leave-requests" className="w-full py-4 bg-[#173363] text-white rounded-2xl font-black text-sm uppercase tracking-widest hover:bg-[#CE0515] transition-all hover:shadow-xl hover:shadow-red-900/20 active:scale-95">
|
||||||
|
İzin Yönetimi
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, Fragment } from 'react'
|
||||||
|
import { CalendarDaysIcon, XMarkIcon, UserIcon, BuildingOfficeIcon, TagIcon, ChatBubbleBottomCenterTextIcon, PencilIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { submitLeaveRequest, updateLeaveRequest } from '@/app/leave-requests/actions'
|
||||||
|
|
||||||
|
interface LeaveRequestFormProps {
|
||||||
|
leaveTypes: any[]
|
||||||
|
companies: any[]
|
||||||
|
allEmployees?: any[] // Only for admins
|
||||||
|
initialData?: any // For edit mode
|
||||||
|
readOnly?: boolean // For approved view mode
|
||||||
|
trigger?: React.ReactNode // Custom trigger button
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeaveRequestForm({ leaveTypes, companies, allEmployees, initialData, readOnly = false, trigger }: LeaveRequestFormProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedCompanyId, setSelectedCompanyId] = useState(initialData?.leave_types?.company_id || initialData?.employees?.company_id || companies[0]?.company_id || companies[0]?.companies?.id || '')
|
||||||
|
|
||||||
|
// Calculate if user is admin/manager for the currently selected company
|
||||||
|
const currentCompanyRole = companies.find(c => (c.company_id || c.companies?.id) === selectedCompanyId)?.roles?.name;
|
||||||
|
const isAdminOrManager = currentCompanyRole === 'admin' || currentCompanyRole === 'manager';
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState(initialData ? `${initialData.employees?.users?.first_name} ${initialData.employees?.users?.last_name}` : '')
|
||||||
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState(initialData?.employee_id || '')
|
||||||
|
const [selectedEmployeeName, setSelectedEmployeeName] = useState(initialData ? `${initialData.employees?.users?.first_name} ${initialData.employees?.users?.last_name}` : '')
|
||||||
|
const [showEmployeeResults, setShowEmployeeResults] = useState(false)
|
||||||
|
const [startDate, setStartDate] = useState(initialData?.start_date || '')
|
||||||
|
const [endDate, setEndDate] = useState(initialData?.end_date || '')
|
||||||
|
|
||||||
|
// Calculate business days (excluding weekends)
|
||||||
|
const calculateDays = (start: string, end: string) => {
|
||||||
|
if (!start || !end) return 0
|
||||||
|
const s = new Date(start)
|
||||||
|
const e = new Date(end)
|
||||||
|
if (e < s) return 0
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
let current = new Date(s)
|
||||||
|
while (current <= e) {
|
||||||
|
const dayOfWeek = current.getDay()
|
||||||
|
if (dayOfWeek !== 0 && dayOfWeek !== 6) count++
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDays = calculateDays(startDate, endDate)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (readOnly) return;
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
if (initialData?.id) {
|
||||||
|
formData.append('id', initialData.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = initialData ? await updateLeaveRequest(formData) : await submitLeaveRequest(formData)
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError(result.error)
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
if (!initialData) {
|
||||||
|
setSearchTerm('')
|
||||||
|
setSelectedEmployeeId('')
|
||||||
|
setSelectedEmployeeName('')
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter employees by selected company AND search term
|
||||||
|
const filteredEmployees = allEmployees?.filter(emp => {
|
||||||
|
const matchesCompany = emp.company_id === selectedCompanyId
|
||||||
|
const fullName = `${emp.users.first_name} ${emp.users.last_name}`.toLowerCase()
|
||||||
|
const matchesSearch = fullName.includes(searchTerm.toLowerCase())
|
||||||
|
return matchesCompany && matchesSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger ? (
|
||||||
|
<div onClick={() => setIsOpen(true)}>{trigger}</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#CE0515] px-6 py-3 text-sm font-bold text-white shadow-lg shadow-red-100 hover:bg-[#b00412] transition-all active:scale-95 group"
|
||||||
|
>
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 group-hover:rotate-12 transition-transform" />
|
||||||
|
Yeni İzin Talebi
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||||
|
onClick={() => !loading && setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="relative w-full max-w-lg bg-white dark:bg-zinc-900 rounded-[2.5rem] shadow-2xl border border-slate-100 dark:border-zinc-800 p-8 animate-in zoom-in-95 slide-in-from-bottom-10 duration-500 overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-black text-[#173363] dark:text-white tracking-tight flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-red-50 dark:bg-red-500/10 rounded-2xl">
|
||||||
|
<CalendarDaysIcon className="h-6 w-6 text-[#CE0515]" />
|
||||||
|
</div>
|
||||||
|
{readOnly ? 'İzin Talebi Detayı' : (initialData ? 'İzin Talebini Düzenle' : 'İzin Talebi Oluştur')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400 font-medium">
|
||||||
|
{readOnly ? 'Onaylanmış izinler üzerinde değişiklik yapılamaz.' : 'Lütfen izin detaylarını eksiksiz doldurunuz.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-2xl bg-red-50 dark:bg-red-500/10 text-sm text-[#CE0515] border border-red-100 dark:border-red-500/20 font-bold">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Şirket Seçimi */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="company_id" className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<BuildingOfficeIcon className="h-3.5 w-3.5" /> Şirket
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="company_id"
|
||||||
|
name="company_id"
|
||||||
|
required
|
||||||
|
disabled={loading || readOnly || !!initialData}
|
||||||
|
value={selectedCompanyId}
|
||||||
|
onChange={(e) => setSelectedCompanyId(e.target.value)}
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{companies.map((emp: any) => (
|
||||||
|
<option key={emp.company_id || emp.companies?.id} value={emp.company_id || emp.companies?.id}>
|
||||||
|
{emp.companies?.name || emp.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personel Seçimi (Sadece Yönetici) */}
|
||||||
|
{isAdminOrManager && allEmployees && (
|
||||||
|
<div className="space-y-2 relative">
|
||||||
|
<label className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<UserIcon className="h-3.5 w-3.5" /> Personel Arama
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Personel adı veya soyadı ile arayın..."
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={loading || readOnly || !!initialData}
|
||||||
|
value={searchTerm || selectedEmployeeName}
|
||||||
|
onFocus={() => {
|
||||||
|
if (loading || readOnly || initialData) return
|
||||||
|
setShowEmployeeResults(true)
|
||||||
|
setSelectedEmployeeName('')
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value)
|
||||||
|
setShowEmployeeResults(true)
|
||||||
|
}}
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="employee_id" value={selectedEmployeeId} />
|
||||||
|
|
||||||
|
{showEmployeeResults && !readOnly && !initialData && (searchTerm.length > 0 || filteredEmployees?.length) && (
|
||||||
|
<div className="absolute z-10 mt-2 w-full max-h-60 overflow-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-slate-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<div
|
||||||
|
className="p-3 hover:bg-slate-50 dark:hover:bg-zinc-800 cursor-pointer transition-colors border-b border-slate-50 dark:border-zinc-800"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmployeeId('')
|
||||||
|
setSelectedEmployeeName('Kendim İçin')
|
||||||
|
setSearchTerm('')
|
||||||
|
setShowEmployeeResults(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-slate-400 italic">Kendim İçin</span>
|
||||||
|
</div>
|
||||||
|
{filteredEmployees?.map((emp: any) => (
|
||||||
|
<div
|
||||||
|
key={emp.id}
|
||||||
|
className="p-3 hover:bg-slate-50 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmployeeId(emp.id)
|
||||||
|
setSelectedEmployeeName(`${emp.users.first_name} ${emp.users.last_name}`)
|
||||||
|
setSearchTerm(`${emp.users.first_name} ${emp.users.last_name}`)
|
||||||
|
setShowEmployeeResults(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-bold text-[#173363] dark:text-white">
|
||||||
|
{emp.users.first_name} {emp.users.last_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredEmployees?.length === 0 && searchTerm.length > 0 && (
|
||||||
|
<div className="p-4 text-center text-xs text-slate-400 font-bold uppercase tracking-widest">
|
||||||
|
Sonuç bulunamadı
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* İzin Türü */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="leave_type_id" className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<TagIcon className="h-3.5 w-3.5" /> İzin Türü
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="leave_type_id"
|
||||||
|
name="leave_type_id"
|
||||||
|
required
|
||||||
|
disabled={loading || readOnly}
|
||||||
|
defaultValue={initialData?.leave_type_id}
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{leaveTypes.filter(t => !t.only_admin_can_create || isAdminOrManager).map((t: any) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tarihler */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="start_date" className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
Başlangıç
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="start_date"
|
||||||
|
id="start_date"
|
||||||
|
required
|
||||||
|
disabled={loading || readOnly}
|
||||||
|
defaultValue={initialData?.start_date}
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50"
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="end_date" className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
Bitiş
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="end_date"
|
||||||
|
id="end_date"
|
||||||
|
required
|
||||||
|
disabled={loading || readOnly}
|
||||||
|
defaultValue={initialData?.end_date}
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50"
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gün Sayısı Göstergesi */}
|
||||||
|
{totalDays > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 rounded-2xl bg-slate-50 dark:bg-zinc-800/50 border border-slate-100 dark:border-zinc-800 animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
<span className="text-sm font-bold text-slate-500 dark:text-slate-400">Tahmini İzin Süresi:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-black text-[#CE0515]">{totalDays}</span>
|
||||||
|
<span className="text-xs font-black text-[#173363] dark:text-slate-300 uppercase tracking-widest">İş Günü</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Açıklama */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reason" className="text-xs font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<ChatBubbleBottomCenterTextIcon className="h-3.5 w-3.5" /> Açıklama
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="reason"
|
||||||
|
id="reason"
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
disabled={loading || readOnly}
|
||||||
|
defaultValue={initialData?.reason}
|
||||||
|
placeholder="İzin nedeninizi belirtiniz..."
|
||||||
|
className="w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 p-4 text-sm font-bold text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none resize-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex items-center justify-center rounded-[1.25rem] bg-[#173363] px-6 py-4 text-sm font-black text-white shadow-xl shadow-blue-900/10 hover:bg-[#1e4585] transition-all active:scale-95 disabled:opacity-50 disabled:active:scale-100"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
Gönderiliyor...
|
||||||
|
</div>
|
||||||
|
) : (initialData ? 'Değişiklikleri Kaydet' : 'Talep Oluştur')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { BuildingOfficeIcon, XMarkIcon, PlusIcon } from '@heroicons/react/24/outline'
|
||||||
|
import CompanyForm from './CompanyForm'
|
||||||
|
|
||||||
|
export default function AddCompanyModal() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#CE0515] px-6 py-3 text-sm font-bold text-white shadow-lg shadow-red-100 hover:bg-[#b00412] transition-all active:scale-95 group"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-5 w-5 group-hover:rotate-90 transition-transform duration-300" />
|
||||||
|
Yeni Şirket Ekle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="relative w-full max-w-md bg-white dark:bg-zinc-900 rounded-[2.5rem] shadow-2xl border border-slate-100 dark:border-zinc-800 p-8 animate-in zoom-in-95 slide-in-from-bottom-10 duration-500 overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-black text-[#173363] dark:text-white tracking-tight flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-red-50 dark:bg-red-500/10 rounded-2xl">
|
||||||
|
<BuildingOfficeIcon className="h-6 w-6 text-[#CE0515]" />
|
||||||
|
</div>
|
||||||
|
Şirket Kaydı
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400 font-medium">Lütfen eklemek istediğiniz şirketin adını giriniz.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompanyForm onSuccess={() => setIsOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,48 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState, useEffect } from 'react';
|
||||||
import { addCompany } from '@/app/companies/actions';
|
import { addCompany } from '@/app/companies/actions';
|
||||||
|
|
||||||
export default function CompanyForm() {
|
interface CompanyFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyForm({ onSuccess }: CompanyFormProps) {
|
||||||
const [state, formAction, isPending] = useActionState(
|
const [state, formAction, isPending] = useActionState(
|
||||||
async (prevState: any, formData: FormData) => {
|
async (prevState: any, formData: FormData) => {
|
||||||
return await addCompany(formData);
|
const result = await addCompany(formData);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.success && onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}, [state, onSuccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="mt-5 space-y-4">
|
<form action={formAction} className="space-y-6">
|
||||||
{state?.error && (
|
{state?.error && (
|
||||||
<div className="p-4 text-xs font-black text-rose-600 bg-rose-50 border border-rose-100 rounded-2xl text-center uppercase tracking-widest leading-relaxed">
|
<div className="p-4 text-xs font-black text-[#CE0515] bg-red-50 dark:bg-red-500/10 border border-red-100 dark:border-red-500/20 rounded-2xl text-center uppercase tracking-widest leading-relaxed animate-in shake duration-300">
|
||||||
{state.error}
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{state?.success && (
|
|
||||||
<div className="p-4 text-xs font-black text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-2xl text-center uppercase tracking-widest leading-relaxed">
|
|
||||||
Şirket başarıyla kaydedildi.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Şirket Adı</label>
|
<label htmlFor="name" className="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] ml-1">
|
||||||
|
Şirket Adı
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
id="name"
|
id="name"
|
||||||
required
|
required
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="block w-full rounded-2xl border-0 py-3 px-4 text-slate-900 shadow-sm ring-1 ring-inset ring-slate-200 dark:ring-zinc-700 dark:bg-zinc-800 dark:text-white placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm outline-none transition-all disabled:opacity-50"
|
className="block w-full rounded-2xl border-none bg-slate-50 dark:bg-zinc-800 py-4 px-5 text-[#173363] dark:text-white ring-1 ring-slate-100 dark:ring-zinc-700 focus:ring-2 focus:ring-[#CE0515] transition-all outline-none disabled:opacity-50 font-bold placeholder:font-medium"
|
||||||
placeholder="Abisena Ltd. Şti."
|
placeholder="Şirket ismini buraya yazın..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="submit"
|
<div className="pt-2">
|
||||||
disabled={isPending}
|
<button
|
||||||
className="inline-flex w-full items-center justify-center rounded-2xl bg-indigo-600 px-4 py-3 text-sm font-bold text-white shadow-sm shadow-indigo-100 dark:shadow-none hover:bg-indigo-700 transition-all active:scale-95 disabled:opacity-50"
|
type="submit"
|
||||||
>
|
disabled={isPending}
|
||||||
{isPending ? 'KAYDEDİLİYOR...' : 'Şirket Kaydet'}
|
className="w-full inline-flex items-center justify-center rounded-[1.25rem] bg-[#173363] px-6 py-4 text-sm font-black text-white shadow-xl shadow-blue-900/10 hover:bg-[#1e4585] transition-all active:scale-95 disabled:opacity-50 disabled:active:scale-100"
|
||||||
</button>
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
Kaydediliyor...
|
||||||
|
</div>
|
||||||
|
) : 'Şirketi Kaydet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,19 @@ const navigation = [
|
|||||||
{ name: 'Ayarlar', href: '/settings', icon: Cog6ToothIcon },
|
{ name: 'Ayarlar', href: '/settings', icon: Cog6ToothIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ isAdmin = false }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const filteredNavigation = navigation.filter(item => {
|
||||||
|
if (item.href === '/companies') return isAdmin
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
// Listen for mobile menu open event from Header
|
// Listen for mobile menu open event from Header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpen = () => setSidebarOpen(true);
|
const handleOpen = () => setSidebarOpen(true);
|
||||||
@@ -67,7 +76,7 @@ export function Sidebar() {
|
|||||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||||
<li>
|
<li>
|
||||||
<ul role="list" className="-mx-2 space-y-2">
|
<ul role="list" className="-mx-2 space-y-2">
|
||||||
{navigation.map((item) => (
|
{filteredNavigation.map((item) => (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -127,7 +136,7 @@ export function Sidebar() {
|
|||||||
<li>
|
<li>
|
||||||
<div className="text-[10px] font-bold tracking-widest leading-6 text-slate-400 mb-4 uppercase">YÖNETİM PANELİ</div>
|
<div className="text-[10px] font-bold tracking-widest leading-6 text-slate-400 mb-4 uppercase">YÖNETİM PANELİ</div>
|
||||||
<ul role="list" className="-mx-2 space-y-2">
|
<ul role="list" className="-mx-2 space-y-2">
|
||||||
{navigation.map((item) => (
|
{filteredNavigation.map((item) => (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
-- 1. Create Public Holidays Table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.public_holidays (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_recurring BOOLEAN DEFAULT false, -- If true, year is ignored (e.g., Oct 29)
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed some recurring Turkish public holidays
|
||||||
|
INSERT INTO public.public_holidays (date, name, is_recurring) VALUES
|
||||||
|
('2024-01-01', 'Yılbaşı', true),
|
||||||
|
('2024-04-23', 'Ulusal Egemenlik ve Çocuk Bayramı', true),
|
||||||
|
('2024-05-01', 'Emek ve Dayanışma Günü', true),
|
||||||
|
('2024-05-19', 'Atatürk''ü Anma, Gençlik ve Spor Bayramı', true),
|
||||||
|
('2024-07-15', 'Demokrasi ve Milli Birlik Günü', true),
|
||||||
|
('2024-08-30', 'Zafer Bayramı', true),
|
||||||
|
('2024-10-29', 'Cumhuriyet Bayramı', true)
|
||||||
|
ON CONFLICT (date) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Create Leave Types Table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.leave_types (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
is_paid BOOLEAN DEFAULT true,
|
||||||
|
requires_approval BOOLEAN DEFAULT true,
|
||||||
|
is_deductible BOOLEAN DEFAULT true, -- If true, reduces standard balance
|
||||||
|
only_admin_can_create BOOLEAN DEFAULT false, -- If true, only managers/admins can initiate
|
||||||
|
color_code TEXT,
|
||||||
|
display_order INTEGER DEFAULT 99, -- Sort order for UI
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add column if it doesn't exist (for cases where table already existed)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_types' AND column_name='display_order') THEN
|
||||||
|
ALTER TABLE public.leave_types ADD COLUMN display_order INTEGER DEFAULT 99;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Seed Leave Types with display_order
|
||||||
|
INSERT INTO public.leave_types (name, description, is_paid, requires_approval, is_deductible, only_admin_can_create, color_code, display_order) VALUES
|
||||||
|
('Yıllık İzin', 'Kıdeme bağlı hakedilen ücretli izin', true, true, true, false, '#4CAF50', 1),
|
||||||
|
('Mazeret İzni', 'Özel durumlar için kısa süreli izinler', true, true, true, false, '#FF9800', 2),
|
||||||
|
('Hastalık İzni', 'Raporlu olunan günler', true, true, false, false, '#F44336', 3),
|
||||||
|
('Evlilik İzni', 'Evlilik durumunda verilen 3 günlük yasal izin', true, true, false, false, '#E91E63', 4),
|
||||||
|
('Vefat İzni', 'Birinci derece yakın vefatı durumunda verilen 3 günlük yasal izin', true, true, false, false, '#607D8B', 5),
|
||||||
|
('İdari İzin', 'Yönetim tarafından verilen idari izin', true, true, false, true, '#2196F3', 6),
|
||||||
|
('İcap', 'Nöbet/İcap görevleri için verilen izin', true, true, false, true, '#9C27B0', 7)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET display_order = EXCLUDED.display_order;
|
||||||
|
|
||||||
|
-- 3. Modify Leave Requests Table
|
||||||
|
-- First, add columns (handle case where they might already exist if re-run)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_requests' AND column_name='leave_type_id') THEN
|
||||||
|
ALTER TABLE public.leave_requests ADD COLUMN leave_type_id UUID REFERENCES public.leave_types(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_requests' AND column_name='total_days') THEN
|
||||||
|
ALTER TABLE public.leave_requests ADD COLUMN total_days DECIMAL(5,2);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_requests' AND column_name='approved_by') THEN
|
||||||
|
ALTER TABLE public.leave_requests ADD COLUMN approved_by UUID REFERENCES auth.users(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_requests' AND column_name='approval_date') THEN
|
||||||
|
ALTER TABLE public.leave_requests ADD COLUMN approval_date TIMESTAMP WITH TIME ZONE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 4. Update Leave Balances Table
|
||||||
|
-- We need to drop the unique constraint on employee_id because now it's employee_id + leave_type_id + year
|
||||||
|
ALTER TABLE public.leave_balances DROP CONSTRAINT IF EXISTS leave_balances_employee_id_key;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_balances' AND column_name='leave_type_id') THEN
|
||||||
|
ALTER TABLE public.leave_balances ADD COLUMN leave_type_id UUID REFERENCES public.leave_types(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_balances' AND column_name='year') THEN
|
||||||
|
ALTER TABLE public.leave_balances ADD COLUMN year INTEGER DEFAULT EXTRACT(YEAR FROM CURRENT_DATE);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='leave_balances' AND column_name='accrued_days') THEN
|
||||||
|
ALTER TABLE public.leave_balances ADD COLUMN accrued_days DECIMAL(5,2) DEFAULT 0;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Update existing data: map annual leave type to existing balances
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
annual_leave_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO annual_leave_id FROM public.leave_types WHERE name = 'Yıllık İzin' LIMIT 1;
|
||||||
|
|
||||||
|
UPDATE public.leave_balances SET leave_type_id = annual_leave_id WHERE leave_type_id IS NULL;
|
||||||
|
UPDATE public.leave_requests SET leave_type_id = annual_leave_id WHERE leave_type_id IS NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Now add NOT NULL and UNIQUE constraint
|
||||||
|
ALTER TABLE public.leave_balances ALTER COLUMN leave_type_id SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'leave_balances_employee_type_year_unique') THEN
|
||||||
|
ALTER TABLE public.leave_balances ADD CONSTRAINT leave_balances_employee_type_year_unique UNIQUE(employee_id, leave_type_id, year);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 5. Helper Function: Calculate Leave Days
|
||||||
|
CREATE OR REPLACE FUNCTION public.calculate_leave_days(p_start_date DATE, p_end_date DATE)
|
||||||
|
RETURNS DECIMAL AS $$
|
||||||
|
DECLARE
|
||||||
|
curr_date DATE;
|
||||||
|
total_days DECIMAL := 0;
|
||||||
|
is_holiday BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
curr_date := p_start_date;
|
||||||
|
WHILE curr_date <= p_end_date LOOP
|
||||||
|
-- Sunday (0) is never counts.
|
||||||
|
-- Saturday logic: Depends on company, but generally counts in law.
|
||||||
|
-- We'll exclude Sundays and Public Holidays.
|
||||||
|
IF EXTRACT(DOW FROM curr_date) != 0 THEN
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM public.public_holidays
|
||||||
|
WHERE (is_recurring AND EXTRACT(MONTH FROM date) = EXTRACT(MONTH FROM curr_date) AND EXTRACT(DAY FROM date) = EXTRACT(DAY FROM curr_date))
|
||||||
|
OR (NOT is_recurring AND date = curr_date)
|
||||||
|
) INTO is_holiday;
|
||||||
|
|
||||||
|
IF NOT is_holiday THEN
|
||||||
|
total_days := total_days + 1;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
curr_date := curr_date + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN total_days;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 6. Trigger to auto-calculate total_days on leave_requests
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_leave_request_days()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.total_days := public.calculate_leave_days(NEW.start_date, NEW.end_date);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tr_calculate_leave_days ON public.leave_requests;
|
||||||
|
CREATE TRIGGER tr_calculate_leave_days
|
||||||
|
BEFORE INSERT OR UPDATE OF start_date, end_date ON public.leave_requests
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE public.handle_leave_request_days();
|
||||||
|
|
||||||
|
-- 7. Seniority based Accrual Function
|
||||||
|
CREATE OR REPLACE FUNCTION public.calculate_annual_leave_entitlement(p_employee_id UUID)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_hire_date DATE;
|
||||||
|
v_years_service INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(start_date, hire_date, created_at::date) INTO v_hire_date
|
||||||
|
FROM public.employees WHERE id = p_employee_id;
|
||||||
|
|
||||||
|
v_years_service := EXTRACT(YEAR FROM age(CURRENT_DATE, v_hire_date));
|
||||||
|
|
||||||
|
IF v_years_service < 1 THEN RETURN 0; -- No leave in first year
|
||||||
|
ELSIF v_years_service < 5 THEN RETURN 14;
|
||||||
|
ELSIF v_years_service < 15 THEN RETURN 20;
|
||||||
|
ELSE RETURN 26;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 8. Updated Balance Trigger
|
||||||
|
-- We need to replace handle_new_employee_balance to handle new logic
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_employee_balance()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
annual_leave_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO annual_leave_id FROM public.leave_types WHERE name = 'Yıllık İzin' LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO public.leave_balances (employee_id, leave_type_id, year, accrued_days, total_days)
|
||||||
|
VALUES (NEW.id, annual_leave_id, EXTRACT(YEAR FROM CURRENT_DATE), 0, 0)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- 9. Function to update balance based on leave request status
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_leave_balance_on_status_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_is_deductible BOOLEAN;
|
||||||
|
v_annual_leave_id UUID;
|
||||||
|
v_year INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT is_deductible INTO v_is_deductible FROM public.leave_types WHERE id = NEW.leave_type_id;
|
||||||
|
|
||||||
|
IF v_is_deductible THEN
|
||||||
|
v_year := EXTRACT(YEAR FROM NEW.start_date);
|
||||||
|
|
||||||
|
-- Ensure balance record exists for this year/type
|
||||||
|
INSERT INTO public.leave_balances (employee_id, leave_type_id, year)
|
||||||
|
VALUES (NEW.employee_id, NEW.leave_type_id, v_year)
|
||||||
|
ON CONFLICT (employee_id, leave_type_id, year) DO NOTHING;
|
||||||
|
|
||||||
|
-- Update based on status
|
||||||
|
IF NEW.status = 'approved' AND (OLD.status IS NULL OR OLD.status != 'approved') THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET used_days = used_days + NEW.total_days,
|
||||||
|
pending_days = CASE WHEN OLD.status = 'pending' THEN pending_days - NEW.total_days ELSE pending_days END
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year;
|
||||||
|
ELSIF NEW.status = 'pending' AND (OLD.status IS NULL OR OLD.status != 'pending') THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET pending_days = pending_days + NEW.total_days
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year;
|
||||||
|
ELSIF (NEW.status = 'rejected' OR NEW.status = 'cancelled') AND OLD.status = 'pending' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET pending_days = pending_days - NEW.total_days
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year;
|
||||||
|
ELSIF (NEW.status = 'cancelled') AND OLD.status = 'approved' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET used_days = used_days - NEW.total_days
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- 10. Update RLS for leave_balances (ensure proper access)
|
||||||
|
ALTER TABLE public.leave_balances ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Admins can do everything on leave_balances" ON public.leave_balances;
|
||||||
|
CREATE POLICY "Admins can do everything on leave_balances"
|
||||||
|
ON public.leave_balances
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.employees e
|
||||||
|
JOIN public.roles r ON e.role_id = r.id
|
||||||
|
WHERE e.user_id = auth.uid() AND r.name IN ('admin', 'manager')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can view their own leave balance" ON public.leave_balances;
|
||||||
|
CREATE POLICY "Users can view their own leave balance"
|
||||||
|
ON public.leave_balances
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
employee_id IN (
|
||||||
|
SELECT id FROM public.employees WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tr_update_leave_balance ON public.leave_requests;
|
||||||
|
CREATE TRIGGER tr_update_leave_balance
|
||||||
|
AFTER INSERT OR UPDATE OF status ON public.leave_requests
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE public.update_leave_balance_on_status_change();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Fix Leave Requests RLS to allow managers to approve/reject
|
||||||
|
-- Enable UPDATE for managers belonging to the same company as the request
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Managers can update leave requests in their company" ON public.leave_requests;
|
||||||
|
|
||||||
|
CREATE POLICY "Managers can update leave requests in their company"
|
||||||
|
ON public.leave_requests
|
||||||
|
FOR UPDATE TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.employees manager_emp
|
||||||
|
JOIN public.roles r ON manager_emp.role_id = r.id
|
||||||
|
WHERE manager_emp.user_id = auth.uid()
|
||||||
|
AND r.name IN ('admin', 'manager')
|
||||||
|
AND manager_emp.company_id = (
|
||||||
|
SELECT company_id FROM public.employees target_emp
|
||||||
|
WHERE target_emp.id = public.leave_requests.employee_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.employees manager_emp
|
||||||
|
JOIN public.roles r ON manager_emp.role_id = r.id
|
||||||
|
WHERE manager_emp.user_id = auth.uid()
|
||||||
|
AND r.name IN ('admin', 'manager')
|
||||||
|
AND manager_emp.company_id = (
|
||||||
|
SELECT company_id FROM public.employees target_emp
|
||||||
|
WHERE target_emp.id = public.leave_requests.employee_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ensure managers can also view everything in their company
|
||||||
|
DROP POLICY IF EXISTS "Managers can view all leave requests in their company" ON public.leave_requests;
|
||||||
|
CREATE POLICY "Managers can view all leave requests in their company"
|
||||||
|
ON public.leave_requests
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.employees manager_emp
|
||||||
|
JOIN public.roles r ON manager_emp.role_id = r.id
|
||||||
|
WHERE manager_emp.user_id = auth.uid()
|
||||||
|
AND r.name IN ('admin', 'manager')
|
||||||
|
AND manager_emp.company_id = (
|
||||||
|
SELECT company_id FROM public.employees target_emp
|
||||||
|
WHERE target_emp.id = public.leave_requests.employee_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
employee_id IN (
|
||||||
|
SELECT id FROM public.employees WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update existing records with NULL total_days
|
||||||
|
UPDATE public.leave_requests
|
||||||
|
SET total_days = public.calculate_leave_days(start_date, end_date)
|
||||||
|
WHERE total_days IS NULL;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Robust Leave Balance Trigger to handle ALL status transitions and edits
|
||||||
|
-- This handles Reset to Pending, Date Changes, and Role Changes
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_leave_balance_on_status_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_is_deductible_old BOOLEAN;
|
||||||
|
v_is_deductible_new BOOLEAN;
|
||||||
|
v_year_old INTEGER;
|
||||||
|
v_year_new INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- 1. REVERT OLD STATE
|
||||||
|
IF OLD IS NOT NULL THEN
|
||||||
|
SELECT is_deductible INTO v_is_deductible_old FROM public.leave_types WHERE id = OLD.leave_type_id;
|
||||||
|
|
||||||
|
IF v_is_deductible_old THEN
|
||||||
|
v_year_old := EXTRACT(YEAR FROM OLD.start_date);
|
||||||
|
|
||||||
|
-- Revert based on OLD status
|
||||||
|
IF OLD.status = 'approved' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET used_days = used_days - OLD.total_days
|
||||||
|
WHERE employee_id = OLD.employee_id AND leave_type_id = OLD.leave_type_id AND year = v_year_old;
|
||||||
|
ELSIF OLD.status = 'pending' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET pending_days = pending_days - OLD.total_days
|
||||||
|
WHERE employee_id = OLD.employee_id AND leave_type_id = OLD.leave_type_id AND year = v_year_old;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. APPLY NEW STATE
|
||||||
|
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
|
||||||
|
IF NEW.status != 'cancelled' AND NEW.status != 'rejected' THEN
|
||||||
|
SELECT is_deductible INTO v_is_deductible_new FROM public.leave_types WHERE id = NEW.leave_type_id;
|
||||||
|
|
||||||
|
IF v_is_deductible_new THEN
|
||||||
|
v_year_new := EXTRACT(YEAR FROM NEW.start_date);
|
||||||
|
|
||||||
|
-- Ensure balance record exists
|
||||||
|
INSERT INTO public.leave_balances (employee_id, leave_type_id, year)
|
||||||
|
VALUES (NEW.employee_id, NEW.leave_type_id, v_year_new)
|
||||||
|
ON CONFLICT (employee_id, leave_type_id, year) DO NOTHING;
|
||||||
|
|
||||||
|
-- Apply based on NEW status
|
||||||
|
IF NEW.status = 'approved' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET used_days = used_days + NEW.total_days
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year_new;
|
||||||
|
ELSIF NEW.status = 'pending' THEN
|
||||||
|
UPDATE public.leave_balances
|
||||||
|
SET pending_days = pending_days + NEW.total_days
|
||||||
|
WHERE employee_id = NEW.employee_id AND leave_type_id = NEW.leave_type_id AND year = v_year_new;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (TG_OP = 'DELETE') THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tr_update_leave_balance ON public.leave_requests;
|
||||||
|
CREATE TRIGGER tr_update_leave_balance
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON public.leave_requests
|
||||||
|
FOR EACH ROW EXECUTE PROCEDURE public.update_leave_balance_on_status_change();
|
||||||
Reference in New Issue
Block a user