sozsoft-platform/ui/src/views/intranet/Dashboard.tsx
2026-08-11 16:18:17 +03:00

638 lines
23 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 relativeTime from 'dayjs/plugin/relativeTime'
import isBetween from 'dayjs/plugin/isBetween'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import SurveyModal from './widgets/SurveyModal'
import AnnouncementModal from './widgets/AnnouncementModal'
import EventModal from './widgets/EventModal'
import { Container } from '@/components/shared'
import { usePermission } from '@/utils/hooks/usePermission'
import {
AnnouncementDto,
EventDto,
IntranetDashboardDto,
SurveyAnswerDto,
SurveyDto,
} from '@/proxy/intranet/models'
import { intranetService } from '@/services/intranet.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import useLocale from '@/utils/hooks/useLocale'
import { currentLocalDate } from '@/utils/dateUtils'
import { useStoreActions, useStoreState } from '@/store/store'
import Button from '@/components/ui/Button'
import { LuX } from 'react-icons/lu'
import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget'
dayjs.extend(relativeTime)
dayjs.extend(isBetween)
dayjs.extend(localizedFormat)
const dashboardColumns: DashboardWidgetColumn[] = ['left', 'center', 'right']
type DashboardWidgetModule = { dashboardWidget?: DashboardWidgetDefinition }
const dashboardWidgets = Object.values(
import.meta.glob<DashboardWidgetModule>(['./widgets/*.tsx', './SocialWall/index.tsx'], {
eager: true,
}),
)
.map((module) => module.dashboardWidget)
.filter((widget): widget is DashboardWidgetDefinition => Boolean(widget))
.sort((left, right) => left.order - right.order)
const layoutPresets = [
{
id: 'default',
columns: [3, 6, 3],
labelKey: '::App.Platform.Intranet.Dashboard.Layout.Default',
},
{
id: 'wide-center',
columns: [2, 8, 2],
labelKey: '::App.Platform.Intranet.Dashboard.Layout.CenterFocus',
},
{
id: 'balanced',
columns: [4, 4, 4],
labelKey: '::App.Platform.Intranet.Dashboard.Layout.Balanced',
},
{
id: 'wide-left',
columns: [4, 6, 2],
labelKey: '::App.Platform.Intranet.Dashboard.Layout.WideLeft',
},
{
id: 'wide-right',
columns: [2, 6, 4],
labelKey: '::App.Platform.Intranet.Dashboard.Layout.WideRight',
},
] as const
const columnSpanClasses: Record<number, string> = {
2: 'lg:col-span-2',
3: 'lg:col-span-3',
4: 'lg:col-span-4',
6: 'lg:col-span-6',
8: 'lg:col-span-8',
}
const IntranetDashboard: React.FC = () => {
const { checkPermission } = usePermission()
const [selectedAnnouncement, setSelectedAnnouncement] = useState<AnnouncementDto | null>(null)
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 { dashboardLayout: selectedLayout, hiddenWidgetIds, widgetOrder } = useStoreState(
(state) => state.admin.dashboard,
)
const {
setDashboardLayout,
setHiddenWidgetIds,
setWidgetOrder,
reset: resetDashboard,
} = useStoreActions((actions) => actions.admin.dashboard)
const [intranetDashboard, setIntranetDashboard] = useState<IntranetDashboardDto>()
const { translate } = useLocalization()
const currentLocale = useLocale()
const fetchIntranetDashboard = async () => {
try {
const dashboard = await intranetService.getDashboard()
if (dashboard.data) {
setIntranetDashboard(dashboard.data)
}
} catch {
// hata apiService tarafından ele alınıyor
}
}
useEffect(() => {
fetchIntranetDashboard()
}, [])
const [dragState, setDragState] = useState<{
draggedId: string | null
targetColumn: DashboardWidgetColumn | null
targetIndex: number | null
}>({
draggedId: null,
targetColumn: null,
targetIndex: null,
})
const handleTakeSurvey = (survey: SurveyDto) => {
setSelectedSurvey(survey)
setShowSurveyModal(true)
}
const handleSubmitSurvey = async (answers: SurveyAnswerDto[]) => {
if (!selectedSurvey) return
try {
await intranetService.createSurveyResponse(
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)
}
const handleEventLikeChange = (updatedEvent: EventDto) => {
setSelectedEvent((current) => (current ? { ...current, ...updatedEvent } : updatedEvent))
setIntranetDashboard((current) =>
current
? {
...current,
events: current.events.map((event) =>
event.id === updatedEvent.id ? { ...event, ...updatedEvent } : event,
),
}
: current,
)
}
const handleAnnouncementLikeChange = (updatedAnnouncement: AnnouncementDto) => {
setSelectedAnnouncement((current) =>
current ? { ...current, ...updatedAnnouncement } : updatedAnnouncement,
)
setIntranetDashboard((current) =>
current
? {
...current,
announcements: current.announcements.map((announcement) =>
announcement.id === updatedAnnouncement.id
? { ...announcement, ...updatedAnnouncement }
: announcement,
),
}
: current,
)
}
const grantedPolicies = useStoreState((state) => state.abpConfig?.config?.auth.grantedPolicies)
const initializeDefaultOrder = () => {
const defaultOrder: Record<DashboardWidgetColumn, string[]> = {
left: [],
center: [],
right: [],
}
dashboardWidgets.forEach((widget) => {
if (checkPermission(widget.permission)) defaultOrder[widget.column].push(widget.id)
})
setWidgetOrder(defaultOrder)
}
useEffect(() => {
if (!grantedPolicies) return
const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0)
if (!hasSavedOrder) {
initializeDefaultOrder()
return
}
// Kayıtlı sıralamayı tekilleştir ve henüz yerleştirilmemiş widget'ları ekle
const order: Record<DashboardWidgetColumn, string[]> = {
left: [...new Set(widgetOrder.left || [])],
center: [...new Set(widgetOrder.center || [])],
right: [...new Set(widgetOrder.right || [])],
}
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
dashboardWidgets.forEach((widget) => {
if (!allAssigned.has(widget.id) && checkPermission(widget.permission)) {
order[widget.column].push(widget.id)
}
})
setWidgetOrder(order)
}, [grantedPolicies])
const setWidgetVisibility = (widgetId: string, visible: boolean) => {
const next = visible
? hiddenWidgetIds.filter((id) => id !== widgetId)
: [...new Set([...hiddenWidgetIds, widgetId])]
setHiddenWidgetIds(next)
}
const activeLayout =
layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0]
const hiddenWidgets = dashboardWidgets.filter(
(widget) => hiddenWidgetIds.includes(widget.id) && checkPermission(widget.permission),
)
const handleDragStart = (e: React.DragEvent, widgetId: string) => {
setDragState({ draggedId: widgetId, targetColumn: null, targetIndex: null })
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('widgetId', widgetId)
}
const handleDragEnterWidget = (
e: React.DragEvent,
column: DashboardWidgetColumn,
index: number,
) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
if (e.clientY - rect.top < rect.height * 0.3) {
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: index }))
} else {
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: null }))
}
}
const handleDragEnterColumn = (column: DashboardWidgetColumn) => {
setDragState((prev) => ({ ...prev, targetColumn: column, targetIndex: null }))
}
const handleDragLeaveColumn = () => {
setDragState((prev) => ({ ...prev, targetColumn: null, targetIndex: null }))
}
const handleDrop = (
e: React.DragEvent,
targetColumn: DashboardWidgetColumn,
targetIndex?: number,
) => {
e.preventDefault()
e.stopPropagation()
const widgetId = e.dataTransfer.getData('widgetId')
if (!widgetId) return
const newOrder = { ...widgetOrder }
dashboardColumns.forEach((col) => {
newOrder[col] = newOrder[col].filter((id) => id !== widgetId)
})
if (targetIndex !== undefined) {
newOrder[targetColumn].splice(targetIndex, 0, widgetId)
} else {
newOrder[targetColumn].push(widgetId)
}
setWidgetOrder(newOrder)
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
}
const handleDragEnd = () => {
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
}
const handleColumnDragOver = (
e: React.DragEvent<HTMLDivElement>,
column: DashboardWidgetColumn,
) => {
if (!isDesignMode) return
e.preventDefault()
const now = Date.now()
const lastUpdate = Number(e.currentTarget.dataset.lastColumnUpdate || 0)
if (now - lastUpdate > 150) {
e.currentTarget.dataset.lastColumnUpdate = now.toString()
handleDragEnterColumn(column)
}
}
const handleColumnDrop = (e: React.DragEvent<HTMLDivElement>, column: DashboardWidgetColumn) => {
if (!isDesignMode) return
handleDrop(e, column, widgetOrder[column].length)
}
const renderWidgets = (column: DashboardWidgetColumn) => {
const columnWidgets = widgetOrder[column] || []
const uniqueWidgets = [...new Set(columnWidgets)]
return uniqueWidgets
.map((widgetId, index) => {
const metadata = dashboardWidgets.find((w) => w.id === widgetId)
if (
!metadata ||
!checkPermission(metadata.permission) ||
hiddenWidgetIds.includes(widgetId)
)
return null
const isDragging = dragState.draggedId === widgetId
const isDropTarget = dragState.targetColumn === column && dragState.targetIndex === index
return (
<div key={`${column}-${widgetId}`} className="relative group">
{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">
<div className="h-2 bg-gradient-to-r from-transparent via-blue-500 to-transparent rounded-full shadow-lg" />
<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>{translate('::App.Platform.Intranet.Dashboard.DropHere')}</span>
</div>
</div>
)}
<div
draggable={isDesignMode}
onDragStart={(e) => {
if (isDesignMode) {
handleDragStart(e, widgetId)
}
}}
onDragOver={(e) => {
if (!isDesignMode) return
e.preventDefault()
e.stopPropagation()
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={() => {
if (isDesignMode) {
setDragState((prev) => ({
...prev,
targetColumn: prev.targetColumn,
targetIndex: null,
}))
}
}}
onDrop={(e) => {
if (!isDesignMode) return
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
if (e.clientY - rect.top < rect.height * 0.3) {
handleDrop(e, column, index)
} else {
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: isDesignMode ? 'none' : 'pan-y',
transition:
'border-color 0.3s ease-out, opacity 0.3s ease-out, box-shadow 0.3s ease-out',
willChange: isDragging ? 'opacity' : 'auto',
}}
>
{isDesignMode && !isDragging && (
<button
type="button"
className="absolute right-2 top-2 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-white text-gray-500 shadow-sm dark:bg-gray-800 dark:text-gray-300"
title={translate('::App.Platform.Intranet.Dashboard.HideWidget')}
aria-label={`${translate('::App.Platform.Intranet.Dashboard.HideWidget')}: ${translate(metadata.labelKey)}`}
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
setWidgetVisibility(widgetId, false)
}}
>
<LuX className="h-4 w-4" aria-hidden="true" />
</button>
)}
{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>{translate('::App.Platform.Intranet.Dashboard.Moving')}</span>
</div>
</div>
)}
<div
className={`${isDesignMode ? 'p-1.5' : ''} transition-all duration-500 ease-out`}
>
{metadata.render({
dashboard: intranetDashboard,
openAnnouncement: setSelectedAnnouncement,
openEvent: setSelectedEvent,
takeSurvey: handleTakeSurvey,
})}
</div>
</div>
</div>
)
})
.filter(Boolean)
}
return (
<Container>
<div className="mx-auto space-y-4">
<div className="relative flex flex-wrap items-center justify-between gap-4 lg:min-h-10">
<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)}
variant="plain"
shape="none"
className={`
relative !inline-flex !h-6 !w-11 !min-w-11 !items-center !justify-start !rounded-full !border-0 !px-0 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${isDesignMode ? '!bg-blue-600 hover:!bg-blue-600' : '!bg-gray-300 hover:!bg-gray-300 dark:!bg-gray-600 dark:hover:!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>
{isDesignMode && (
<Button
onClick={() => {
resetDashboard()
initializeDefaultOrder()
}}
variant="plain"
shape="none"
className="!h-auto !items-center gap-1 !rounded-none !px-0 !py-0 text-sm text-gray-500 transition-colors hover:!bg-transparent hover:text-gray-700 active:!bg-transparent focus:!bg-transparent dark:text-gray-400 dark:hover:text-gray-200"
title={translate('::App.Platform.Intranet.Dashboard.ResetTitle')}
>
🔄 {translate('::Reset')}
</Button>
)}
</div>
{isDesignMode && (
<div
className="order-last flex w-full items-center justify-center gap-1 lg:absolute lg:left-1/2 lg:top-1/2 lg:order-none lg:w-auto lg:-translate-x-1/2 lg:-translate-y-1/2"
role="group"
aria-label={translate('::App.Platform.Intranet.Dashboard.Layout')}
>
<span className="mr-1 text-sm text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.Dashboard.Layout')}
</span>
{layoutPresets.map((layout) => (
<Button
key={layout.id}
type="button"
variant="plain"
shape="none"
onClick={() => setDashboardLayout(layout.id)}
className={`!h-8 !rounded-md !px-2 ${selectedLayout === layout.id ? '!bg-blue-600 text-white hover:!bg-blue-600' : '!bg-gray-100 text-gray-600 hover:!bg-gray-200 dark:!bg-gray-700 dark:text-gray-200'}`}
title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
aria-pressed={selectedLayout === layout.id}
>
<span className="flex h-4 w-12 gap-0.5" aria-hidden="true">
{layout.columns.map((width, index) => (
<span
key={index}
className="h-full rounded-sm bg-current opacity-70"
style={{ flex: width }}
/>
))}
</span>
</Button>
))}
</div>
)}
<p className="mt-1 text-gray-600 dark:text-gray-400">
<span className="font-medium">{translate('::AI.Welcome')},</span>{' '}
{currentLocalDate(new Date(), currentLocale || 'tr')}
</p>
</div>
{isDesignMode && hiddenWidgets.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="mr-1 text-sm text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.Dashboard.HiddenWidgets')}:
</span>
{hiddenWidgets.map((widget) => (
<Button
key={widget.id}
type="button"
variant="plain"
shape="none"
className="!h-8 !rounded-md !bg-emerald-50 !px-2 text-xs text-emerald-700 hover:!bg-emerald-100 dark:!bg-emerald-950 dark:text-emerald-300"
onClick={() => setWidgetVisibility(widget.id, true)}
title={translate('::App.Platform.Intranet.Dashboard.ShowWidget')}
>
+ {translate(widget.labelKey)}
</Button>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-12">
{dashboardColumns.map((column, index) => {
const isEmptyDropTarget =
isDesignMode && dragState.targetColumn === column && widgetOrder[column].length === 0
return (
<div
key={column}
className={`${columnSpanClasses[activeLayout.columns[index]]} min-h-[100px] space-y-6 rounded-xl p-1 ${
isDesignMode &&
dragState.targetColumn === column &&
dragState.targetIndex === null
? 'bg-blue-50/80 ring-2 ring-blue-300 shadow-lg dark:bg-blue-900/20 dark:ring-blue-600'
: 'bg-transparent'
} transition-all duration-700 ease-out`}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleDragLeaveColumn}
onDrop={(e) => handleColumnDrop(e, column)}
>
{renderWidgets(column)}
{isEmptyDropTarget && (
<div className="flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/50 dark:border-blue-600 dark:bg-blue-900/10">
<span className="font-medium text-blue-600 dark:text-blue-400">
{translate('::App.Platform.Intranet.Dashboard.DropWidgetHere')}
</span>
</div>
)}
</div>
)
})}
</div>
</div>
<AnimatePresence>
{showSurveyModal && selectedSurvey && (
<SurveyModal
survey={selectedSurvey}
onClose={() => setShowSurveyModal(false)}
onSubmit={handleSubmitSurvey}
/>
)}
</AnimatePresence>
<AnimatePresence>
{selectedEvent && (
<EventModal
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
onLikeChange={handleEventLikeChange}
/>
)}
</AnimatePresence>
<AnimatePresence>
{selectedAnnouncement && (
<AnnouncementModal
announcement={selectedAnnouncement}
onClose={() => setSelectedAnnouncement(null)}
onLikeChange={handleAnnouncementLikeChange}
/>
)}
</AnimatePresence>
</Container>
)
}
export default IntranetDashboard