Add contract document management functionality in UserDetailModal and API
This commit is contained in:
parent
f7fab195fd
commit
ebcded33f1
@ -30,6 +30,13 @@ interface UserDetailModalProps {
|
||||
|
||||
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 }[] = [
|
||||
{ value: 'pending', label: 'Pending', color: 'amber' },
|
||||
{ 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 },
|
||||
})
|
||||
|
||||
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 && (
|
||||
userDetails.userStatus.documents_uploaded !== 1 ||
|
||||
userDetails.userStatus.contract_signed !== 1
|
||||
@ -71,8 +86,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
useEffect(() => {
|
||||
if (isOpen && userId && token) {
|
||||
fetchUserDetails()
|
||||
loadContractFiles()
|
||||
}
|
||||
}, [isOpen, userId, token])
|
||||
useEffect(() => {
|
||||
if (!isOpen || !userId || !token || !userDetails) return
|
||||
loadContractFiles()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userDetails])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@ -81,6 +102,8 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
contract: { loading: false, html: null, error: null, warning: null },
|
||||
gdpr: { loading: false, html: null, error: null, warning: null }
|
||||
})
|
||||
setContractFiles({ contract: [], gdpr: [] })
|
||||
setSelectedFile({})
|
||||
}, [isOpen, userId])
|
||||
|
||||
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
|
||||
setPreviewState((prev) => ({
|
||||
...prev,
|
||||
[contractType]: { ...prev[contractType], loading: true, error: null, warning: null }
|
||||
}))
|
||||
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) => ({
|
||||
...prev,
|
||||
[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) => {
|
||||
if (!dateString) return 'N/A'
|
||||
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 option = STATUS_OPTIONS.find(opt => opt.value === status)
|
||||
return option?.color || 'gray'
|
||||
@ -469,7 +522,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
||||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
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 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 && (
|
||||
<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}
|
||||
|
||||
@ -56,6 +56,9 @@ export const API_ENDPOINTS = {
|
||||
ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id',
|
||||
// Contract preview (admin) – matches backend route
|
||||
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
|
||||
@ -480,11 +483,13 @@ export class AdminAPI {
|
||||
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)
|
||||
const qs = new URLSearchParams()
|
||||
if (userType) qs.set('userType', userType)
|
||||
if (contractType) qs.set('contract_type', contractType)
|
||||
if (documentId) qs.set('documentId', String(documentId))
|
||||
if (objectKey) qs.set('objectKey', objectKey)
|
||||
const qsStr = qs.toString()
|
||||
if (qsStr) endpoint += `?${qsStr}`
|
||||
const response = await ApiClient.get(endpoint, token)
|
||||
@ -500,6 +505,44 @@ export class AdminAPI {
|
||||
const html = await response.text()
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user