-
Propose Code: {propose.propose_code || '—'}
+
Propose Code: {propose.propose_code ? : '—'}
Status: {propose.status}
-
Created By: User #{propose.created_by_id || '—'}
+
Created By: {propose.created_by_username || (propose.created_by_id ? `User #${propose.created_by_id}` : '—')}
Created: {dayjs(propose.created_at).format('YYYY-MM-DD HH:mm')}
Updated: {propose.updated_at ? dayjs(propose.updated_at).format('YYYY-MM-DD HH:mm') : '—'}
Feature Task: {propose.feat_task_id || '—'}
diff --git a/src/pages/ProposesPage.tsx b/src/pages/ProposesPage.tsx
index c9f16ea..1e84e5d 100644
--- a/src/pages/ProposesPage.tsx
+++ b/src/pages/ProposesPage.tsx
@@ -72,7 +72,7 @@ export default function ProposesPage() {
{proposes.map((pr) => (
-
navigate(`/proposes/${pr.id}?project_id=${pr.project_id}`)}>
+
navigate(`/proposes/${pr.propose_code || pr.id}?project_id=${pr.project_id}`)}>
{pr.status}
{pr.propose_code &&
{pr.propose_code}}
diff --git a/src/pages/SupportDetailPage.tsx b/src/pages/SupportDetailPage.tsx
new file mode 100644
index 0000000..f36def5
--- /dev/null
+++ b/src/pages/SupportDetailPage.tsx
@@ -0,0 +1,299 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '@/services/api'
+import { useAuth } from '@/hooks/useAuth'
+import CopyableCode from '@/components/CopyableCode'
+
+interface SupportItem {
+ id: number
+ code: string | null
+ support_code: string | null
+ title: string
+ description: string | null
+ status: string
+ priority: string
+ project_id: number
+ project_code: string | null
+ milestone_id: number
+ milestone_code: string | null
+ reporter_id: number
+ assignee_id: number | null
+ taken_by: string | null
+ created_at: string
+ updated_at: string | null
+}
+
+const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
+const PRIORITY_OPTIONS = ['low', 'medium', 'high', 'critical']
+
+export default function SupportDetailPage() {
+ const { supportId } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const [support, setSupport] = useState
(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [message, setMessage] = useState('')
+ const [editMode, setEditMode] = useState(false)
+ const [editForm, setEditForm] = useState({ title: '', description: '', priority: '' })
+ const [transitionStatus, setTransitionStatus] = useState('')
+
+ const fetchSupport = async () => {
+ try {
+ const { data } = await api.get(`/supports/${supportId}`)
+ setSupport(data)
+ setEditForm({
+ title: data.title,
+ description: data.description || '',
+ priority: data.priority,
+ })
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to load support ticket')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchSupport()
+ }, [supportId])
+
+ const handleTake = async () => {
+ if (!support) return
+ setSaving(true)
+ setMessage('')
+ try {
+ const { data } = await api.post(`/supports/${supportId}/take`)
+ setSupport(data)
+ setMessage('You have taken this support ticket')
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to take support ticket')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleTransition = async () => {
+ if (!support || !transitionStatus) return
+ setSaving(true)
+ setMessage('')
+ try {
+ const { data } = await api.post(`/supports/${supportId}/transition`, {
+ status: transitionStatus,
+ })
+ setSupport(data)
+ setTransitionStatus('')
+ setMessage(`Status changed to ${transitionStatus}`)
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to transition support ticket')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleSave = async () => {
+ if (!support) return
+ setSaving(true)
+ setMessage('')
+ try {
+ const payload: Record = {}
+ if (editForm.title.trim() !== support.title) payload.title = editForm.title.trim()
+ if ((editForm.description || '') !== (support.description || '')) payload.description = editForm.description || null
+ if (editForm.priority !== support.priority) payload.priority = editForm.priority
+
+ if (Object.keys(payload).length === 0) {
+ setEditMode(false)
+ return
+ }
+
+ const { data } = await api.patch(`/supports/${supportId}`, payload)
+ setSupport(data)
+ setEditMode(false)
+ setMessage('Support ticket updated')
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to update support ticket')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!support) return
+ if (!confirm(`Delete support ticket ${support.support_code || support.id}? This cannot be undone.`)) return
+ setSaving(true)
+ try {
+ await api.delete(`/supports/${supportId}`)
+ navigate(-1)
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to delete support ticket')
+ setSaving(false)
+ }
+ }
+
+ if (loading) return Loading support ticket...
+ if (!support) return {message || 'Support ticket not found'}
+
+ const canTake = user && (!support.assignee_id || support.assignee_id === user.id) && support.status !== 'closed' && support.status !== 'resolved'
+ const isMine = user && support.assignee_id === user.id
+ const availableTransitions = STATUS_OPTIONS.filter((s) => s !== support.status)
+
+ return (
+
+
+
+
+
🎫 {support.support_code ? : `#${support.id}`}
+
+ {support.status}
+ {support.priority}
+ {support.project_code && Project: {support.project_code}}
+ {support.milestone_code && Milestone: {support.milestone_code}}
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+ {/* Main content */}
+
+ {editMode ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+ <>
+
{support.title}
+ {support.description && (
+
{support.description}
+ )}
+ {!support.description &&
No description provided.
}
+ >
+ )}
+
+
+ {/* Sidebar */}
+
+ {/* Ownership */}
+
+
Assigned To
+ {support.taken_by ? (
+
+ {support.taken_by}
+ {isMine && (you)}
+
+ ) : (
+
Unassigned
+ )}
+ {canTake && !isMine && (
+
+ )}
+
+
+ {/* Transition */}
+
+
Transition
+
+
+
+
+ {/* Actions */}
+
+
Actions
+
+ {!editMode && (
+
+ )}
+
+
+
+
+ {/* Info */}
+
+
Info
+
+
Created: {new Date(support.created_at).toLocaleString()}
+ {support.updated_at &&
Updated: {new Date(support.updated_at).toLocaleString()}
}
+
+
+
+
+
+ )
+}
diff --git a/src/pages/TaskDetailPage.tsx b/src/pages/TaskDetailPage.tsx
index 3469d6a..63a5fbb 100644
--- a/src/pages/TaskDetailPage.tsx
+++ b/src/pages/TaskDetailPage.tsx
@@ -5,6 +5,7 @@ import type { Task, Comment, Project, ProjectMember, Milestone } from '@/types'
import dayjs from 'dayjs'
import { useAuth } from '@/hooks/useAuth'
import CreateTaskModal from '@/components/CreateTaskModal'
+import CopyableCode from '@/components/CopyableCode'
export default function TaskDetailPage() {
const { id } = useParams()
@@ -120,7 +121,7 @@ export default function TaskDetailPage() {
-
#{task.id} {task.title}
+
{task.task_code ? <> > : `#${task.id} `}{task.title}
{task.status}
{task.priority}
@@ -261,7 +262,7 @@ export default function TaskDetailPage() {
Comments ({comments.length})
{comments.map((c) => (
-
User #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}
+
{c.author_username || `User #${c.author_id}`} · {dayjs(c.created_at).format('MM-DD HH:mm')}
{c.content}
))}
diff --git a/src/pages/TasksPage.tsx b/src/pages/TasksPage.tsx
index 8f02aee..165ff6e 100644
--- a/src/pages/TasksPage.tsx
+++ b/src/pages/TasksPage.tsx
@@ -27,8 +27,8 @@ export default function TasksPage() {
useEffect(() => { fetchTasks() }, [page, statusFilter, priorityFilter])
const statusColors: Record
= {
- open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
- closed: '#6b7280', blocked: '#ef4444',
+ pending: '#9ca3af', open: '#3b82f6', undergoing: '#f59e0b',
+ completed: '#10b981', closed: '#6b7280',
}
return (
@@ -50,11 +50,11 @@ export default function TasksPage() {
@@ -64,8 +64,8 @@ export default function TasksPage() {
{tasks.map((t) => (
- navigate(`/tasks/${t.id}`)} className="clickable">
- | {t.id} |
+
navigate(`/tasks/${t.task_code || t.id}`)} className="clickable">
+ | {t.task_code || t.id} |
{t.title} |
{t.status} |
{t.priority} |
diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx
index 182cd97..251a78d 100644
--- a/src/pages/UsersPage.tsx
+++ b/src/pages/UsersPage.tsx
@@ -9,6 +9,11 @@ interface RoleOption {
description?: string | null
}
+interface ApiKeyPerms {
+ can_reset_self: boolean
+ can_reset_any: boolean
+}
+
export default function UsersPage() {
const { user } = useAuth()
const isAdmin = user?.is_admin === true
@@ -19,6 +24,8 @@ export default function UsersPage() {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [selectedId, setSelectedId] = useState(null)
+ const [apikeyPerms, setApikeyPerms] = useState({ can_reset_self: false, can_reset_any: false })
+ const [generatedApiKey, setGeneratedApiKey] = useState(null)
const [createForm, setCreateForm] = useState({
username: '',
@@ -51,6 +58,7 @@ export default function UsersPage() {
useEffect(() => {
if (!selectedUser) return
+ setGeneratedApiKey(null)
setEditForm({
email: selectedUser.email,
full_name: selectedUser.full_name || '',
@@ -71,10 +79,12 @@ export default function UsersPage() {
const fetchData = async () => {
try {
- const [usersRes, rolesRes] = await Promise.all([
+ const [usersRes, rolesRes, apikeyRes] = await Promise.all([
api.get('/users'),
api.get('/roles'),
+ api.get('/auth/me/apikey-permissions').catch(() => ({ data: { can_reset_self: false, can_reset_any: false } })),
])
+ setApikeyPerms(apikeyRes.data)
const assignableRoles = rolesRes.data
.filter((role) => role.name !== 'admin')
.sort((a, b) => a.name.localeCompare(b.name))
@@ -152,6 +162,29 @@ export default function UsersPage() {
}
}
+ const canResetApiKey = (targetUser: User) => {
+ if (apikeyPerms.can_reset_any) return true
+ if (apikeyPerms.can_reset_self && targetUser.id === user?.id) return true
+ return false
+ }
+
+ const handleResetApiKey = async () => {
+ if (!selectedUser) return
+ if (!confirm(`Reset API key for ${selectedUser.username}? The old key will be deactivated.`)) return
+ setSaving(true)
+ setMessage('')
+ setGeneratedApiKey(null)
+ try {
+ const { data } = await api.post(`/users/${selectedUser.id}/reset-apikey`)
+ setGeneratedApiKey(data.api_key)
+ setMessage('API key reset successfully. Copy it now — it will not be shown again.')
+ } catch (err: any) {
+ setMessage(err.response?.data?.detail || 'Failed to reset API key')
+ } finally {
+ setSaving(false)
+ }
+ }
+
const handleDeleteUser = async () => {
if (!selectedUser) return
if (!confirm(`Delete user ${selectedUser.username}? This cannot be undone.`)) return
@@ -274,7 +307,7 @@ export default function UsersPage() {
-