feat: implement custom fields, gantt view, calendar view, and file encryption
- Custom Fields (FEAT-001): - CustomField and TaskCustomValue models with formula support - CRUD API for custom field management - Formula engine for calculated fields - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page - Task list API now includes custom_values - KanbanBoard displays custom field values - Gantt View (FEAT-003): - TaskDependency model with FS/SS/FF/SF dependency types - Dependency CRUD API with cycle detection - start_date field added to tasks - GanttChart component with Frappe Gantt integration - Dependency type selector in UI - Calendar View (FEAT-004): - CalendarView component with FullCalendar integration - Date range filtering API for tasks - Drag-and-drop date updates - View mode switching in Tasks page - File Encryption (FEAT-010): - AES-256-GCM encryption service - EncryptionKey model with key rotation support - Admin API for key management - Encrypted upload/download for confidential projects - Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies) - Updated issues.md with completion status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,12 @@ import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from './CustomFieldInput'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
@@ -18,6 +21,7 @@ interface Task {
|
||||
due_date: string | null
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
custom_values?: CustomValueResponse[]
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
@@ -59,6 +63,44 @@ export function TaskDetailModal({
|
||||
: null
|
||||
)
|
||||
|
||||
// Custom fields state
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([])
|
||||
const [customValues, setCustomValues] = useState<CustomValueResponse[]>([])
|
||||
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
|
||||
const [loadingCustomFields, setLoadingCustomFields] = useState(false)
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
const loadCustomFields = async () => {
|
||||
setLoadingCustomFields(true)
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(task.project_id)
|
||||
setCustomFields(response.fields)
|
||||
} catch (err) {
|
||||
console.error('Failed to load custom fields:', err)
|
||||
} finally {
|
||||
setLoadingCustomFields(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize custom values from task
|
||||
useEffect(() => {
|
||||
setCustomValues(task.custom_values || [])
|
||||
// Build edit values map
|
||||
const valuesMap: Record<string, string | number | null> = {}
|
||||
if (task.custom_values) {
|
||||
task.custom_values.forEach((cv) => {
|
||||
valuesMap[cv.field_id] = cv.value
|
||||
})
|
||||
}
|
||||
setEditCustomValues(valuesMap)
|
||||
}, [task.custom_values])
|
||||
|
||||
// Reset form when task changes
|
||||
useEffect(() => {
|
||||
setEditForm({
|
||||
@@ -108,6 +150,21 @@ export function TaskDetailModal({
|
||||
payload.time_estimate = null
|
||||
}
|
||||
|
||||
// Include custom field values (only non-formula fields)
|
||||
const customValuesPayload = Object.entries(editCustomValues)
|
||||
.filter(([fieldId]) => {
|
||||
const field = customFields.find((f) => f.id === fieldId)
|
||||
return field && field.field_type !== 'formula'
|
||||
})
|
||||
.map(([fieldId, value]) => ({
|
||||
field_id: fieldId,
|
||||
value: value,
|
||||
}))
|
||||
|
||||
if (customValuesPayload.length > 0) {
|
||||
payload.custom_values = customValuesPayload
|
||||
}
|
||||
|
||||
await api.patch(`/tasks/${task.id}`, payload)
|
||||
setIsEditing(false)
|
||||
onUpdate()
|
||||
@@ -118,6 +175,13 @@ export function TaskDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (fieldId: string, value: string | number | null) => {
|
||||
setEditCustomValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
|
||||
setEditForm({ ...editForm, assignee_id: userId || '' })
|
||||
setSelectedAssignee(user)
|
||||
@@ -349,6 +413,50 @@ export function TaskDetailModal({
|
||||
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Fields Section */}
|
||||
{customFields.length > 0 && (
|
||||
<>
|
||||
<div style={styles.customFieldsDivider} />
|
||||
<div style={styles.customFieldsHeader}>Custom Fields</div>
|
||||
{loadingCustomFields ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
) : (
|
||||
customFields.map((field) => {
|
||||
// Get the value for this field
|
||||
const valueResponse = customValues.find(
|
||||
(v) => v.field_id === field.id
|
||||
) || {
|
||||
field_id: field.id,
|
||||
field_name: field.name,
|
||||
field_type: field.field_type,
|
||||
value: editCustomValues[field.id] ?? null,
|
||||
display_value: null,
|
||||
}
|
||||
|
||||
// For editing mode, create a modified value response with edit values
|
||||
const displayValue = isEditing
|
||||
? {
|
||||
...valueResponse,
|
||||
value: editCustomValues[field.id] ?? valueResponse.value,
|
||||
}
|
||||
: valueResponse
|
||||
|
||||
return (
|
||||
<div key={field.id} style={styles.sidebarField}>
|
||||
<CustomFieldInput
|
||||
field={field}
|
||||
value={displayValue}
|
||||
onChange={handleCustomFieldChange}
|
||||
disabled={!isEditing || field.field_type === 'formula'}
|
||||
showLabel={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +679,23 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
customFieldsDivider: {
|
||||
height: '1px',
|
||||
backgroundColor: '#ddd',
|
||||
margin: '20px 0',
|
||||
},
|
||||
customFieldsHeader: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
marginBottom: '16px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: '13px',
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}
|
||||
|
||||
export default TaskDetailModal
|
||||
|
||||
Reference in New Issue
Block a user