Intranet üzerinde Recent Documents komponenti yapıldı
This commit is contained in:
parent
7a0839a3f6
commit
47d9a20998
9 changed files with 844 additions and 26 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
577
ui/src/views/intranet/documents/IntranetDocuments.tsx
Normal file
577
ui/src/views/intranet/documents/IntranetDocuments.tsx
Normal 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
|
||||
67
ui/src/views/intranet/documents/documentUtils.ts
Normal file
67
ui/src/views/intranet/documents/documentUtils.ts
Normal 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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue