Add contract document management functionality in UserDetailModal and API

This commit is contained in:
seaznCode 2026-02-04 15:28:33 +01:00
parent f7fab195fd
commit ebcded33f1
2 changed files with 181 additions and 12 deletions

View File

@ -30,6 +30,13 @@ interface UserDetailModalProps {
type UserStatus = 'inactive' | 'pending' | 'active' | 'suspended' | 'archived' type UserStatus = 'inactive' | 'pending' | 'active' | 'suspended' | 'archived'
type ContractFileItem = {
key: string
filename: string
documentId?: number | null
contract_type?: 'contract' | 'gdpr' | string | null
}
const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [ const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
{ value: 'pending', label: 'Pending', color: 'amber' }, { value: 'pending', label: 'Pending', color: 'amber' },
{ value: 'active', label: 'Active', color: 'green' }, { value: 'active', label: 'Active', color: 'green' },
@ -53,6 +60,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
gdpr: { loading: false, html: null as string | null, error: null as string | null, warning: null as string | null }, gdpr: { loading: false, html: null as string | null, error: null as string | null, warning: null as string | null },
}) })
const [contractFiles, setContractFiles] = useState<{ contract: ContractFileItem[]; gdpr: ContractFileItem[] }>({
contract: [],
gdpr: []
})
const [docsLoading, setDocsLoading] = useState(false)
const [moveLoading, setMoveLoading] = useState<Record<string, boolean>>({})
const [selectedFile, setSelectedFile] = useState<{ contract?: string; gdpr?: string }>({})
const missingIdOrContract = !!userDetails?.userStatus && ( const missingIdOrContract = !!userDetails?.userStatus && (
userDetails.userStatus.documents_uploaded !== 1 || userDetails.userStatus.documents_uploaded !== 1 ||
userDetails.userStatus.contract_signed !== 1 userDetails.userStatus.contract_signed !== 1
@ -71,8 +86,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
useEffect(() => { useEffect(() => {
if (isOpen && userId && token) { if (isOpen && userId && token) {
fetchUserDetails() fetchUserDetails()
loadContractFiles()
} }
}, [isOpen, userId, token]) }, [isOpen, userId, token])
useEffect(() => {
if (!isOpen || !userId || !token || !userDetails) return
loadContractFiles()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userDetails])
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@ -81,6 +102,8 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
contract: { loading: false, html: null, error: null, warning: null }, contract: { loading: false, html: null, error: null, warning: null },
gdpr: { loading: false, html: null, error: null, warning: null } gdpr: { loading: false, html: null, error: null, warning: null }
}) })
setContractFiles({ contract: [], gdpr: [] })
setSelectedFile({})
}, [isOpen, userId]) }, [isOpen, userId])
useEffect(() => { useEffect(() => {
@ -164,14 +187,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
} }
} }
const loadContractPreview = async (contractType: 'contract' | 'gdpr') => { const loadContractPreview = async (contractType: 'contract' | 'gdpr', documentId?: number, objectKey?: string) => {
if (!userId || !token || !userDetails) return if (!userId || !token || !userDetails) return
setPreviewState((prev) => ({ setPreviewState((prev) => ({
...prev, ...prev,
[contractType]: { ...prev[contractType], loading: true, error: null, warning: null } [contractType]: { ...prev[contractType], loading: true, error: null, warning: null }
})) }))
try { try {
const result = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type, contractType) const result = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type, contractType, documentId, objectKey)
setPreviewState((prev) => ({ setPreviewState((prev) => ({
...prev, ...prev,
[contractType]: { loading: false, html: result.html, error: null, warning: result.warning || null } [contractType]: { loading: false, html: result.html, error: null, warning: result.warning || null }
@ -185,6 +208,44 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
} }
} }
const loadContractFiles = async () => {
if (!userId || !token) return
setDocsLoading(true)
try {
const result = await AdminAPI.listContractFiles(token, String(userId), userDetails?.user?.user_type)
const contract = Array.isArray(result?.contract) ? result.contract : []
const gdpr = Array.isArray(result?.gdpr) ? result.gdpr : []
setContractFiles({ contract, gdpr })
setSelectedFile((prev) => ({
contract: prev.contract || contract[0]?.key || undefined,
gdpr: prev.gdpr || gdpr[0]?.key || undefined
}))
} catch (e) {
console.error('UserDetailModal.loadContractFiles error:', e)
setContractFiles({ contract: [], gdpr: [] })
} finally {
setDocsLoading(false)
}
}
const moveContractDoc = async (documentId: number | undefined, targetType: 'contract' | 'gdpr', filename?: string | null, objectKey?: string) => {
if (!userId || !token) return
const label = targetType === 'gdpr' ? 'GDPR' : 'Contract'
const name = filename ? `\n\nFile: ${filename}` : ''
const ok = window.confirm(`Move this document to ${label}?${name}`)
if (!ok) return
const loadingKey = objectKey || String(documentId || '')
setMoveLoading((prev) => ({ ...prev, [loadingKey]: true }))
try {
await AdminAPI.moveContractDocument(token, String(userId), documentId, targetType, objectKey)
await loadContractFiles()
} catch (e) {
console.error('UserDetailModal.moveContractDoc error:', e)
} finally {
setMoveLoading((prev) => ({ ...prev, [loadingKey]: false }))
}
}
const formatDate = (dateString: string | undefined | null) => { const formatDate = (dateString: string | undefined | null) => {
if (!dateString) return 'N/A' if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
@ -194,14 +255,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
}) })
} }
const formatFileSize = (bytes: number | undefined) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
const getStatusColor = (status: UserStatus) => { const getStatusColor = (status: UserStatus) => {
const option = STATUS_OPTIONS.find(opt => opt.value === status) const option = STATUS_OPTIONS.find(opt => opt.value === status)
return option?.color || 'gray' return option?.color || 'gray'
@ -469,7 +522,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => loadContractPreview(activePreviewTab)} onClick={() => {
const files = contractFiles[activePreviewTab] || []
const selectedKey = selectedFile[activePreviewTab] || files[0]?.key
const item = files.find((f) => f.key === selectedKey) || files[0]
loadContractPreview(activePreviewTab, item?.documentId || undefined, item?.key)
}}
disabled={previewState[activePreviewTab].loading} disabled={previewState[activePreviewTab].loading}
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60" className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
> >
@ -492,6 +550,74 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</div> </div>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-5">
{(() => {
const files = contractFiles[activePreviewTab] || []
const selectedKey = selectedFile[activePreviewTab] || files[0]?.key
const selectedItem = files.find((f) => f.key === selectedKey) || files[0]
const moveTarget = activePreviewTab === 'contract' ? 'gdpr' : 'contract'
const isMoving = selectedItem?.key ? !!moveLoading[selectedItem.key] : false
return (
<div className="mb-4">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-gray-900">Files in {activePreviewTab.toUpperCase()}</div>
<button
type="button"
onClick={() => loadContractFiles()}
disabled={docsLoading}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
>
{docsLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{docsLoading && (
<div className="mt-2 text-xs text-gray-500">Loading files</div>
)}
{!docsLoading && files.length === 0 && (
<div className="mt-2 text-xs text-gray-500">No files found in this folder.</div>
)}
{!docsLoading && files.length > 0 && (
<>
{files.length > 1 && (
<div className="mt-2 flex flex-wrap gap-2">
{files.map((f) => (
<button
key={f.key}
type="button"
onClick={() => {
setSelectedFile((prev) => ({ ...prev, [activePreviewTab]: f.key }))
loadContractPreview(activePreviewTab, f.documentId || undefined, f.key)
}}
className={`px-2.5 py-1 text-xs rounded-md border transition ${selectedKey === f.key ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'}`}
>
{f.filename}
</button>
))}
</div>
)}
{selectedItem && (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-xs text-gray-600">
<div className="truncate">Selected: {selectedItem.filename}</div>
{files.length >= 1 && (
<button
type="button"
onClick={() => moveContractDoc(selectedItem.documentId || undefined, moveTarget as 'contract' | 'gdpr', selectedItem.filename, selectedItem.key)}
disabled={isMoving}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
>
{isMoving ? 'Moving…' : `Move to ${moveTarget.toUpperCase()}`}
</button>
)}
</div>
)}
</>
)}
</div>
)
})()}
{previewState[activePreviewTab].warning && ( {previewState[activePreviewTab].warning && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4"> <div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">
{previewState[activePreviewTab].warning} {previewState[activePreviewTab].warning}

View File

@ -56,6 +56,9 @@ export const API_ENDPOINTS = {
ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id', ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id',
// Contract preview (admin) matches backend route // Contract preview (admin) matches backend route
ADMIN_CONTRACT_PREVIEW: '/api/admin/contracts/:id/preview', ADMIN_CONTRACT_PREVIEW: '/api/admin/contracts/:id/preview',
ADMIN_CONTRACT_DOCS: '/api/admin/contracts/:id/documents',
ADMIN_CONTRACT_MOVE: '/api/admin/contracts/:id/move',
ADMIN_CONTRACT_FILES: '/api/admin/contracts/:id/files',
} }
// API Helper Functions // API Helper Functions
@ -480,11 +483,13 @@ export class AdminAPI {
return true return true
} }
static async getContractPreviewHtml(token: string, userId: string, userType?: 'personal' | 'company', contractType?: 'contract' | 'gdpr') { static async getContractPreviewHtml(token: string, userId: string, userType?: 'personal' | 'company', contractType?: 'contract' | 'gdpr', documentId?: number, objectKey?: string) {
let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_PREVIEW.replace(':id', userId) let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_PREVIEW.replace(':id', userId)
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (userType) qs.set('userType', userType) if (userType) qs.set('userType', userType)
if (contractType) qs.set('contract_type', contractType) if (contractType) qs.set('contract_type', contractType)
if (documentId) qs.set('documentId', String(documentId))
if (objectKey) qs.set('objectKey', objectKey)
const qsStr = qs.toString() const qsStr = qs.toString()
if (qsStr) endpoint += `?${qsStr}` if (qsStr) endpoint += `?${qsStr}`
const response = await ApiClient.get(endpoint, token) const response = await ApiClient.get(endpoint, token)
@ -500,6 +505,44 @@ export class AdminAPI {
const html = await response.text() const html = await response.text()
return { html, warning: warningHeader } return { html, warning: warningHeader }
} }
static async listContractDocuments(token: string, userId: string, userType?: 'personal' | 'company') {
let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_DOCS.replace(':id', userId)
const qs = new URLSearchParams()
if (userType) qs.set('userType', userType)
const qsStr = qs.toString()
if (qsStr) endpoint += `?${qsStr}`
const response = await ApiClient.get(endpoint, token)
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Failed to load contract documents' }))
throw new Error(error.message || 'Failed to load contract documents')
}
return response.json()
}
static async listContractFiles(token: string, userId: string, userType?: 'personal' | 'company') {
let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_FILES.replace(':id', userId)
const qs = new URLSearchParams()
if (userType) qs.set('userType', userType)
const qsStr = qs.toString()
if (qsStr) endpoint += `?${qsStr}`
const response = await ApiClient.get(endpoint, token)
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Failed to load contract files' }))
throw new Error(error.message || 'Failed to load contract files')
}
return response.json()
}
static async moveContractDocument(token: string, userId: string, documentId: number | undefined, targetType: 'contract' | 'gdpr', objectKey?: string) {
const endpoint = API_ENDPOINTS.ADMIN_CONTRACT_MOVE.replace(':id', userId)
const response = await ApiClient.post(endpoint, { documentId, targetType, objectKey }, token)
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Failed to move contract document' }))
throw new Error(error.message || 'Failed to move contract document')
}
return response.json()
}
} }
// Response Types // Response Types