erp-platform/ui/src/views/intranet/Dashboard.tsx
2025-10-28 14:43:45 +03:00

688 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import dayjs from 'dayjs'
import 'dayjs/locale/tr'
import relativeTime from 'dayjs/plugin/relativeTime'
import isBetween from 'dayjs/plugin/isBetween'
// Widgets
import TodayBirthdays from './widgets/TodayBirthdays'
import UpcomingEvents from './widgets/UpcomingEvents'
import RecentDocuments from './widgets/RecentDocuments'
import ImportantAnnouncements from './widgets/ImportantAnnouncements'
import PriorityTasks from './widgets/PriorityTasks'
import MealWeeklyMenu from './widgets/MealWeeklyMenu'
import ShuttleSchedule from './widgets/ShuttleSchedule'
import LeaveManagement from './widgets/LeaveManagement'
import OvertimeManagement from './widgets/OvertimeManagement'
import ExpenseManagement from './widgets/ExpenseManagement'
import UpcomingTrainings from './widgets/UpcomingTrainings'
import ActiveReservations from './widgets/ActiveReservations'
import ActiveSurveys from './widgets/ActiveSurveys'
import Visitors from './widgets/Visitors'
// Modals
import SurveyModal from './modals/SurveyModal'
import LeaveRequestModal from './modals/LeaveRequestModal'
import OvertimeRequestModal from './modals/OvertimeRequestModal'
import ExpenseRequestModal from './modals/ExpenseRequestModal'
import ReservationRequestModal from './modals/ReservationRequestModal'
import AnnouncementDetailModal from './modals/AnnouncementDetailModal'
// Social Wall
import SocialWall from './SocialWall'
import { Announcement, Survey, SurveyAnswer } from '@/types/intranet'
import { Container } from '@/components/shared'
import { usePermission } from '@/utils/hooks/usePermission'
dayjs.locale('tr')
dayjs.extend(relativeTime)
dayjs.extend(isBetween)
interface WidgetConfig {
id: string
permission: string
component: React.ReactNode
column: 'left' | 'center' | 'right'
}
const WIDGET_ORDER_KEY = 'dashboard-widget-order'
const IntranetDashboard: React.FC = () => {
const { checkPermission } = usePermission()
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
const [selectedSurvey, setSelectedSurvey] = useState<Survey | null>(null)
const [showSurveyModal, setShowSurveyModal] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showOvertimeModal, setShowOvertimeModal] = useState(false)
const [showExpenseModal, setShowExpenseModal] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [isDesignMode, setIsDesignMode] = useState(false)
const [widgetOrder, setWidgetOrder] = useState<Record<string, string[]>>({
left: [],
center: [],
right: [],
})
// Drag state'leri birleştirildi
const [dragState, setDragState] = useState<{
draggedId: string | null
targetColumn: string | null
targetIndex: number | null
}>({
draggedId: null,
targetColumn: null,
targetIndex: null,
})
const handleTakeSurvey = (survey: Survey) => {
setSelectedSurvey(survey)
setShowSurveyModal(true)
}
const handleSubmitSurvey = (answers: SurveyAnswer[]) => {
setShowSurveyModal(false)
setSelectedSurvey(null)
}
const handleSubmitLeave = () => {
setShowLeaveModal(false)
}
const handleSubmitOvertime = () => {
setShowOvertimeModal(false)
}
const handleSubmitExpense = () => {
setShowExpenseModal(false)
}
const handleSubmitReservation = () => {
setShowReservationModal(false)
}
// Widget metadata (component'lar yerine sadece meta bilgiler)
const widgetMetadata = [
{ id: 'upcoming-events', permission: 'App.Intranet.Events.Event.Widget', column: 'left' },
{ id: 'today-birthdays', permission: 'App.Hr.Employee.Widget', column: 'left' },
{ id: 'recent-documents', permission: 'App.Files.Widget', column: 'left' },
{ id: 'upcoming-trainings', permission: 'App.Hr.Training.Widget', column: 'left' },
{ id: 'active-reservations', permission: 'App.Intranet.Reservation.Widget', column: 'left' },
{ id: 'active-surveys', permission: 'App.Intranet.Survey.Widget', column: 'left' },
{ id: 'visitors', permission: 'App.Intranet.Visitor.Widget', column: 'left' },
{ id: 'expense-management', permission: 'App.Hr.Expense.Widget', column: 'left' },
{ id: 'social-wall', permission: 'App.Intranet.SocialPost.Widget', column: 'center' },
{
id: 'important-announcements',
permission: 'App.Intranet.Announcement.Widget',
column: 'right',
},
{ id: 'priority-tasks', permission: 'App.Projects.Tasks.Widget', column: 'right' },
{ id: 'meal-weekly-menu', permission: 'App.Intranet.Meal.Widget', column: 'right' },
{ id: 'shuttle-schedule', permission: 'App.Intranet.ShuttleRoute.Widget', column: 'right' },
{ id: 'leave-management', permission: 'App.Hr.Leave.Widget', column: 'right' },
{ id: 'overtime-management', permission: 'App.Hr.Overtime.Widget', column: 'right' },
]
// Widget sıralamasını yükle
useEffect(() => {
const savedOrder = localStorage.getItem(WIDGET_ORDER_KEY)
if (savedOrder) {
try {
const parsed = JSON.parse(savedOrder) as Record<string, unknown[]>
// Duplicate key'leri temizle
const cleanedOrder: Record<string, string[]> = {
left: [...new Set((parsed.left || []) as string[])],
center: [...new Set((parsed.center || []) as string[])],
right: [...new Set((parsed.right || []) as string[])],
}
setWidgetOrder(cleanedOrder)
} catch (error) {
console.error('Widget order parse error:', error)
initializeDefaultOrder()
}
} else {
initializeDefaultOrder()
}
}, [])
const initializeDefaultOrder = () => {
const defaultOrder = {
left: widgetMetadata
.filter((w) => w.column === 'left' && checkPermission(w.permission))
.map((w) => w.id),
center: widgetMetadata
.filter((w) => w.column === 'center' && checkPermission(w.permission))
.map((w) => w.id),
right: widgetMetadata
.filter((w) => w.column === 'right' && checkPermission(w.permission))
.map((w) => w.id),
}
setWidgetOrder(defaultOrder)
}
// Widget sıralamasını kaydet
const saveWidgetOrder = (newOrder: Record<string, string[]>) => {
setWidgetOrder(newOrder)
localStorage.setItem(WIDGET_ORDER_KEY, JSON.stringify(newOrder))
}
const handleDragStart = (e: React.DragEvent, widgetId: string, column: string) => {
setDragState({ draggedId: widgetId, targetColumn: null, targetIndex: null })
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('widgetId', widgetId)
e.dataTransfer.setData('sourceColumn', column)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDragEnterWidget = (e: React.DragEvent, column: string, index: number) => {
// Sadece widget'ın üst kısmına yakınsa indicator göster
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const mouseY = e.clientY
const widgetTop = rect.top
const widgetHeight = rect.height
const threshold = widgetHeight * 0.3 // Üst %30'luk alan
if (mouseY - widgetTop < threshold) {
// Üst kısma yakın - indicator göster
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: index }))
} else {
// Widget'ın ortasında veya altında - indicator gösterme
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: null }))
}
}
const handleDragEnterColumn = (column: string) => {
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: null }))
}
const handleDragLeaveColumn = () => {
setDragState((prev) => ({ ...prev, targetColumn: null, targetIndex: null }))
}
const handleDrop = (e: React.DragEvent, targetColumn: string, targetIndex?: number) => {
e.preventDefault()
e.stopPropagation()
const widgetId = e.dataTransfer.getData('widgetId')
const sourceColumn = e.dataTransfer.getData('sourceColumn')
if (!widgetId || !sourceColumn) return
const newOrder = { ...widgetOrder }
// ÖNCE tüm kolonlardan bu widget'ı kaldır (duplicate önleme)
Object.keys(newOrder).forEach((col) => {
newOrder[col] = newOrder[col].filter((id) => id !== widgetId)
})
// SONRA hedef kolona ekle
if (targetIndex !== undefined) {
newOrder[targetColumn].splice(targetIndex, 0, widgetId)
} else {
newOrder[targetColumn].push(widgetId)
}
// Duplicate'leri temizle
Object.keys(newOrder).forEach((col) => {
newOrder[col] = [...new Set(newOrder[col])]
})
saveWidgetOrder(newOrder)
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
}
const handleDragEnd = () => {
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
}
// Widget component'ını render et
const renderWidgetComponent = (widgetId: string) => {
switch (widgetId) {
case 'upcoming-events':
return <UpcomingEvents />
case 'today-birthdays':
return <TodayBirthdays />
case 'recent-documents':
return <RecentDocuments />
case 'upcoming-trainings':
return <UpcomingTrainings />
case 'active-reservations':
return <ActiveReservations onNewReservation={() => setShowReservationModal(true)} />
case 'active-surveys':
return <ActiveSurveys onTakeSurvey={handleTakeSurvey} />
case 'visitors':
return <Visitors />
case 'expense-management':
return <ExpenseManagement onNewExpense={() => setShowExpenseModal(true)} />
case 'social-wall':
return <SocialWall />
case 'important-announcements':
return <ImportantAnnouncements onAnnouncementClick={setSelectedAnnouncement} />
case 'priority-tasks':
return <PriorityTasks />
case 'meal-weekly-menu':
return <MealWeeklyMenu />
case 'shuttle-schedule':
return <ShuttleSchedule />
case 'leave-management':
return <LeaveManagement onNewLeave={() => setShowLeaveModal(true)} />
case 'overtime-management':
return <OvertimeManagement onNewOvertime={() => setShowOvertimeModal(true)} />
default:
return null
}
}
// Widget'ları render et
const renderWidgets = (column: 'left' | 'center' | 'right') => {
const columnWidgets = widgetOrder[column] || []
// Duplicate'leri filtrele
const uniqueWidgets = [...new Set(columnWidgets)]
return uniqueWidgets
.map((widgetId, index) => {
const metadata = widgetMetadata.find((w) => w.id === widgetId)
if (!metadata || !checkPermission(metadata.permission)) return null
const isDragging = dragState.draggedId === widgetId
const isDropTarget = dragState.targetColumn === column && dragState.targetIndex === index
return (
<div key={`${column}-${widgetId}-${index}`} className="relative group">
{/* Drop indicator - SADECE widget'ların arasına (üst %30'luk alana) gelince göster */}
{isDesignMode && isDropTarget && !isDragging && (
<div className="absolute -top-5 left-0 right-0 z-20 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Çizgi */}
<div className="h-2 bg-gradient-to-r from-transparent via-blue-500 to-transparent rounded-full shadow-lg" />
{/* Badge */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-blue-500 to-blue-600 text-white text-xs px-4 py-2 rounded-full whitespace-nowrap shadow-xl font-semibold flex items-center gap-2 border-2 border-white dark:border-gray-800">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span>Buraya Bırak</span>
</div>
</div>
)}
<div
draggable={isDesignMode}
onDragStart={(e) => {
if (isDesignMode) {
handleDragStart(e, widgetId, column)
// Drag ghost image'i gizle
const ghost = document.createElement('div')
ghost.style.opacity = '0'
e.dataTransfer.setDragImage(ghost, 0, 0)
}
}}
onDragOver={(e) => {
if (!isDesignMode) return
e.preventDefault()
e.stopPropagation()
// Throttle: Sadece düzenli aralıklarla güncelle
const now = Date.now()
if (
!e.currentTarget.dataset.lastUpdate ||
now - parseInt(e.currentTarget.dataset.lastUpdate) > 150
) {
e.currentTarget.dataset.lastUpdate = now.toString()
handleDragEnterWidget(e, column, index)
}
}}
onDragLeave={(e) => {
// Widget'tan çıkınca indicator'ı kaldır
if (isDesignMode) {
setDragState((prev) => ({
...prev,
targetColumn: prev.targetColumn,
targetIndex: null,
}))
}
}}
onDrop={(e) => {
if (!isDesignMode) return
e.stopPropagation()
// Drop pozisyonunu hesapla
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const mouseY = e.clientY
const widgetTop = rect.top
const widgetHeight = rect.height
const threshold = widgetHeight * 0.3
if (mouseY - widgetTop < threshold) {
// Üst kısma bırak - mevcut index'e ekle
handleDrop(e, column, index)
} else {
// Alt kısma bırak - sonraki index'e ekle
handleDrop(e, column, index + 1)
}
}}
onDragEnd={handleDragEnd}
className={`
relative
${
isDesignMode
? `border-2 border-dashed rounded-lg cursor-move
${
isDragging
? 'border-blue-400 opacity-70 bg-blue-50/30 dark:bg-blue-900/10'
: 'border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-md'
}
transition-all duration-300 ease-out`
: 'border-0 transition-none'
}
`}
style={{
touchAction: 'none',
transition:
'border-color 0.3s ease-out, opacity 0.3s ease-out, box-shadow 0.3s ease-out',
willChange: isDragging ? 'opacity' : 'auto',
}}
>
{/* Dragging overlay - daha minimal */}
{isDesignMode && isDragging && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-900/40 rounded-lg z-10 flex items-center justify-center backdrop-blur-[1px] transition-opacity duration-300">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-2 rounded-lg font-medium shadow-lg flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
<span>Taşınıyor</span>
</div>
</div>
)}
<div
className={`${isDesignMode ? 'p-1.5' : ''} transition-all duration-500 ease-out`}
>
{renderWidgetComponent(widgetId)}
</div>
</div>
</div>
)
})
.filter(Boolean)
}
return (
<Container>
<div className="mx-auto space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Design Mode Toggle */}
<div className="flex items-center gap-2">
<label
htmlFor="design-mode-toggle"
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
🎨 Dizayn Modu
</label>
<button
id="design-mode-toggle"
onClick={() => setIsDesignMode(!isDesignMode)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isDesignMode ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}
`}
role="switch"
aria-checked={isDesignMode}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${isDesignMode ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
</div>
{/* Reset Button - Sadece design mode aktifken görünsün */}
{isDesignMode && (
<button
onClick={() => {
localStorage.removeItem(WIDGET_ORDER_KEY)
initializeDefaultOrder()
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex items-center gap-1"
title="Widget düzenini varsayılana döndür"
>
🔄 Sıfırla
</button>
)}
</div>
<div>
<p className="text-gray-600 dark:text-gray-400 mt-1">
<span className="font-medium">Hoş geldiniz,</span>{' '}
{dayjs().format('DD MMMM YYYY dddd')}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12">
<div
className={`lg:col-span-3 space-y-6 min-h-[100px] rounded-xl p-1
${
isDesignMode && dragState.targetColumn === 'left' && dragState.targetIndex === null
? 'bg-blue-50/80 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600 shadow-lg'
: 'bg-transparent'
}
transition-all duration-700 ease-out
`}
onDragOver={(e) => {
if (!isDesignMode) return
e.preventDefault()
// Throttle: Sadece her 150ms'de bir güncelle
const now = Date.now()
const target = e.currentTarget as HTMLElement
if (
!target.dataset.lastColumnUpdate ||
now - parseInt(target.dataset.lastColumnUpdate) > 150
) {
target.dataset.lastColumnUpdate = now.toString()
handleDragEnterColumn('left')
}
}}
onDragLeave={() => {
if (isDesignMode) handleDragLeaveColumn()
}}
onDrop={(e) => {
if (!isDesignMode) return
e.stopPropagation()
const columnWidgets = widgetOrder['left'] || []
handleDrop(e, 'left', columnWidgets.length)
}}
>
{renderWidgets('left')}
{isDesignMode &&
dragState.targetColumn === 'left' &&
widgetOrder['left']?.length === 0 && (
<div className="flex items-center justify-center h-40 border-2 border-dashed border-blue-300 dark:border-blue-600 rounded-xl bg-blue-50/50 dark:bg-blue-900/10 transition-all duration-500 ease-out">
<p className="text-blue-600 dark:text-blue-400 font-medium flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<span>Widget'ı buraya bırakın</span>
</p>
</div>
)}
</div>
<div
className={`lg:col-span-6 space-y-6 min-h-[100px] rounded-xl p-1
${
isDesignMode &&
dragState.targetColumn === 'center' &&
dragState.targetIndex === null
? 'bg-blue-50/80 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600 shadow-lg'
: 'bg-transparent'
}
transition-all duration-700 ease-out
`}
onDragOver={(e) => {
if (!isDesignMode) return
e.preventDefault()
const now = Date.now()
const target = e.currentTarget as HTMLElement
if (
!target.dataset.lastColumnUpdate ||
now - parseInt(target.dataset.lastColumnUpdate) > 150
) {
target.dataset.lastColumnUpdate = now.toString()
handleDragEnterColumn('center')
}
}}
onDragLeave={() => {
if (isDesignMode) handleDragLeaveColumn()
}}
onDrop={(e) => {
if (!isDesignMode) return
e.stopPropagation()
const columnWidgets = widgetOrder['center'] || []
handleDrop(e, 'center', columnWidgets.length)
}}
>
{renderWidgets('center')}
{isDesignMode &&
dragState.targetColumn === 'center' &&
widgetOrder['center']?.length === 0 && (
<div className="flex items-center justify-center rounded-xl bg-blue-50/50 dark:bg-blue-900/10 transition-all duration-500 ease-out">
<p className="text-blue-600 dark:text-blue-400 font-medium flex items-center gap-2">
<span>Widget'ı buraya bırakın</span>
</p>
</div>
)}
</div>
<div
className={`lg:col-span-3 space-y-6 min-h-[100px] rounded-xl p-1
${
isDesignMode && dragState.targetColumn === 'right' && dragState.targetIndex === null
? 'bg-blue-50/80 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600 shadow-lg'
: 'bg-transparent'
}
transition-all duration-700 ease-out
`}
onDragOver={(e) => {
if (!isDesignMode) return
e.preventDefault()
const now = Date.now()
const target = e.currentTarget as HTMLElement
if (
!target.dataset.lastColumnUpdate ||
now - parseInt(target.dataset.lastColumnUpdate) > 150
) {
target.dataset.lastColumnUpdate = now.toString()
handleDragEnterColumn('right')
}
}}
onDragLeave={() => {
if (isDesignMode) handleDragLeaveColumn()
}}
onDrop={(e) => {
if (!isDesignMode) return
e.stopPropagation()
const columnWidgets = widgetOrder['right'] || []
handleDrop(e, 'right', columnWidgets.length)
}}
>
{renderWidgets('right')}
{isDesignMode &&
dragState.targetColumn === 'right' &&
widgetOrder['right']?.length === 0 && (
<div className="flex items-center justify-center h-40 border-2 border-dashed border-blue-300 dark:border-blue-600 rounded-xl bg-blue-50/50 dark:bg-blue-900/10 transition-all duration-500 ease-out">
<p className="text-blue-600 dark:text-blue-400 font-medium flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<span>Widget'ı buraya bırakın</span>
</p>
</div>
)}
</div>
</div>
</div>
<AnimatePresence>
{showSurveyModal && selectedSurvey && (
<SurveyModal
survey={selectedSurvey}
onClose={() => setShowSurveyModal(false)}
onSubmit={handleSubmitSurvey}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showLeaveModal && (
<LeaveRequestModal
onClose={() => setShowLeaveModal(false)}
onSubmit={handleSubmitLeave}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showOvertimeModal && (
<OvertimeRequestModal
onClose={() => setShowOvertimeModal(false)}
onSubmit={handleSubmitOvertime}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showExpenseModal && (
<ExpenseRequestModal
onClose={() => setShowExpenseModal(false)}
onSubmit={handleSubmitExpense}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showReservationModal && (
<ReservationRequestModal
onClose={() => setShowReservationModal(false)}
onSubmit={handleSubmitReservation}
/>
)}
</AnimatePresence>
<AnimatePresence>
{selectedAnnouncement && (
<AnnouncementDetailModal
announcement={selectedAnnouncement}
onClose={() => setSelectedAnnouncement(null)}
/>
)}
</AnimatePresence>
</Container>
)
}
export default IntranetDashboard