sozsoft-platform/ui/src/views/intranet/Dashboard.tsx

653 lines
25 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import isBetween from 'dayjs/plugin/isBetween'
import localizedFormat from 'dayjs/plugin/localizedFormat'
// Widgets
import TodayBirthdays from './widgets/TodayBirthdays'
import RecentDocuments from './widgets/RecentDocuments'
import Surveys from './widgets/Surveys'
// Modals
import SurveyModal from './widgets/SurveyModal'
import AnnouncementModal from './widgets/AnnouncementModal'
2026-05-07 14:25:06 +00:00
import EventModal from './widgets/EventModal'
// Social Wall
import SocialWall from './SocialWall'
import { Container } from '@/components/shared'
import { usePermission } from '@/utils/hooks/usePermission'
import {
AnnouncementDto,
2026-05-07 14:25:06 +00:00
EventDto,
IntranetDashboardDto,
SurveyAnswerDto,
SurveyDto,
} from '@/proxy/intranet/models'
import { intranetService } from '@/services/intranet.service'
import Announcements from './widgets/Announcements'
import { useLocalization } from '@/utils/hooks/useLocalization'
import useLocale from '@/utils/hooks/useLocale'
import { currentLocalDate } from '@/utils/dateUtils'
import { useStoreState } from '@/store/store'
2026-05-06 14:48:44 +00:00
import UpcomingEvents from './widgets/UpcomingEvents'
dayjs.extend(relativeTime)
dayjs.extend(isBetween)
dayjs.extend(localizedFormat)
const WIDGET_ORDER_KEY = 'dashboard-widget-order'
const IntranetDashboard: React.FC = () => {
const { checkPermission } = usePermission()
const [selectedAnnouncement, setSelectedAnnouncement] = useState<AnnouncementDto | null>(null)
2026-05-07 14:25:06 +00:00
const [selectedEvent, setSelectedEvent] = useState<EventDto | null>(null)
const [selectedSurvey, setSelectedSurvey] = useState<SurveyDto | null>(null)
const [showSurveyModal, setShowSurveyModal] = useState(false)
const [isDesignMode, setIsDesignMode] = useState(false)
const [widgetOrder, setWidgetOrder] = useState<Record<string, string[]>>({
left: [],
center: [],
right: [],
})
const [intranetDashboard, setIntranetDashboard] = useState<IntranetDashboardDto>()
const { translate } = useLocalization()
const currentLocale = useLocale()
const fetchIntranetDashboard = async () => {
const dashboard = await intranetService.getDashboard()
if (dashboard.data) {
setIntranetDashboard(dashboard.data)
}
}
useEffect(() => {
fetchIntranetDashboard()
}, [])
// 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: SurveyDto) => {
setSelectedSurvey(survey)
setShowSurveyModal(true)
}
2026-05-06 07:54:04 +00:00
const handleSubmitSurvey = async (answers: SurveyAnswerDto[]) => {
if (!selectedSurvey) return
try {
2026-05-06 13:47:18 +00:00
await intranetService.createSurveyResponse(
2026-05-06 07:54:04 +00:00
selectedSurvey.id,
answers.map((a) => ({
questionId: a.questionId,
questionType: a.questionType,
value: a.value !== undefined && a.value !== null ? String(a.value) : '',
})),
)
await fetchIntranetDashboard()
} catch (e) {
console.error('Survey submit error', e)
}
setShowSurveyModal(false)
setSelectedSurvey(null)
}
// Widget metadata (component'lar yerine sadece meta bilgiler)
const widgetMetadata = [
{ id: 'today-birthdays', permission: 'AbpIdentity.Users.Widget', column: 'left' },
{ id: 'documents', permission: 'App.Files.Widget', column: 'left' },
{ id: 'active-surveys', permission: 'App.Intranet.Survey.Widget', column: 'left' },
{ id: 'social-wall', permission: 'App.Intranet.SocialPost.Widget', column: 'center' },
{
id: 'announcements',
permission: 'App.Intranet.Announcement.Widget',
column: 'right',
},
2026-05-06 14:48:44 +00:00
{ id: 'upcoming-events', permission: 'App.Intranet.Events.Event.Widget', column: 'right' },
]
// If permissions arrive after mount, initialize default order when needed
const grantedPolicies = useStoreState((state) => state.abpConfig?.config?.auth.grantedPolicies)
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ı yükle — grantedPolicies gelince çalış ki checkPermission doğru çalışsın
useEffect(() => {
if (!grantedPolicies) return
const savedOrder = localStorage.getItem(WIDGET_ORDER_KEY)
if (savedOrder) {
try {
const parsed = JSON.parse(savedOrder) as Record<string, unknown[]>
const order: Record<string, string[]> = {
left: [...new Set((parsed.left || []) as string[])],
center: [...new Set((parsed.center || []) as string[])],
right: [...new Set((parsed.right || []) as string[])],
}
// Kaydedilmiş düzende olmayan ama izni olan widgetları varsayılan kolonuna ekle
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
widgetMetadata.forEach((w) => {
if (!allAssigned.has(w.id) && checkPermission(w.permission)) {
order[w.column as keyof typeof order].push(w.id)
}
})
setWidgetOrder(order)
} catch (error) {
console.error('Widget order parse error:', error)
initializeDefaultOrder()
}
} else {
initializeDefaultOrder()
}
}, [grantedPolicies])
// 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 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) {
2026-05-06 14:48:44 +00:00
case 'upcoming-events':
2026-05-07 14:25:06 +00:00
return <UpcomingEvents events={intranetDashboard?.events || []} onEventClick={setSelectedEvent} />
case 'today-birthdays':
return <TodayBirthdays employees={intranetDashboard?.birthdays || []} />
case 'documents':
return <RecentDocuments documents={intranetDashboard?.documents || []} />
case 'announcements':
return (
<Announcements
announcements={intranetDashboard?.announcements || []}
onAnnouncementClick={setSelectedAnnouncement}
/>
)
case 'active-surveys':
return (
2026-05-06 14:48:44 +00:00
<Surveys surveys={intranetDashboard?.surveys || []} onTakeSurvey={handleTakeSurvey} />
)
case 'social-wall':
return <SocialWall />
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"
>
🎨 {translate('::App.Platform.Intranet.Dashboard.DesignMode')}
</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"
>
🔄 {translate('::App.Platform.Intranet.Dashboard.Reset')}
</button>
)}
</div>
<div>
<p className="text-gray-600 dark:text-gray-400 mt-1">
<span className="font-medium">{translate('::AI.Welcome')},</span>{' '}
{currentLocalDate(new Date(), currentLocale || 'tr')}
</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>
2026-05-07 14:25:06 +00:00
<AnimatePresence>
{selectedEvent && (
<EventModal
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
/>
)}
</AnimatePresence>
<AnimatePresence>
{selectedAnnouncement && (
<AnnouncementModal
announcement={selectedAnnouncement}
onClose={() => setSelectedAnnouncement(null)}
/>
)}
</AnimatePresence>
</Container>
)
}
export default IntranetDashboard