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

649 lines
24 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'
import SurveyModal from './widgets/SurveyModal'
import AnnouncementModal from './widgets/AnnouncementModal'
2026-05-07 14:25:06 +00:00
import EventModal from './widgets/EventModal'
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 { useLocalization } from '@/utils/hooks/useLocalization'
import useLocale from '@/utils/hooks/useLocale'
import { currentLocalDate } from '@/utils/dateUtils'
import { useStoreActions, useStoreState } from '@/store/store'
import type { DashboardLayout } from '@/store/admin.model'
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
type DashboardLayoutId = DashboardLayout
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)
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 { 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 () => {
const dashboard = await intranetService.getDashboard()
if (dashboard.data) {
setIntranetDashboard(dashboard.data)
}
}
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)
}
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)
}
2026-06-26 19:02:53 +00:00
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) {
try {
const parsed = widgetOrder
const order: Record<DashboardWidgetColumn, string[]> = {
left: [...new Set((parsed.left || []) as string[])],
center: [...new Set((parsed.center || []) as string[])],
right: [...new Set((parsed.right || []) as string[])],
}
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
dashboardWidgets.forEach((w) => {
if (!allAssigned.has(w.id) && checkPermission(w.permission)) {
order[w.column as keyof typeof order].push(w.id)
}
})
setWidgetOrder(order)
} catch {
initializeDefaultOrder()
}
} else {
initializeDefaultOrder()
}
}, [grantedPolicies])
const saveWidgetOrder = (newOrder: Record<DashboardWidgetColumn, string[]>) => {
setWidgetOrder(newOrder)
}
const setWidgetVisibility = (widgetId: string, visible: boolean) => {
const next = visible
? hiddenWidgetIds.filter((id) => id !== widgetId)
: [...new Set([...hiddenWidgetIds, widgetId])]
setHiddenWidgetIds(next)
}
const selectLayout = (layoutId: DashboardLayoutId) => {
setDashboardLayout(layoutId)
}
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)
}
saveWidgetOrder(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={{
2026-06-05 05:44:01 +00:00
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')}
>
2026-08-06 13:17:59 +00:00
🔄 {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={() => selectLayout(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>
2026-05-07 14:25:06 +00:00
<AnimatePresence>
{selectedEvent && (
2026-06-26 19:02:53 +00:00
<EventModal
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
onLikeChange={handleEventLikeChange}
/>
2026-05-07 14:25:06 +00:00
)}
</AnimatePresence>
<AnimatePresence>
{selectedAnnouncement && (
<AnnouncementModal
announcement={selectedAnnouncement}
onClose={() => setSelectedAnnouncement(null)}
2026-06-26 19:02:53 +00:00
onLikeChange={handleAnnouncementLikeChange}
/>
)}
</AnimatePresence>
</Container>
)
}
export default IntranetDashboard