Intranet üzerinde Recent Documents komponenti yapıldı

This commit is contained in:
Sedat ÖZTÜRK 2026-09-03 12:17:07 +03:00
parent 7a0839a3f6
commit 47d9a20998
9 changed files with 844 additions and 26 deletions

View file

@ -16,6 +16,7 @@ using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Permissions;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.Authorization;
using Volo.Abp.MultiTenancy;
namespace Sozsoft.Platform.FileManagement;
@ -196,8 +197,28 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
return ext.Trim().TrimStart('.').ToLowerInvariant();
}
private string GetEffectiveTenantId(string? inputTenantId) =>
!string.IsNullOrEmpty(inputTenantId) ? inputTenantId : (_currentTenant.Id?.ToString() ?? "host");
/// <summary>
/// Kapsam secimi yalnizca host baglaminda serbesttir. Tenant baglamindaki bir cagri
/// istekte baska bir tenant id gonderirse reddedilir; aksi halde istemci parametresi
/// tenant izolasyonunu asabilirdi.
/// </summary>
private string GetEffectiveTenantId(string? inputTenantId)
{
var currentTenantId = _currentTenant.Id?.ToString();
if (currentTenantId == null)
{
return !string.IsNullOrEmpty(inputTenantId) ? inputTenantId : HostFolderName;
}
if (!string.IsNullOrEmpty(inputTenantId) &&
!string.Equals(inputTenantId, currentTenantId, StringComparison.OrdinalIgnoreCase))
{
throw new AbpAuthorizationException("Files of another tenant cannot be accessed.");
}
return currentTenantId;
}
private string EncodePathAsId(string path)
{

View file

@ -741,8 +741,12 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
return items;
}
var tenantId = _currentTenant.Id?.ToString() ?? "host";
var fullPath = Path.Combine(cdnBasePath, tenantId);
// CDN kapsam duzeni File Manager ile ayni olmak zorunda: tenant dosyalari
// tenants/{tenantId} altina yazilir, host dosyalari host altina.
var tenantId = _currentTenant.Id?.ToString();
var fullPath = tenantId == null
? Path.Combine(cdnBasePath, "host")
: Path.Combine(cdnBasePath, "tenants", tenantId);
if (!string.IsNullOrEmpty(folderPath))
{

View file

@ -12318,12 +12318,6 @@
"tr": "Bugünün doğum günleri",
"en": "Today's birthdays"
},
{
"resourceName": "Platform",
"key": "App.DashboardWidget.Documents",
"tr": "Son dokümanlar",
"en": "Recent documents"
},
{
"resourceName": "Platform",
"key": "App.DashboardWidget.Surveys",
@ -19326,6 +19320,108 @@
"en": "Object",
"tr": "Nesne"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document",
"en": "Documents",
"tr": "Dokümanlar"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Active",
"en": "Published",
"tr": "Yayında"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Passive",
"en": "Unpublished",
"tr": "Yayında Değil"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Publish",
"en": "Publish",
"tr": "Yayına Al"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Unpublish",
"en": "Unpublish",
"tr": "Yayından Kaldır"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.DisplayName",
"en": "Display Name",
"tr": "Görünecek Ad"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.DisplayNameHint",
"en": "This name is saved as the file name and is shown in the Documents widget.",
"tr": "Bu ad dosya adı olarak kaydedilir ve Son Dokümanlar widget'ında görünür."
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.KanbanView",
"en": "Board view",
"tr": "Pano görünümü"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.ListView",
"en": "List view",
"tr": "Liste görünümü"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.ShowUnpublished",
"en": "Show unpublished",
"tr": "Yayında olmayanları göster"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Unknown",
"en": "Unknown",
"tr": "Bilinmiyor"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Empty",
"en": "There are no documents in this folder.",
"tr": "Bu klasörde doküman yok."
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.DeleteConfirm",
"en": "The selected document(s) will be permanently deleted. Do you confirm?",
"tr": "Seçili doküman(lar) kalıcı olarak silinecek. Onaylıyor musunuz?"
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Uploaded",
"en": "Document(s) uploaded.",
"tr": "Doküman(lar) yüklendi."
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.Deleted",
"en": "Document(s) deleted.",
"tr": "Doküman(lar) silindi."
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.StatusChanged",
"en": "Publication status updated.",
"tr": "Yayın durumu güncellendi."
},
{
"resourceName": "Platform",
"key": "App.Intranet.Document.LoadFailed",
"en": "Documents could not be loaded.",
"tr": "Dokümanlar yüklenemedi."
},
{
"resourceName": "Platform",
"key": "App.Platform.Passive",

View file

@ -376,6 +376,16 @@
"routeType": "protected",
"authority": ["App.DeveloperKit.Components"]
},
{
"key": "admin.intranet.documents",
"path": "/admin/intranet-documents",
"componentType": "normal",
"componentPath": "@/views/intranet/documents/IntranetDocuments",
"routeType": "protected",
"authority": [
"App.Intranet.Document"
]
},
{
"key": "admin.fileManagement",
"path": "/admin/files",
@ -1259,6 +1269,17 @@
"IsDisabled": false,
"ModuleId": "Adm"
},
{
"ParentCode": "App.Administration.Intranet",
"Code": "App.Intranet.Document",
"DisplayName": "App.Intranet.Document",
"Order": 7,
"Url": "/admin/intranet-documents",
"Icon": "FcOpenedFolder",
"RequiredPermissionName": "App.Intranet.Document",
"IsDisabled": false,
"ModuleId": "Adm"
},
{
"ParentCode": "App.Administration",
"Code": "App.Administration.Restrictions",

View file

@ -3396,6 +3396,42 @@
"MultiTenancySide": 3,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Administration",
"Name": "App.Intranet.Document",
"ParentName": "App.Intranet",
"DisplayName": "App.Intranet.Document",
"IsEnabled": true,
"MultiTenancySide": 3,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Administration",
"Name": "App.Intranet.Document.Create",
"ParentName": "App.Intranet.Document",
"DisplayName": "App.Platform.Upload",
"IsEnabled": true,
"MultiTenancySide": 3,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Administration",
"Name": "App.Intranet.Document.Update",
"ParentName": "App.Intranet.Document",
"DisplayName": "App.Platform.Update",
"IsEnabled": true,
"MultiTenancySide": 3,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Administration",
"Name": "App.Intranet.Document.Delete",
"ParentName": "App.Intranet.Document",
"DisplayName": "App.Platform.Delete",
"IsEnabled": true,
"MultiTenancySide": 3,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Administration",
"Name": "App.Intranet.Announcement",

View file

@ -2255,8 +2255,8 @@
},
{
"Code": "documents",
"Name": "Recent Documents",
"LabelKey": "::App.DashboardWidget.Documents",
"Name": "Documents",
"LabelKey": "::App.Intranet.Documents",
"SourceType": "Builtin",
"ComponentName": "",
"PermissionName": "App.Files.Widget",

View file

@ -0,0 +1,577 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import {
FaCloudUploadAlt,
FaEye,
FaEyeSlash,
FaList,
FaSearch,
FaSyncAlt,
FaTh,
FaTrash,
} from 'react-icons/fa'
import Container from '@/components/shared/Container'
import PageTitle from '@/components/shared/PageTitle'
import { Button, Checkbox, Dialog, Input, Notification, Spinner, Tag, toast } from '@/components/ui'
import { getFileIcon } from '@/proxy/intranet/utils'
import fileManagementService from '@/services/fileManagement.service'
import { useStoreState } from '@/store'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import type { FileItem } from '@/types/fileManagement'
import classNames from 'classnames'
import {
ACTIVE_FOLDER_ID,
PASSIVE_FOLDER_ID,
baseNameOf,
formatFileSize,
normalizeExtension,
splitFileName,
toneOf,
type IntranetDocument,
type PendingUpload,
} from './documentUtils'
const TOAST_PLACEMENT = { placement: 'bottom-end' } as const
const PERMISSION = {
UPLOAD: 'App.Intranet.Document.Create',
UPDATE: 'App.Intranet.Document.Update',
DELETE: 'App.Intranet.Document.Delete',
} as const
type ViewMode = 'kanban' | 'list'
interface DocumentColumn {
key: string
label: string
items: IntranetDocument[]
}
const IntranetDocuments = () => {
const { translate } = useLocalization()
const { checkPermission } = usePermission()
const tenantId = useStoreState((state) => state.auth.tenant?.tenantId)
const [documents, setDocuments] = useState<IntranetDocument[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
const [searchTerm, setSearchTerm] = useState('')
const [showUnpublished, setShowUnpublished] = useState(true)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [uploadOpen, setUploadOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const canUpload = checkPermission(PERMISSION.UPLOAD)
const canUpdate = checkPermission(PERMISSION.UPDATE)
const canDelete = checkPermission(PERMISSION.DELETE)
const notify = useCallback((type: 'success' | 'danger' | 'warning', message: string) => {
toast.push(<Notification type={type}>{message}</Notification>, TOAST_PLACEMENT)
}, [])
const load = useCallback(async () => {
setLoading(true)
try {
const [published, unpublished] = await Promise.all([
fileManagementService.getItems(ACTIVE_FOLDER_ID, tenantId),
fileManagementService.getItems(PASSIVE_FOLDER_ID, tenantId),
])
const toDocuments = (items: FileItem[], isActive: boolean): IntranetDocument[] =>
items.filter((item) => item.type === 'file').map((item) => ({ ...item, isActive }))
setDocuments([
...toDocuments(published.data.items || [], true),
...toDocuments(unpublished.data.items || [], false),
])
setSelectedIds([])
} catch (error) {
console.error('Failed to load intranet documents:', error)
notify('danger', translate('::App.Intranet.Document.LoadFailed'))
setDocuments([])
} finally {
setLoading(false)
}
}, [notify, tenantId, translate])
useEffect(() => {
load()
}, [load])
const visibleDocuments = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return documents
.filter((item) => showUnpublished || item.isActive)
.filter((item) => !term || item.name.toLowerCase().includes(term))
.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
}, [documents, searchTerm, showUnpublished])
// Kolonlar klasördeki dosyalardan türer; yeni bir tür yüklendiğinde kolonu kendiliğinden açılır.
const columns = useMemo<DocumentColumn[]>(() => {
const grouped = new Map<string, DocumentColumn>()
for (const item of visibleDocuments) {
const key = normalizeExtension(item.extension)
const column = grouped.get(key)
if (column) {
column.items.push(item)
} else {
grouped.set(key, {
key,
label: key ? key.toUpperCase() : translate('::App.Intranet.Document.Unknown'),
items: [item],
})
}
}
return [...grouped.values()].sort(
(a, b) => b.items.length - a.items.length || a.label.localeCompare(b.label),
)
}, [translate, visibleDocuments])
const selectedDocuments = useMemo(
() => documents.filter((item) => selectedIds.includes(item.id)),
[documents, selectedIds],
)
const publishable = useMemo(
() => selectedDocuments.filter((item) => !item.isActive),
[selectedDocuments],
)
const unpublishable = useMemo(
() => selectedDocuments.filter((item) => item.isActive),
[selectedDocuments],
)
const toggleSelection = useCallback((id: string) => {
setSelectedIds((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
}, [])
const handleFilesPicked = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || [])
// Aynı dosya art arda seçilebilsin diye input her seçimden sonra sıfırlanır.
event.target.value = ''
if (files.length === 0) return
setPendingUploads(
files.map((file) => {
const { base, extension } = splitFileName(file.name)
return { file, name: base, extension }
}),
)
setUploadOpen(true)
}
const closeUpload = () => {
if (busy) return
setUploadOpen(false)
setPendingUploads([])
}
const uploadPendingFiles = async () => {
if (pendingUploads.some((item) => !item.name.trim())) {
notify('warning', translate('::App.Intranet.Document.DisplayNameHint'))
return
}
setBusy(true)
try {
for (const item of pendingUploads) {
const formData = new FormData()
formData.append('fileName', `${item.name.trim()}${item.extension}`)
formData.append('parentId', ACTIVE_FOLDER_ID)
formData.append('Files', item.file)
await fileManagementService.uploadFileDirectly(formData, tenantId)
}
setUploadOpen(false)
setPendingUploads([])
await load()
notify('success', translate('::App.Intranet.Document.Uploaded'))
} catch (error) {
console.error('Intranet document upload failed:', error)
notify('danger', translate('::App.Platform.Error'))
} finally {
setBusy(false)
}
}
const changePublication = async (items: IntranetDocument[], publish: boolean) => {
if (items.length === 0) return
setBusy(true)
try {
await fileManagementService.moveItems(
items.map((item) => item.id),
publish ? ACTIVE_FOLDER_ID : PASSIVE_FOLDER_ID,
tenantId,
)
await load()
notify('success', translate('::App.Intranet.Document.StatusChanged'))
} catch (error) {
console.error('Failed to change publication state:', error)
notify('danger', translate('::App.Platform.Error'))
} finally {
setBusy(false)
}
}
const deleteSelected = async () => {
setBusy(true)
try {
await fileManagementService.bulkDeleteItems(selectedIds, tenantId)
setDeleteOpen(false)
await load()
notify('success', translate('::App.Intranet.Document.Deleted'))
} catch (error) {
console.error('Failed to delete intranet documents:', error)
notify('danger', translate('::App.Platform.Error'))
} finally {
setBusy(false)
}
}
const renderBadge = (document: IntranetDocument) => (
<span
className={classNames(
'inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
toneOf(document.extension),
)}
title={document.extension}
>
{getFileIcon(document.extension || '')}
</span>
)
const renderStatus = (isActive: boolean) => (
<Tag
className={
isActive
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
>
{translate(isActive ? '::App.Intranet.Document.Active' : '::App.Intranet.Document.Passive')}
</Tag>
)
const renderPublicationButton = (document: IntranetDocument) =>
canUpdate ? (
<Button
className="text-gray-600 hover:text-blue-600"
disabled={busy}
icon={document.isActive ? <FaEyeSlash /> : <FaEye />}
size="xs"
title={translate(
document.isActive
? '::App.Intranet.Document.Unpublish'
: '::App.Intranet.Document.Publish',
)}
variant="plain"
onClick={() => changePublication([document], !document.isActive)}
/>
) : null
const renderCard = (document: IntranetDocument) => (
<div
key={document.id}
className={classNames(
'rounded-lg border p-3 transition-colors',
selectedIds.includes(document.id)
? 'border-blue-400 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20'
: 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={selectedIds.includes(document.id)}
onChange={() => toggleSelection(document.id)}
/>
{renderBadge(document)}
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium text-gray-900 dark:text-white"
title={document.name}
>
{baseNameOf(document.name)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(document.size)}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
{renderStatus(document.isActive)}
{renderPublicationButton(document)}
</div>
</div>
</div>
</div>
)
const renderKanban = () =>
columns.length === 0 ? (
<div className="py-20 text-center text-sm text-gray-500 dark:text-gray-400">
{translate('::App.Intranet.Document.Empty')}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{columns.map((column) => (
<div key={column.key} className="rounded-lg bg-gray-50 p-2 dark:bg-gray-900/40">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{column.label}
</span>
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{column.items.length}
</span>
</div>
<div className="space-y-2">{column.items.map(renderCard)}</div>
</div>
))}
</div>
)
const renderList = () => (
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500 dark:bg-gray-900/40 dark:text-gray-400">
<tr>
<th className="w-10 px-3 py-2" />
<th className="px-3 py-2">{translate('::App.Listform.ListformField.Name')}</th>
<th className="px-3 py-2">{translate('::App.Listform.ListformField.Type')}</th>
<th className="px-3 py-2">{translate('::App.Listform.ListformField.Size')}</th>
<th className="px-3 py-2">{translate('::App.Intranet.Document.Active')}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{visibleDocuments.map((document) => (
<tr key={document.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td className="px-3 py-2">
<Checkbox
checked={selectedIds.includes(document.id)}
onChange={() => toggleSelection(document.id)}
/>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{renderBadge(document)}
<span className="font-medium text-gray-900 dark:text-white">
{baseNameOf(document.name)}
</span>
</div>
</td>
<td className="px-3 py-2 uppercase text-gray-500 dark:text-gray-400">
{normalizeExtension(document.extension)}
</td>
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">
{formatFileSize(document.size)}
</td>
<td className="px-3 py-2">{renderStatus(document.isActive)}</td>
<td className="px-3 py-2 text-right">{renderPublicationButton(document)}</td>
</tr>
))}
{visibleDocuments.length === 0 && (
<tr>
<td className="px-3 py-8 text-center text-gray-400" colSpan={6}>
{translate('::App.Intranet.Document.Empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
return (
<Container className="px-1">
<PageTitle title={translate('::App.Intranet.Document')} />
<div className="mb-4 mt-2 flex flex-col gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{canUpload && (
<Button
disabled={busy}
icon={<FaCloudUploadAlt />}
size="sm"
variant="solid"
onClick={() => fileInputRef.current?.click()}
>
{translate('::App.Platform.Upload')}
</Button>
)}
{canUpdate && (
<>
<Button
disabled={busy || publishable.length === 0}
icon={<FaEye />}
size="sm"
onClick={() => changePublication(publishable, true)}
>
{translate('::App.Intranet.Document.Publish')}
</Button>
<Button
disabled={busy || unpublishable.length === 0}
icon={<FaEyeSlash />}
size="sm"
onClick={() => changePublication(unpublishable, false)}
>
{translate('::App.Intranet.Document.Unpublish')}
</Button>
</>
)}
{canDelete && (
<Button
className="text-gray-600 hover:text-red-600"
disabled={busy || selectedIds.length === 0}
icon={<FaTrash />}
size="sm"
variant="plain"
onClick={() => setDeleteOpen(true)}
>
{translate('::App.Platform.Delete')}
{selectedIds.length > 0 && ` (${selectedIds.length})`}
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<Checkbox checked={showUnpublished} onChange={setShowUnpublished}>
{translate('::App.Intranet.Document.ShowUnpublished')}
</Checkbox>
<Input
className="w-full sm:w-56"
placeholder={translate('::App.Platform.Search')}
prefix={<FaSearch className="text-gray-400" />}
size="sm"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<div className="flex gap-1">
<Button
icon={<FaTh />}
size="sm"
title={translate('::App.Intranet.Document.KanbanView')}
variant={viewMode === 'kanban' ? 'solid' : 'default'}
onClick={() => setViewMode('kanban')}
/>
<Button
icon={<FaList />}
size="sm"
title={translate('::App.Intranet.Document.ListView')}
variant={viewMode === 'list' ? 'solid' : 'default'}
onClick={() => setViewMode('list')}
/>
</div>
<Button
disabled={busy}
icon={<FaSyncAlt />}
size="sm"
title={translate('::App.Platform.Refresh')}
variant="plain"
onClick={load}
/>
</div>
</div>
<input
ref={fileInputRef}
multiple
className="hidden"
type="file"
onChange={handleFilesPicked}
/>
{loading ? (
<div className="flex justify-center py-20">
<Spinner size="lg" />
</div>
) : viewMode === 'kanban' ? (
renderKanban()
) : (
renderList()
)}
<Dialog isOpen={uploadOpen} onClose={closeUpload} onRequestClose={closeUpload}>
<h5 className="mb-1">{translate('::App.Platform.Upload')}</h5>
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
{translate('::App.Intranet.Document.DisplayNameHint')}
</p>
<div className="max-h-80 space-y-3 overflow-y-auto pr-1">
{pendingUploads.map((item, index) => (
<div
key={`${item.file.name}-${index}`}
className="rounded-lg border border-gray-200 p-3 dark:border-gray-700"
>
<div
className="mb-1 truncate text-xs text-gray-500 dark:text-gray-400"
title={item.file.name}
>
{item.file.name} · {formatFileSize(item.file.size)}
</div>
<Input
placeholder={translate('::App.Intranet.Document.DisplayName')}
size="sm"
suffix={<span className="text-xs text-gray-400">{item.extension}</span>}
value={item.name}
onChange={(event) => {
const { value } = event.target
setPendingUploads((previous) =>
previous.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, name: value } : entry,
),
)
}}
/>
</div>
))}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button disabled={busy} size="sm" onClick={closeUpload}>
{translate('::App.Platform.Cancel')}
</Button>
<Button
disabled={pendingUploads.length === 0}
loading={busy}
size="sm"
variant="solid"
onClick={uploadPendingFiles}
>
{translate('::App.Platform.Upload')}
</Button>
</div>
</Dialog>
<Dialog
isOpen={deleteOpen}
onClose={() => !busy && setDeleteOpen(false)}
onRequestClose={() => !busy && setDeleteOpen(false)}
>
<h5 className="mb-4">{translate('::App.Platform.Delete')}</h5>
<p className="mb-2 text-sm">{translate('::App.Intranet.Document.DeleteConfirm')}</p>
<ul className="mb-4 max-h-48 list-disc overflow-y-auto pl-5 text-sm text-gray-600 dark:text-gray-300">
{selectedDocuments.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<div className="flex justify-end gap-2">
<Button disabled={busy} size="sm" onClick={() => setDeleteOpen(false)}>
{translate('::App.Platform.Cancel')}
</Button>
<Button color="red-600" loading={busy} size="sm" variant="solid" onClick={deleteSelected}>
{translate('::App.Platform.Delete')}
</Button>
</div>
</Dialog>
</Container>
)
}
export default IntranetDocuments

View file

@ -0,0 +1,67 @@
import type { FileItem } from '@/types/fileManagement'
/** Intranet dokümanlarının yayında olduğu CDN klasörü; widget yalnızca bu klasörü okur. */
export const ACTIVE_FOLDER_ID = 'intranet'
/** Yayından kaldırılan dosyalar alt klasöre taşınır, böylece widget'ta görünmez. */
export const PASSIVE_FOLDER_ID = 'intranet|_passive'
export interface IntranetDocument extends FileItem {
isActive: boolean
}
export interface PendingUpload {
file: File
/** Kullanıcının girdiği okunabilir ad; uzantı hariç tutulur. */
name: string
/** Nokta ile birlikte uzantı (".pdf"); ad değişse de korunur. */
extension: string
}
/**
* Pano kolonlarının rengi uzantıdan türetilir: sabit bir uzantı listesi tutulmaz,
* klasöre hangi tür yüklenirse kolonu o türe göre kendiliğinden oluşur.
*/
const TONES = [
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200',
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200',
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200',
'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200',
'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-200',
]
/** Uzantıyı noktasız ve küçük harfe indirger; uzantısız dosyalar boş anahtar alır. */
export const normalizeExtension = (extension?: string): string =>
(extension || '').replace('.', '').toLowerCase()
export const toneOf = (extension?: string): string => {
const key = normalizeExtension(extension)
if (!key) return TONES[TONES.length - 1]
let hash = 0
for (let index = 0; index < key.length; index += 1) {
hash = (hash * 31 + key.charCodeAt(index)) % TONES.length
}
return TONES[hash]
}
export const formatFileSize = (bytes?: number): string => {
const value = bytes ?? 0
if (value === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
return `${parseFloat((value / Math.pow(1024, index)).toFixed(2))} ${units[index]}`
}
/** Uzantı ayrı taşınır; kullanıcı yalnızca okunabilir kısmı düzenler. */
export const splitFileName = (fileName: string): { base: string; extension: string } => {
const dotIndex = fileName.lastIndexOf('.')
return dotIndex > 0
? { base: fileName.slice(0, dotIndex), extension: fileName.slice(dotIndex) }
: { base: fileName, extension: '' }
}
export const baseNameOf = (fileName: string): string => splitFileName(fileName).base

View file

@ -7,16 +7,9 @@ import { getFileIcon, getFileType } from '@/proxy/intranet/utils'
import { FILE_URL } from '@/constants/app.constant'
import Button from '@/components/ui/Button'
import type { DashboardWidgetDefinition } from '../dashboardWidget'
import { baseNameOf, formatFileSize } from '../documents/documentUtils'
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
const RecentDocuments: React.FC<{ documents: DocumentDto[] }> = ({ documents }) => {
const Documents: React.FC<{ documents: DocumentDto[] }> = ({ documents }) => {
const { translate } = useLocalization()
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
@ -24,7 +17,7 @@ const RecentDocuments: React.FC<{ documents: DocumentDto[] }> = ({ documents })
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<FaFileAlt className="w-5 h-5" />
{translate('::App.DashboardWidget.Documents')}
{translate('::App.Intranet.Document')}
</h2>
</div>
</div>
@ -40,8 +33,11 @@ const RecentDocuments: React.FC<{ documents: DocumentDto[] }> = ({ documents })
{getFileIcon(doc.extension)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
{doc.name}
<h4
className="text-sm font-medium text-gray-900 dark:text-white truncate"
title={doc.name}
>
{baseNameOf(doc.name)}
</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{getFileType(doc.extension)}
@ -97,7 +93,7 @@ const RecentDocuments: React.FC<{ documents: DocumentDto[] }> = ({ documents })
export const dashboardWidget: DashboardWidgetDefinition = {
id: 'documents',
render: ({ dashboard }) => <RecentDocuments documents={dashboard?.documents || []} />,
render: ({ dashboard }) => <Documents documents={dashboard?.documents || []} />,
}
export default RecentDocuments
export default Documents