feat: implement debug logging cleanup and i18n coverage proposals
## cleanup-debug-logging - Create environment-aware logger utility (logger.ts) - Replace 60+ console.log/error statements across 28 files - Production: only warn/error logs visible - Development: all log levels with prefixes Updated files: - Contexts: NotificationContext, ProjectSyncContext, AuthContext - Components: GanttChart, CalendarView, ErrorBoundary, and 11 others - Pages: Tasks, Projects, Dashboard, and 7 others - Services: api.ts ## complete-i18n-coverage - WeeklyReportPreview: all strings translated, dynamic locale - ReportHistory: all strings translated, dynamic locale - AuditPage: detail modal and verification modal translated - WorkloadPage: error message translated Locale files updated: - en/common.json, zh-TW/common.json: reports section - en/audit.json, zh-TW/audit.json: modal sections - en/workload.json, zh-TW/workload.json: errors section Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,5 +52,39 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Audit Records",
|
"title": "No Audit Records",
|
||||||
"description": "No audit records match the current filters"
|
"description": "No audit records match the current filters"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"details": {
|
||||||
|
"title": "Audit Log Details",
|
||||||
|
"eventType": "Event Type:",
|
||||||
|
"action": "Action:",
|
||||||
|
"resource": "Resource:",
|
||||||
|
"user": "User:",
|
||||||
|
"ipAddress": "IP Address:",
|
||||||
|
"sensitivity": "Sensitivity:",
|
||||||
|
"time": "Time:",
|
||||||
|
"changes": "Changes",
|
||||||
|
"field": "Field",
|
||||||
|
"oldValue": "Old Value",
|
||||||
|
"newValue": "New Value",
|
||||||
|
"checksum": "Checksum:",
|
||||||
|
"na": "N/A",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"title": "Integrity Verification",
|
||||||
|
"verifying": "Verifying audit log integrity...",
|
||||||
|
"verifyingHint": "This may take a moment depending on the number of records.",
|
||||||
|
"failed": "Verification Failed",
|
||||||
|
"statusSuccess": "Integrity Verified",
|
||||||
|
"statusFailed": "Integrity Issues Detected",
|
||||||
|
"successDescription": "All audit records have valid checksums and have not been tampered with.",
|
||||||
|
"failedDescription": "Some audit records have invalid checksums, indicating potential tampering or corruption.",
|
||||||
|
"totalChecked": "Total Checked",
|
||||||
|
"valid": "Valid",
|
||||||
|
"invalid": "Invalid",
|
||||||
|
"invalidRecords": "Invalid Records",
|
||||||
|
"invalidRecordsDescription": "The following record IDs failed integrity verification:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"upload": "Upload"
|
"upload": "Upload",
|
||||||
|
"retry": "Retry"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -135,5 +136,62 @@
|
|||||||
"maxFileSize": "Maximum file size: {{size}}",
|
"maxFileSize": "Maximum file size: {{size}}",
|
||||||
"uploading": "Uploading {{filename}} ({{current}}/{{total}})...",
|
"uploading": "Uploading {{filename}} ({{current}}/{{total}})...",
|
||||||
"uploadFailed": "Upload failed"
|
"uploadFailed": "Upload failed"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"weeklyPreview": {
|
||||||
|
"title": "Weekly Report Preview",
|
||||||
|
"generateNow": "Generate Now",
|
||||||
|
"generating": "Generating...",
|
||||||
|
"loading": "Loading report preview...",
|
||||||
|
"noData": "No report data available",
|
||||||
|
"noProjects": "No projects found",
|
||||||
|
"projects": "Projects",
|
||||||
|
"retry": "Retry",
|
||||||
|
"completedShort": "completed",
|
||||||
|
"done": "done",
|
||||||
|
"inProgressLower": "in progress",
|
||||||
|
"overdueLower": "overdue",
|
||||||
|
"blockedLower": "blocked",
|
||||||
|
"nextWeekLower": "next week",
|
||||||
|
"unassigned": "Unassigned",
|
||||||
|
"due": "Due",
|
||||||
|
"since": "Since",
|
||||||
|
"noReasonProvided": "No reason provided",
|
||||||
|
"daysOverdue": "{{count}} day overdue",
|
||||||
|
"daysOverdue_other": "{{count}} days overdue",
|
||||||
|
"status": {
|
||||||
|
"completed": "Completed",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"nextWeek": "Next Week",
|
||||||
|
"total": "Total"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Failed to load report preview",
|
||||||
|
"generateFailed": "Failed to generate report"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"generateSuccess": "Report generated and notification sent!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"title": "Report History",
|
||||||
|
"loading": "Loading history...",
|
||||||
|
"retry": "Retry",
|
||||||
|
"empty": "No report history found. Reports are generated every Friday at 16:00.",
|
||||||
|
"totalReports": "{{count}} report",
|
||||||
|
"totalReports_other": "{{count}} reports",
|
||||||
|
"completed": "Completed",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"status": {
|
||||||
|
"sent": "sent",
|
||||||
|
"failed": "failed"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Failed to load report history"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,5 +68,8 @@
|
|||||||
"normal": "Normal: < 80%",
|
"normal": "Normal: < 80%",
|
||||||
"warning": "Warning: 80% - 99%",
|
"warning": "Warning: 80% - 99%",
|
||||||
"overloaded": "Overloaded: ≥ 100%"
|
"overloaded": "Overloaded: ≥ 100%"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Failed to load workload data. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,39 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"title": "沒有稽核記錄",
|
"title": "沒有稽核記錄",
|
||||||
"description": "目前沒有符合條件的稽核記錄"
|
"description": "目前沒有符合條件的稽核記錄"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"details": {
|
||||||
|
"title": "稽核日誌詳情",
|
||||||
|
"eventType": "事件類型:",
|
||||||
|
"action": "操作:",
|
||||||
|
"resource": "資源:",
|
||||||
|
"user": "使用者:",
|
||||||
|
"ipAddress": "IP 位址:",
|
||||||
|
"sensitivity": "敏感度:",
|
||||||
|
"time": "時間:",
|
||||||
|
"changes": "變更內容",
|
||||||
|
"field": "欄位",
|
||||||
|
"oldValue": "舊值",
|
||||||
|
"newValue": "新值",
|
||||||
|
"checksum": "校驗碼:",
|
||||||
|
"na": "無",
|
||||||
|
"system": "系統"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"title": "完整性驗證",
|
||||||
|
"verifying": "正在驗證稽核日誌完整性...",
|
||||||
|
"verifyingHint": "根據記錄數量,這可能需要一些時間。",
|
||||||
|
"failed": "驗證失敗",
|
||||||
|
"statusSuccess": "完整性已驗證",
|
||||||
|
"statusFailed": "偵測到完整性問題",
|
||||||
|
"successDescription": "所有稽核記錄都具有有效的校驗碼,未被竄改。",
|
||||||
|
"failedDescription": "部分稽核記錄的校驗碼無效,表示可能存在竄改或損壞。",
|
||||||
|
"totalChecked": "已檢查總數",
|
||||||
|
"valid": "有效",
|
||||||
|
"invalid": "無效",
|
||||||
|
"invalidRecords": "無效記錄",
|
||||||
|
"invalidRecordsDescription": "以下記錄 ID 未通過完整性驗證:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
"view": "檢視",
|
"view": "檢視",
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"upload": "上傳"
|
"upload": "上傳",
|
||||||
|
"retry": "重試"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -135,5 +136,60 @@
|
|||||||
"maxFileSize": "檔案大小上限:{{size}}",
|
"maxFileSize": "檔案大小上限:{{size}}",
|
||||||
"uploading": "正在上傳 {{filename}} ({{current}}/{{total}})...",
|
"uploading": "正在上傳 {{filename}} ({{current}}/{{total}})...",
|
||||||
"uploadFailed": "上傳失敗"
|
"uploadFailed": "上傳失敗"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"weeklyPreview": {
|
||||||
|
"title": "週報預覽",
|
||||||
|
"generateNow": "立即產生",
|
||||||
|
"generating": "產生中...",
|
||||||
|
"loading": "載入報告預覽中...",
|
||||||
|
"noData": "無可用的報告資料",
|
||||||
|
"noProjects": "找不到專案",
|
||||||
|
"projects": "專案",
|
||||||
|
"retry": "重試",
|
||||||
|
"completedShort": "已完成",
|
||||||
|
"done": "完成",
|
||||||
|
"inProgressLower": "進行中",
|
||||||
|
"overdueLower": "逾期",
|
||||||
|
"blockedLower": "受阻",
|
||||||
|
"nextWeekLower": "下週",
|
||||||
|
"unassigned": "未指派",
|
||||||
|
"due": "截止",
|
||||||
|
"since": "自",
|
||||||
|
"noReasonProvided": "未提供原因",
|
||||||
|
"daysOverdue": "逾期 {{count}} 天",
|
||||||
|
"status": {
|
||||||
|
"completed": "已完成",
|
||||||
|
"inProgress": "進行中",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"blocked": "受阻",
|
||||||
|
"nextWeek": "下週",
|
||||||
|
"total": "總計"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "載入報告預覽失敗",
|
||||||
|
"generateFailed": "產生報告失敗"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"generateSuccess": "報告已產生並發送通知!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"title": "報告歷史",
|
||||||
|
"loading": "載入歷史中...",
|
||||||
|
"retry": "重試",
|
||||||
|
"empty": "找不到報告歷史記錄。報告會在每週五 16:00 自動產生。",
|
||||||
|
"totalReports": "{{count}} 份報告",
|
||||||
|
"completed": "已完成",
|
||||||
|
"inProgress": "進行中",
|
||||||
|
"overdue": "逾期",
|
||||||
|
"status": {
|
||||||
|
"sent": "已發送",
|
||||||
|
"failed": "失敗"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "載入報告歷史失敗"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,5 +68,8 @@
|
|||||||
"normal": "正常:< 80%",
|
"normal": "正常:< 80%",
|
||||||
"warning": "警告:80% - 99%",
|
"warning": "警告:80% - 99%",
|
||||||
"overloaded": "超載:≥ 100%"
|
"overloaded": "超載:≥ 100%"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "載入工作負載資料失敗,請再試一次。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AttachmentVersionHistory } from './AttachmentVersionHistory'
|
|||||||
import { ConfirmModal } from './ConfirmModal'
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import { SkeletonList } from './Skeleton'
|
import { SkeletonList } from './Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface AttachmentListProps {
|
interface AttachmentListProps {
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -30,7 +31,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
|||||||
const response = await attachmentService.listAttachments(taskId)
|
const response = await attachmentService.listAttachments(taskId)
|
||||||
setAttachments(response.attachments)
|
setAttachments(response.attachments)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load attachments:', error)
|
logger.error('Failed to load attachments:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
|||||||
try {
|
try {
|
||||||
await attachmentService.downloadAttachment(attachment.id)
|
await attachmentService.downloadAttachment(attachment.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download attachment:', error)
|
logger.error('Failed to download attachment:', error)
|
||||||
showToast('Failed to download file', 'error')
|
showToast('Failed to download file', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
|||||||
onRefresh?.()
|
onRefresh?.()
|
||||||
showToast('File deleted successfully', 'success')
|
showToast('File deleted successfully', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete attachment:', error)
|
logger.error('Failed to delete attachment:', error)
|
||||||
showToast('Failed to delete file', 'error')
|
showToast('Failed to delete file', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(null)
|
setDeleting(null)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'
|
import { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { attachmentService } from '../services/attachments'
|
import { attachmentService } from '../services/attachments'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
// Spinner animation keyframes - injected once via useEffect
|
// Spinner animation keyframes - injected once via useEffect
|
||||||
const SPINNER_KEYFRAMES_ID = 'attachment-upload-spinner-keyframes'
|
const SPINNER_KEYFRAMES_ID = 'attachment-upload-spinner-keyframes'
|
||||||
@@ -93,7 +94,7 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
|
|||||||
setUploadProgress(null)
|
setUploadProgress(null)
|
||||||
onUploadComplete?.()
|
onUploadComplete?.()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Upload failed:', err)
|
logger.error('Upload failed:', err)
|
||||||
const errorMessage = err instanceof Error ? err.message : t('attachments.uploadFailed')
|
const errorMessage = err instanceof Error ? err.message : t('attachments.uploadFailed')
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
|
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface AttachmentVersionHistoryProps {
|
interface AttachmentVersionHistoryProps {
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
@@ -50,7 +51,7 @@ export function AttachmentVersionHistory({
|
|||||||
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
|
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
|
||||||
setVersions(response.versions)
|
setVersions(response.versions)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load version history:', err)
|
logger.error('Failed to load version history:', err)
|
||||||
setError('Failed to load version history')
|
setError('Failed to load version history')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -72,7 +73,7 @@ export function AttachmentVersionHistory({
|
|||||||
onRestore()
|
onRestore()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to restore version:', err)
|
logger.error('Failed to restore version:', err)
|
||||||
setError('Failed to restore version. Please try again.')
|
setError('Failed to restore version. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setRestoring(null)
|
setRestoring(null)
|
||||||
@@ -83,7 +84,7 @@ export function AttachmentVersionHistory({
|
|||||||
try {
|
try {
|
||||||
await attachmentService.downloadAttachment(attachmentId, version)
|
await attachmentService.downloadAttachment(attachmentId, version)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to download version:', err)
|
logger.error('Failed to download version:', err)
|
||||||
setError('Failed to download version')
|
setError('Failed to download version')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import interactionPlugin from '@fullcalendar/interaction'
|
|||||||
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
|
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Skeleton } from './Skeleton'
|
import { Skeleton } from './Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -109,7 +110,7 @@ export function CalendarView({
|
|||||||
Array.from(uniqueAssignees.entries()).map(([id, name]) => ({ id, name }))
|
Array.from(uniqueAssignees.entries()).map(([id, name]) => ({ id, name }))
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load assignees:', err)
|
logger.error('Failed to load assignees:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ export function CalendarView({
|
|||||||
|
|
||||||
setEvents(calendarEvents)
|
setEvents(calendarEvents)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tasks:', err)
|
logger.error('Failed to load tasks:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -277,7 +278,7 @@ export function CalendarView({
|
|||||||
onTaskUpdate()
|
onTaskUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error('Failed to update task date:', err)
|
logger.error('Failed to update task date:', err)
|
||||||
// Rollback on error
|
// Rollback on error
|
||||||
dropInfo.revert()
|
dropInfo.revert()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
||||||
import { CustomFieldEditor } from './CustomFieldEditor'
|
import { CustomFieldEditor } from './CustomFieldEditor'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface CustomFieldListProps {
|
interface CustomFieldListProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -39,7 +40,7 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
|||||||
const response = await customFieldsApi.getCustomFields(projectId)
|
const response = await customFieldsApi.getCustomFields(projectId)
|
||||||
setFields(response.fields)
|
setFields(response.fields)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load custom fields:', err)
|
logger.error('Failed to load custom fields:', err)
|
||||||
setError(t('customFields.loadError'))
|
setError(t('customFields.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
// Error logging service - can be extended to send to external service
|
// Error logging service - can be extended to send to external service
|
||||||
export interface ErrorLog {
|
export interface ErrorLog {
|
||||||
@@ -25,13 +26,12 @@ export function logError(error: Error, errorInfo: ErrorInfo): ErrorLog {
|
|||||||
|
|
||||||
errorLogs.push(log)
|
errorLogs.push(log)
|
||||||
|
|
||||||
// Log to console for debugging
|
// Log to console for debugging (always logged as these are critical errors)
|
||||||
console.group('ErrorBoundary caught an error')
|
logger.error('ErrorBoundary caught an error')
|
||||||
console.error('Error:', error)
|
logger.error('Error:', error)
|
||||||
console.error('Component Stack:', errorInfo.componentStack)
|
logger.error('Component Stack:', errorInfo.componentStack)
|
||||||
console.error('Timestamp:', log.timestamp.toISOString())
|
logger.error('Timestamp:', log.timestamp.toISOString())
|
||||||
console.error('URL:', log.url)
|
logger.error('URL:', log.url)
|
||||||
console.groupEnd()
|
|
||||||
|
|
||||||
// In production, could send to error tracking service
|
// In production, could send to error tracking service
|
||||||
// sendToErrorTrackingService(log)
|
// sendToErrorTrackingService(log)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import api from '../services/api'
|
|||||||
import { dependenciesApi, TaskDependency, DependencyType } from '../services/dependencies'
|
import { dependenciesApi, TaskDependency, DependencyType } from '../services/dependencies'
|
||||||
import { CircularDependencyError, parseCircularError } from './CircularDependencyError'
|
import { CircularDependencyError, parseCircularError } from './CircularDependencyError'
|
||||||
import { escapeHtml } from '../utils/escapeHtml'
|
import { escapeHtml } from '../utils/escapeHtml'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface CycleDetails {
|
interface CycleDetails {
|
||||||
cycle: string[]
|
cycle: string[]
|
||||||
@@ -91,7 +92,7 @@ export function GanttChart({
|
|||||||
const deps = await dependenciesApi.getProjectDependencies(projectId)
|
const deps = await dependenciesApi.getProjectDependencies(projectId)
|
||||||
setDependencies(deps)
|
setDependencies(deps)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dependencies:', err)
|
logger.error('Failed to load dependencies:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +284,7 @@ export function GanttChart({
|
|||||||
await api.patch(`/tasks/${taskId}`, payload)
|
await api.patch(`/tasks/${taskId}`, payload)
|
||||||
onTaskUpdate()
|
onTaskUpdate()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to update task dates:', err)
|
logger.error('Failed to update task dates:', err)
|
||||||
const error = err as { response?: { status?: number; data?: { detail?: string | { message?: string } } } }
|
const error = err as { response?: { status?: number; data?: { detail?: string | { message?: string } } } }
|
||||||
// Handle 409 Conflict - version mismatch
|
// Handle 409 Conflict - version mismatch
|
||||||
if (error.response?.status === 409) {
|
if (error.response?.status === 409) {
|
||||||
@@ -306,8 +307,8 @@ export function GanttChart({
|
|||||||
// Handle progress change
|
// Handle progress change
|
||||||
const handleProgressChange = async (taskId: string, progress: number) => {
|
const handleProgressChange = async (taskId: string, progress: number) => {
|
||||||
// Progress changes could update task status in the future
|
// Progress changes could update task status in the future
|
||||||
// For now, just log it
|
// For now, just log it for debugging
|
||||||
console.log(`Task ${taskId} progress changed to ${progress}%`)
|
logger.debug(`Task ${taskId} progress changed to ${progress}%`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add dependency
|
// Add dependency
|
||||||
@@ -328,7 +329,7 @@ export function GanttChart({
|
|||||||
setSelectedPredecessor('')
|
setSelectedPredecessor('')
|
||||||
setSelectedDependencyType('FS')
|
setSelectedDependencyType('FS')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to add dependency:', err)
|
logger.error('Failed to add dependency:', err)
|
||||||
const error = err as { response?: { data?: { detail?: unknown } } }
|
const error = err as { response?: { data?: { detail?: unknown } } }
|
||||||
const errorDetail = error.response?.data?.detail
|
const errorDetail = error.response?.data?.detail
|
||||||
|
|
||||||
@@ -353,7 +354,7 @@ export function GanttChart({
|
|||||||
await dependenciesApi.removeDependency(dependencyId)
|
await dependenciesApi.removeDependency(dependencyId)
|
||||||
await loadDependencies()
|
await loadDependencies()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove dependency:', err)
|
logger.error('Failed to remove dependency:', err)
|
||||||
setError('Failed to remove dependency')
|
setError('Failed to remove dependency')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useToast } from '../contexts/ToastContext'
|
|||||||
import { Skeleton } from './Skeleton'
|
import { Skeleton } from './Skeleton'
|
||||||
import { ConfirmModal } from './ConfirmModal'
|
import { ConfirmModal } from './ConfirmModal'
|
||||||
import { AddMemberModal } from './AddMemberModal'
|
import { AddMemberModal } from './AddMemberModal'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface ProjectMemberListProps {
|
interface ProjectMemberListProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -28,7 +29,7 @@ export function ProjectMemberList({ projectId }: ProjectMemberListProps) {
|
|||||||
const response = await projectMembersApi.list(projectId)
|
const response = await projectMembersApi.list(projectId)
|
||||||
setMembers(response.members)
|
setMembers(response.members)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project members:', err)
|
logger.error('Failed to load project members:', err)
|
||||||
setError(t('members.loadError'))
|
setError(t('members.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -47,7 +48,7 @@ export function ProjectMemberList({ projectId }: ProjectMemberListProps) {
|
|||||||
setIsAddModalOpen(false)
|
setIsAddModalOpen(false)
|
||||||
showToast(t('members.memberAdded'), 'success')
|
showToast(t('members.memberAdded'), 'success')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to add member:', err)
|
logger.error('Failed to add member:', err)
|
||||||
const errorMessage = err instanceof Error ? err.message : t('members.addError')
|
const errorMessage = err instanceof Error ? err.message : t('members.addError')
|
||||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||||
if (axiosError.response?.data?.detail) {
|
if (axiosError.response?.data?.detail) {
|
||||||
@@ -70,7 +71,7 @@ export function ProjectMemberList({ projectId }: ProjectMemberListProps) {
|
|||||||
setMemberToRemove(null)
|
setMemberToRemove(null)
|
||||||
showToast(t('messages.memberRemoved'), 'success')
|
showToast(t('messages.memberRemoved'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove member:', err)
|
logger.error('Failed to remove member:', err)
|
||||||
showToast(t('members.removeError'), 'error')
|
showToast(t('members.removeError'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false)
|
setActionLoading(false)
|
||||||
@@ -94,7 +95,7 @@ export function ProjectMemberList({ projectId }: ProjectMemberListProps) {
|
|||||||
setEditingMemberId(null)
|
setEditingMemberId(null)
|
||||||
showToast(t('messages.roleChanged'), 'success')
|
showToast(t('messages.roleChanged'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update member role:', err)
|
logger.error('Failed to update member role:', err)
|
||||||
showToast(t('members.roleChangeError'), 'error')
|
showToast(t('members.roleChangeError'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false)
|
setActionLoading(false)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { reportsApi, ReportHistoryItem } from '../services/reports'
|
import { reportsApi, ReportHistoryItem } from '../services/reports'
|
||||||
|
|
||||||
export function ReportHistory() {
|
export function ReportHistory() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const [reports, setReports] = useState<ReportHistoryItem[]>([])
|
const [reports, setReports] = useState<ReportHistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -15,7 +17,7 @@ export function ReportHistory() {
|
|||||||
setTotal(data.total)
|
setTotal(data.total)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load report history')
|
setError(t('reports.history.errors.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,7 @@ export function ReportHistory() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleString('zh-TW', {
|
return new Date(dateStr).toLocaleString(i18n.language, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -36,7 +38,7 @@ export function ReportHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-center text-gray-500">Loading history...</div>
|
return <div className="p-4 text-center text-gray-500">{t('reports.history.loading')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -44,7 +46,7 @@ export function ReportHistory() {
|
|||||||
<div className="p-4 text-center text-red-500">
|
<div className="p-4 text-center text-red-500">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={fetchHistory} className="ml-2 text-blue-600 hover:underline">
|
<button onClick={fetchHistory} className="ml-2 text-blue-600 hover:underline">
|
||||||
Retry
|
{t('reports.history.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -53,7 +55,7 @@ export function ReportHistory() {
|
|||||||
if (reports.length === 0) {
|
if (reports.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="p-4 text-center text-gray-500">
|
||||||
No report history found. Reports are generated every Friday at 16:00.
|
{t('reports.history.empty')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,8 +63,8 @@ export function ReportHistory() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-lg font-medium">Report History</h3>
|
<h3 className="text-lg font-medium">{t('reports.history.title')}</h3>
|
||||||
<span className="text-sm text-gray-500">{total} reports</span>
|
<span className="text-sm text-gray-500">{t('reports.history.totalReports', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -80,9 +82,9 @@ export function ReportHistory() {
|
|||||||
<p className="font-medium">{formatDate(report.generated_at)}</p>
|
<p className="font-medium">{formatDate(report.generated_at)}</p>
|
||||||
{report.status === 'sent' && summary && (
|
{report.status === 'sent' && summary && (
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Completed: {summary.completed_count || 0} |
|
{t('reports.history.completed')}: {summary.completed_count || 0} |
|
||||||
In Progress: {summary.in_progress_count || 0} |
|
{t('reports.history.inProgress')}: {summary.in_progress_count || 0} |
|
||||||
Overdue: {summary.overdue_count || 0}
|
{t('reports.history.overdue')}: {summary.overdue_count || 0}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{report.status === 'failed' && report.error_message && (
|
{report.status === 'failed' && report.error_message && (
|
||||||
@@ -94,7 +96,7 @@ export function ReportHistory() {
|
|||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-red-100 text-red-800'
|
: 'bg-red-100 text-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
{report.status}
|
{t(`reports.history.status.${report.status}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { auditService, AuditLog } from '../services/audit'
|
import { auditService, AuditLog } from '../services/audit'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface ResourceHistoryProps {
|
interface ResourceHistoryProps {
|
||||||
resourceType: string
|
resourceType: string
|
||||||
@@ -18,7 +19,7 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
|||||||
const response = await auditService.getResourceHistory(resourceType, resourceId, 10)
|
const response = await auditService.getResourceHistory(resourceType, resourceId, 10)
|
||||||
setLogs(response.logs)
|
setLogs(response.logs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load resource history:', error)
|
logger.error('Failed to load resource history:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Subtask {
|
interface Subtask {
|
||||||
id: string
|
id: string
|
||||||
@@ -44,7 +45,7 @@ export function SubtaskList({
|
|||||||
setSubtasks(response.data.tasks || [])
|
setSubtasks(response.data.tasks || [])
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch subtasks:', err)
|
logger.error('Failed to fetch subtasks:', err)
|
||||||
setError(t('subtasks.error.load'))
|
setError(t('subtasks.error.load'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -71,7 +72,7 @@ export function SubtaskList({
|
|||||||
fetchSubtasks()
|
fetchSubtasks()
|
||||||
onSubtaskCreated?.()
|
onSubtaskCreated?.()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to create subtask:', err)
|
logger.error('Failed to create subtask:', err)
|
||||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||||
const errorMessage = axiosError.response?.data?.detail || t('subtasks.error.create')
|
const errorMessage = axiosError.response?.data?.detail || t('subtasks.error.create')
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { UserSearchResult } from '../services/collaboration'
|
|||||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||||
import { CustomFieldInput } from './CustomFieldInput'
|
import { CustomFieldInput } from './CustomFieldInput'
|
||||||
import { SkeletonList } from './Skeleton'
|
import { SkeletonList } from './Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -88,7 +89,7 @@ export function TaskDetailModal({
|
|||||||
const response = await customFieldsApi.getCustomFields(task.project_id)
|
const response = await customFieldsApi.getCustomFields(task.project_id)
|
||||||
setCustomFields(response.fields)
|
setCustomFields(response.fields)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load custom fields:', err)
|
logger.error('Failed to load custom fields:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCustomFields(false)
|
setLoadingCustomFields(false)
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,7 @@ export function TaskDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error('Failed to update task:', err)
|
logger.error('Failed to update task:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { usersApi, UserSearchResult } from '../services/collaboration'
|
import { usersApi, UserSearchResult } from '../services/collaboration'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface UserSelectProps {
|
interface UserSelectProps {
|
||||||
value: string | null
|
value: string | null
|
||||||
@@ -46,7 +47,7 @@ export function UserSelect({
|
|||||||
const results = await usersApi.search(query)
|
const results = await usersApi.search(query)
|
||||||
setUsers(results)
|
setUsers(results)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to search users:', err)
|
logger.error('Failed to search users:', err)
|
||||||
setUsers([])
|
setUsers([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
|
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
|
|
||||||
@@ -53,9 +54,11 @@ function TaskItem({ title, subtitle, highlight }: TaskItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project }: { project: ProjectSummary }) {
|
function ProjectCard({ project }: { project: ProjectSummary }) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
const formatDate = (dateStr: string | null) => {
|
const formatDate = (dateStr: string | null) => {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
return new Date(dateStr).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
return new Date(dateStr).toLocaleDateString(i18n.language, { month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,78 +66,78 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
|
|||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h5 className="font-medium">{project.project_title}</h5>
|
<h5 className="font-medium">{project.project_title}</h5>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{project.completed_count}/{project.total_tasks} completed
|
{project.completed_count}/{project.total_tasks} {t('reports.weeklyPreview.completedShort')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary row */}
|
{/* Summary row */}
|
||||||
<div className="flex flex-wrap gap-3 text-sm mb-2">
|
<div className="flex flex-wrap gap-3 text-sm mb-2">
|
||||||
<span className="text-green-600">{project.completed_count} done</span>
|
<span className="text-green-600">{project.completed_count} {t('reports.weeklyPreview.done')}</span>
|
||||||
<span className="text-blue-600">{project.in_progress_count} in progress</span>
|
<span className="text-blue-600">{project.in_progress_count} {t('reports.weeklyPreview.inProgressLower')}</span>
|
||||||
{project.overdue_count > 0 && (
|
{project.overdue_count > 0 && (
|
||||||
<span className="text-red-600">{project.overdue_count} overdue</span>
|
<span className="text-red-600">{project.overdue_count} {t('reports.weeklyPreview.overdueLower')}</span>
|
||||||
)}
|
)}
|
||||||
{project.blocked_count > 0 && (
|
{project.blocked_count > 0 && (
|
||||||
<span className="text-orange-600">{project.blocked_count} blocked</span>
|
<span className="text-orange-600">{project.blocked_count} {t('reports.weeklyPreview.blockedLower')}</span>
|
||||||
)}
|
)}
|
||||||
{project.next_week_count > 0 && (
|
{project.next_week_count > 0 && (
|
||||||
<span className="text-purple-600">{project.next_week_count} next week</span>
|
<span className="text-purple-600">{project.next_week_count} {t('reports.weeklyPreview.nextWeekLower')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completed Tasks */}
|
{/* Completed Tasks */}
|
||||||
<CollapsibleSection title="Completed" count={project.completed_count} colorClass="text-green-700">
|
<CollapsibleSection title={t('reports.weeklyPreview.status.completed')} count={project.completed_count} colorClass="text-green-700">
|
||||||
{project.completed_tasks.map(task => (
|
{project.completed_tasks.map(task => (
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
subtitle={`${task.assignee_name || 'Unassigned'} • ${formatDate(task.completed_at)}`}
|
subtitle={`${task.assignee_name || t('reports.weeklyPreview.unassigned')} • ${formatDate(task.completed_at)}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* In Progress Tasks */}
|
{/* In Progress Tasks */}
|
||||||
<CollapsibleSection title="In Progress" count={project.in_progress_count} colorClass="text-blue-700">
|
<CollapsibleSection title={t('reports.weeklyPreview.status.inProgress')} count={project.in_progress_count} colorClass="text-blue-700">
|
||||||
{project.in_progress_tasks.map(task => (
|
{project.in_progress_tasks.map(task => (
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
subtitle={`${task.assignee_name || 'Unassigned'}${task.due_date ? ` • Due ${formatDate(task.due_date)}` : ''}`}
|
subtitle={`${task.assignee_name || t('reports.weeklyPreview.unassigned')}${task.due_date ? ` • ${t('reports.weeklyPreview.due')} ${formatDate(task.due_date)}` : ''}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Overdue Tasks */}
|
{/* Overdue Tasks */}
|
||||||
<CollapsibleSection title="Overdue" count={project.overdue_count} colorClass="text-red-700" defaultOpen>
|
<CollapsibleSection title={t('reports.weeklyPreview.status.overdue')} count={project.overdue_count} colorClass="text-red-700" defaultOpen>
|
||||||
{project.overdue_tasks.map(task => (
|
{project.overdue_tasks.map(task => (
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
subtitle={`${task.assignee_name || 'Unassigned'} • ${task.days_overdue} days overdue`}
|
subtitle={`${task.assignee_name || t('reports.weeklyPreview.unassigned')} • ${t('reports.weeklyPreview.daysOverdue', { count: task.days_overdue })}`}
|
||||||
highlight="overdue"
|
highlight="overdue"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Blocked Tasks */}
|
{/* Blocked Tasks */}
|
||||||
<CollapsibleSection title="Blocked" count={project.blocked_count} colorClass="text-orange-700" defaultOpen>
|
<CollapsibleSection title={t('reports.weeklyPreview.status.blocked')} count={project.blocked_count} colorClass="text-orange-700" defaultOpen>
|
||||||
{project.blocked_tasks.map(task => (
|
{project.blocked_tasks.map(task => (
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
subtitle={`${task.blocker_reason || 'No reason provided'}${task.blocked_since ? ` • Since ${formatDate(task.blocked_since)}` : ''}`}
|
subtitle={`${task.blocker_reason || t('reports.weeklyPreview.noReasonProvided')}${task.blocked_since ? ` • ${t('reports.weeklyPreview.since')} ${formatDate(task.blocked_since)}` : ''}`}
|
||||||
highlight="blocked"
|
highlight="blocked"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Next Week Tasks */}
|
{/* Next Week Tasks */}
|
||||||
<CollapsibleSection title="Next Week" count={project.next_week_count} colorClass="text-purple-700">
|
<CollapsibleSection title={t('reports.weeklyPreview.status.nextWeek')} count={project.next_week_count} colorClass="text-purple-700">
|
||||||
{project.next_week_tasks.map(task => (
|
{project.next_week_tasks.map(task => (
|
||||||
<TaskItem
|
<TaskItem
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
subtitle={`${task.assignee_name || 'Unassigned'} • Due ${formatDate(task.due_date)}`}
|
subtitle={`${task.assignee_name || t('reports.weeklyPreview.unassigned')} • ${t('reports.weeklyPreview.due')} ${formatDate(task.due_date)}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
@@ -143,6 +146,7 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WeeklyReportPreview() {
|
export function WeeklyReportPreview() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [report, setReport] = useState<WeeklyReportContent | null>(null)
|
const [report, setReport] = useState<WeeklyReportContent | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -156,7 +160,7 @@ export function WeeklyReportPreview() {
|
|||||||
setReport(data)
|
setReport(data)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load report preview')
|
setError(t('reports.weeklyPreview.errors.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -170,17 +174,17 @@ export function WeeklyReportPreview() {
|
|||||||
try {
|
try {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
await reportsApi.generateWeeklyReport()
|
await reportsApi.generateWeeklyReport()
|
||||||
showToast('Report generated and notification sent!', 'success')
|
showToast(t('reports.weeklyPreview.messages.generateSuccess'), 'success')
|
||||||
fetchPreview()
|
fetchPreview()
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Failed to generate report', 'error')
|
showToast(t('reports.weeklyPreview.errors.generateFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-4 text-center text-gray-500">Loading report preview...</div>
|
return <div className="p-4 text-center text-gray-500">{t('reports.weeklyPreview.loading')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -188,18 +192,18 @@ export function WeeklyReportPreview() {
|
|||||||
<div className="p-4 text-center text-red-500">
|
<div className="p-4 text-center text-red-500">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={fetchPreview} className="ml-2 text-blue-600 hover:underline">
|
<button onClick={fetchPreview} className="ml-2 text-blue-600 hover:underline">
|
||||||
Retry
|
{t('reports.weeklyPreview.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
return <div className="p-4 text-center text-gray-500">No report data available</div>
|
return <div className="p-4 text-center text-gray-500">{t('reports.weeklyPreview.noData')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString('zh-TW', {
|
return new Date(dateStr).toLocaleDateString(i18n.language, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})
|
})
|
||||||
@@ -209,7 +213,7 @@ export function WeeklyReportPreview() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Weekly Report Preview</h3>
|
<h3 className="text-lg font-medium">{t('reports.weeklyPreview.title')}</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{formatDate(report.week_start)} - {formatDate(report.week_end)}
|
{formatDate(report.week_start)} - {formatDate(report.week_end)}
|
||||||
</p>
|
</p>
|
||||||
@@ -219,7 +223,7 @@ export function WeeklyReportPreview() {
|
|||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{generating ? 'Generating...' : 'Generate Now'}
|
{generating ? t('reports.weeklyPreview.generating') : t('reports.weeklyPreview.generateNow')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,40 +231,40 @@ export function WeeklyReportPreview() {
|
|||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-green-600">{report.summary.completed_count}</p>
|
<p className="text-2xl font-bold text-green-600">{report.summary.completed_count}</p>
|
||||||
<p className="text-xs text-green-800">Completed</p>
|
<p className="text-xs text-green-800">{t('reports.weeklyPreview.status.completed')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
|
<p className="text-2xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
|
||||||
<p className="text-xs text-blue-800">In Progress</p>
|
<p className="text-xs text-blue-800">{t('reports.weeklyPreview.status.inProgress')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-red-600">{report.summary.overdue_count}</p>
|
<p className="text-2xl font-bold text-red-600">{report.summary.overdue_count}</p>
|
||||||
<p className="text-xs text-red-800">Overdue</p>
|
<p className="text-xs text-red-800">{t('reports.weeklyPreview.status.overdue')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-orange-600">{report.summary.blocked_count}</p>
|
<p className="text-2xl font-bold text-orange-600">{report.summary.blocked_count}</p>
|
||||||
<p className="text-xs text-orange-800">Blocked</p>
|
<p className="text-xs text-orange-800">{t('reports.weeklyPreview.status.blocked')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3">
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-purple-600">{report.summary.next_week_count}</p>
|
<p className="text-2xl font-bold text-purple-600">{report.summary.next_week_count}</p>
|
||||||
<p className="text-xs text-purple-800">Next Week</p>
|
<p className="text-xs text-purple-800">{t('reports.weeklyPreview.status.nextWeek')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||||
<p className="text-2xl font-bold text-gray-600">{report.summary.total_tasks}</p>
|
<p className="text-2xl font-bold text-gray-600">{report.summary.total_tasks}</p>
|
||||||
<p className="text-xs text-gray-800">Total</p>
|
<p className="text-xs text-gray-800">{t('reports.weeklyPreview.status.total')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Details */}
|
{/* Project Details */}
|
||||||
{report.projects.length > 0 ? (
|
{report.projects.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium">Projects</h4>
|
<h4 className="font-medium">{t('reports.weeklyPreview.projects')}</h4>
|
||||||
{report.projects.map(project => (
|
{report.projects.map(project => (
|
||||||
<ProjectCard key={project.project_id} project={project} />
|
<ProjectCard key={project.project_id} project={project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 text-center py-4">No projects found</p>
|
<p className="text-gray-500 text-center py-4">{t('reports.weeklyPreview.noProjects')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||||
import { SkeletonList } from './Skeleton'
|
import { SkeletonList } from './Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface WorkloadUserDetailProps {
|
interface WorkloadUserDetailProps {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -62,7 +63,7 @@ export function WorkloadUserDetail({
|
|||||||
const data = await workloadApi.getUserWorkload(userId, weekStart)
|
const data = await workloadApi.getUserWorkload(userId, weekStart)
|
||||||
setDetail(data)
|
setDetail(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load user workload:', err)
|
logger.error('Failed to load user workload:', err)
|
||||||
setError('Failed to load workload details')
|
setError('Failed to load workload details')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getStoredToken,
|
getStoredToken,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a parsed object has the required User properties.
|
* Validates that a parsed object has the required User properties.
|
||||||
@@ -89,7 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(validatedUser)
|
setUser(validatedUser)
|
||||||
} else {
|
} else {
|
||||||
// Invalid user data structure, clear storage and redirect to login
|
// Invalid user data structure, clear storage and redirect to login
|
||||||
console.warn('Invalid user data in localStorage, clearing session')
|
logger.warn('Invalid user data in localStorage, clearing session')
|
||||||
clearStoredTokens()
|
clearStoredTokens()
|
||||||
// Don't redirect here as we're in initial loading state
|
// Don't redirect here as we're in initial loading state
|
||||||
// The app will naturally show login page when user is null
|
// The app will naturally show login page when user is null
|
||||||
@@ -97,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// JSON parse error or other unexpected error
|
// JSON parse error or other unexpected error
|
||||||
console.error('Error parsing stored user data:', err)
|
logger.error('Error parsing stored user data:', err)
|
||||||
clearStoredTokens()
|
clearStoredTokens()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode, useRef } from 'react'
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode, useRef } from 'react'
|
||||||
import { notificationsApi, Notification, NotificationListResponse } from '../services/collaboration'
|
import { notificationsApi, Notification, NotificationListResponse } from '../services/collaboration'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface NotificationContextType {
|
interface NotificationContextType {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
@@ -32,7 +33,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
const count = await notificationsApi.getUnreadCount()
|
const count = await notificationsApi.getUnreadCount()
|
||||||
setUnreadCount(count)
|
setUnreadCount(count)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch unread count:', err)
|
logger.error('Failed to fetch unread count:', err)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
setUnreadCount(response.unread_count)
|
setUnreadCount(response.unread_count)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load notifications')
|
setError('Failed to load notifications')
|
||||||
console.error('Failed to fetch notifications:', err)
|
logger.error('Failed to fetch notifications:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
setUnreadCount(prev => Math.max(0, prev - 1))
|
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to mark as read:', err)
|
logger.error('Failed to mark as read:', err)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
setUnreadCount(0)
|
setUnreadCount(0)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to mark all as read:', err)
|
logger.error('Failed to mark all as read:', err)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket opened, sending authentication...')
|
logger.debug('WebSocket opened, sending authentication...')
|
||||||
// Send authentication message as first message (more secure than query parameter)
|
// Send authentication message as first message (more secure than query parameter)
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'auth', token }))
|
ws.send(JSON.stringify({ type: 'auth', token }))
|
||||||
@@ -116,7 +117,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'connected':
|
case 'connected':
|
||||||
console.log('WebSocket authenticated:', message.data.message)
|
logger.debug('WebSocket authenticated:', message.data.message)
|
||||||
// Start ping interval after successful authentication
|
// Start ping interval after successful authentication
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -169,12 +170,12 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse WebSocket message:', err)
|
logger.error('Failed to parse WebSocket message:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
console.log('WebSocket disconnected')
|
logger.debug('WebSocket disconnected')
|
||||||
// Clear ping interval
|
// Clear ping interval
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
@@ -192,10 +193,10 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
console.error('WebSocket error:', err)
|
logger.error('WebSocket error:', err)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create WebSocket:', err)
|
logger.error('Failed to create WebSocket:', err)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface TaskEvent {
|
interface TaskEvent {
|
||||||
type: 'task_created' | 'task_updated' | 'task_status_changed' | 'task_deleted' | 'task_assigned'
|
type: 'task_created' | 'task_updated' | 'task_status_changed' | 'task_deleted' | 'task_assigned'
|
||||||
@@ -47,18 +48,6 @@ const INITIAL_RECONNECT_DELAY = 1000 // 1 second
|
|||||||
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
|
const MAX_RECONNECT_DELAY = 30000 // 30 seconds
|
||||||
const MAX_PROCESSED_EVENTS = 1000 // Limit memory usage for event tracking
|
const MAX_PROCESSED_EVENTS = 1000 // Limit memory usage for event tracking
|
||||||
|
|
||||||
// Development-only logging helper
|
|
||||||
const devLog = (...args: unknown[]) => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log(...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const devError = (...args: unknown[]) => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.error(...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectSyncProvider({ children }: { children: React.ReactNode }) {
|
export function ProjectSyncProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
@@ -110,7 +99,7 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode })
|
|||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
devLog(`WebSocket opened for project ${projectId}, sending auth...`)
|
logger.debug(`WebSocket opened for project ${projectId}, sending auth...`)
|
||||||
// Send authentication message as first message (more secure than query parameter)
|
// Send authentication message as first message (more secure than query parameter)
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'auth', token }))
|
ws.send(JSON.stringify({ type: 'auth', token }))
|
||||||
@@ -134,7 +123,7 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode })
|
|||||||
reconnectAttemptsRef.current = 0 // Reset on successful connection
|
reconnectAttemptsRef.current = 0 // Reset on successful connection
|
||||||
setIsConnected(true)
|
setIsConnected(true)
|
||||||
setCurrentProjectId(projectId)
|
setCurrentProjectId(projectId)
|
||||||
devLog(`Authenticated and connected to project ${projectId} sync`)
|
logger.debug(`Authenticated and connected to project ${projectId} sync`)
|
||||||
|
|
||||||
// Start ping interval to keep connection alive
|
// Start ping interval to keep connection alive
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
@@ -183,7 +172,7 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode })
|
|||||||
listenersRef.current.forEach((listener) => listener(message as TaskEvent))
|
listenersRef.current.forEach((listener) => listener(message as TaskEvent))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
devError('Failed to parse WebSocket message:', e)
|
logger.error('Failed to parse WebSocket message:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +195,7 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode })
|
|||||||
MAX_RECONNECT_DELAY
|
MAX_RECONNECT_DELAY
|
||||||
)
|
)
|
||||||
reconnectAttemptsRef.current++
|
reconnectAttemptsRef.current++
|
||||||
devLog(
|
logger.debug(
|
||||||
`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
|
`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,17 +205,17 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
}, delay)
|
}, delay)
|
||||||
} else if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
} else if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
devError('Max reconnection attempts reached. Please refresh the page.')
|
logger.error('Max reconnection attempts reached. Please refresh the page.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
devError('WebSocket error:', error)
|
logger.error('WebSocket error:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
devError('Failed to create WebSocket:', err)
|
logger.error('Failed to create WebSocket:', err)
|
||||||
}
|
}
|
||||||
}, [cleanup])
|
}, [cleanup])
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'
|
|||||||
import { SkeletonTable } from '../components/Skeleton'
|
import { SkeletonTable } from '../components/Skeleton'
|
||||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||||
import { escapeHtml } from '../utils/escapeHtml'
|
import { escapeHtml } from '../utils/escapeHtml'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface AuditLogDetailProps {
|
interface AuditLogDetailProps {
|
||||||
log: AuditLog
|
log: AuditLog
|
||||||
@@ -11,6 +12,7 @@ interface AuditLogDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||||
|
const { t, i18n } = useTranslation('audit')
|
||||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Handle Escape key to close modal - document-level listener
|
// Handle Escape key to close modal - document-level listener
|
||||||
@@ -41,49 +43,49 @@ function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
|||||||
>
|
>
|
||||||
<div style={styles.modalContent}>
|
<div style={styles.modalContent}>
|
||||||
<div style={styles.modalHeader}>
|
<div style={styles.modalHeader}>
|
||||||
<h3 id="audit-log-detail-title">Audit Log Details</h3>
|
<h3 id="audit-log-detail-title">{t('modal.details.title')}</h3>
|
||||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">×</button>
|
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.modalBody}>
|
<div style={styles.modalBody}>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Event Type:</span>
|
<span style={styles.label}>{t('modal.details.eventType')}</span>
|
||||||
<span>{log.event_type}</span>
|
<span>{log.event_type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Action:</span>
|
<span style={styles.label}>{t('modal.details.action')}</span>
|
||||||
<span>{log.action}</span>
|
<span>{log.action}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Resource:</span>
|
<span style={styles.label}>{t('modal.details.resource')}</span>
|
||||||
<span>{log.resource_type} / {log.resource_id || 'N/A'}</span>
|
<span>{log.resource_type} / {log.resource_id || t('modal.details.na')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>User:</span>
|
<span style={styles.label}>{t('modal.details.user')}</span>
|
||||||
<span>{log.user_name || log.user_id || 'System'}</span>
|
<span>{log.user_name || log.user_id || t('modal.details.system')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>IP Address:</span>
|
<span style={styles.label}>{t('modal.details.ipAddress')}</span>
|
||||||
<span>{log.request_metadata?.ip_address || 'N/A'}</span>
|
<span>{log.request_metadata?.ip_address || t('modal.details.na')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Sensitivity:</span>
|
<span style={styles.label}>{t('modal.details.sensitivity')}</span>
|
||||||
<span style={getSensitivityStyle(log.sensitivity_level)}>
|
<span style={getSensitivityStyle(log.sensitivity_level)}>
|
||||||
{log.sensitivity_level}
|
{log.sensitivity_level}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Time:</span>
|
<span style={styles.label}>{t('modal.details.time')}</span>
|
||||||
<span>{new Date(log.created_at).toLocaleString()}</span>
|
<span>{new Date(log.created_at).toLocaleString(i18n.language)}</span>
|
||||||
</div>
|
</div>
|
||||||
{log.changes && log.changes.length > 0 && (
|
{log.changes && log.changes.length > 0 && (
|
||||||
<div style={styles.changesSection}>
|
<div style={styles.changesSection}>
|
||||||
<h4>Changes</h4>
|
<h4>{t('modal.details.changes')}</h4>
|
||||||
<table style={styles.changesTable}>
|
<table style={styles.changesTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Field</th>
|
<th>{t('modal.details.field')}</th>
|
||||||
<th>Old Value</th>
|
<th>{t('modal.details.oldValue')}</th>
|
||||||
<th>New Value</th>
|
<th>{t('modal.details.newValue')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -99,7 +101,7 @@ function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={styles.detailRow}>
|
<div style={styles.detailRow}>
|
||||||
<span style={styles.label}>Checksum:</span>
|
<span style={styles.label}>{t('modal.details.checksum')}</span>
|
||||||
<span style={styles.checksum}>{log.checksum}</span>
|
<span style={styles.checksum}>{log.checksum}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +135,7 @@ interface IntegrityVerificationModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
|
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
|
||||||
|
const { t } = useTranslation('audit')
|
||||||
const isSuccess = result && result.invalid_count === 0
|
const isSuccess = result && result.invalid_count === 0
|
||||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -186,22 +189,22 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ
|
|||||||
>
|
>
|
||||||
<div style={styles.modalContent}>
|
<div style={styles.modalContent}>
|
||||||
<div style={styles.modalHeader}>
|
<div style={styles.modalHeader}>
|
||||||
<h3 id="integrity-verification-title">Integrity Verification</h3>
|
<h3 id="integrity-verification-title">{t('modal.verification.title')}</h3>
|
||||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">x</button>
|
<button onClick={onClose} style={styles.closeButton} aria-label={t('common:buttons.close')}>x</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.modalBody}>
|
<div style={styles.modalBody}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div style={styles.loadingContainer}>
|
<div style={styles.loadingContainer}>
|
||||||
<div className="integrity-spinner" style={styles.spinner}></div>
|
<div className="integrity-spinner" style={styles.spinner}></div>
|
||||||
<p style={styles.loadingText}>Verifying audit log integrity...</p>
|
<p style={styles.loadingText}>{t('modal.verification.verifying')}</p>
|
||||||
<p style={styles.loadingSubtext}>This may take a moment depending on the number of records.</p>
|
<p style={styles.loadingSubtext}>{t('modal.verification.verifyingHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={styles.errorContainer}>
|
<div style={styles.errorContainer}>
|
||||||
<div style={styles.errorIcon}>!</div>
|
<div style={styles.errorIcon}>!</div>
|
||||||
<h4 style={styles.errorTitle}>Verification Failed</h4>
|
<h4 style={styles.errorTitle}>{t('modal.verification.failed')}</h4>
|
||||||
<p style={styles.errorMessage}>{error}</p>
|
<p style={styles.errorMessage}>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -225,15 +228,15 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ
|
|||||||
...styles.statusTitle,
|
...styles.statusTitle,
|
||||||
color: isSuccess ? '#155724' : '#721c24',
|
color: isSuccess ? '#155724' : '#721c24',
|
||||||
}}>
|
}}>
|
||||||
{isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'}
|
{isSuccess ? t('modal.verification.statusSuccess') : t('modal.verification.statusFailed')}
|
||||||
</h4>
|
</h4>
|
||||||
<p style={{
|
<p style={{
|
||||||
...styles.statusDescription,
|
...styles.statusDescription,
|
||||||
color: isSuccess ? '#155724' : '#721c24',
|
color: isSuccess ? '#155724' : '#721c24',
|
||||||
}}>
|
}}>
|
||||||
{isSuccess
|
{isSuccess
|
||||||
? 'All audit records have valid checksums and have not been tampered with.'
|
? t('modal.verification.successDescription')
|
||||||
: 'Some audit records have invalid checksums, indicating potential tampering or corruption.'}
|
: t('modal.verification.failedDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,26 +245,26 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ
|
|||||||
<div style={styles.statsContainer}>
|
<div style={styles.statsContainer}>
|
||||||
<div style={styles.statBox}>
|
<div style={styles.statBox}>
|
||||||
<span style={styles.statValue}>{result.total_checked}</span>
|
<span style={styles.statValue}>{result.total_checked}</span>
|
||||||
<span style={styles.statLabel}>Total Checked</span>
|
<span style={styles.statLabel}>{t('modal.verification.totalChecked')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
|
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
|
||||||
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
|
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
|
||||||
<span style={styles.statLabel}>Valid</span>
|
<span style={styles.statLabel}>{t('modal.verification.valid')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
|
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
|
||||||
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
|
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
|
||||||
{result.invalid_count}
|
{result.invalid_count}
|
||||||
</span>
|
</span>
|
||||||
<span style={styles.statLabel}>Invalid</span>
|
<span style={styles.statLabel}>{t('modal.verification.invalid')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invalid Records List */}
|
{/* Invalid Records List */}
|
||||||
{result.invalid_records && result.invalid_records.length > 0 && (
|
{result.invalid_records && result.invalid_records.length > 0 && (
|
||||||
<div style={styles.invalidRecordsSection}>
|
<div style={styles.invalidRecordsSection}>
|
||||||
<h4 style={styles.invalidRecordsTitle}>Invalid Records</h4>
|
<h4 style={styles.invalidRecordsTitle}>{t('modal.verification.invalidRecords')}</h4>
|
||||||
<p style={styles.invalidRecordsDescription}>
|
<p style={styles.invalidRecordsDescription}>
|
||||||
The following record IDs failed integrity verification:
|
{t('modal.verification.invalidRecordsDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div style={styles.invalidRecordsList}>
|
<div style={styles.invalidRecordsList}>
|
||||||
{result.invalid_records.map((recordId, index) => (
|
{result.invalid_records.map((recordId, index) => (
|
||||||
@@ -312,7 +315,7 @@ export default function AuditPage() {
|
|||||||
setLogs(response.logs)
|
setLogs(response.logs)
|
||||||
setTotal(response.total)
|
setTotal(response.total)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load audit logs:', error)
|
logger.error('Failed to load audit logs:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -344,7 +347,7 @@ export default function AuditPage() {
|
|||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to export audit logs:', error)
|
logger.error('Failed to export audit logs:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +355,7 @@ export default function AuditPage() {
|
|||||||
// Create a printable version of the audit logs
|
// Create a printable version of the audit logs
|
||||||
const printWindow = window.open('', '_blank')
|
const printWindow = window.open('', '_blank')
|
||||||
if (!printWindow) {
|
if (!printWindow) {
|
||||||
console.error('Failed to open print window. Please allow popups.')
|
logger.error('Failed to open print window. Please allow popups.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +451,7 @@ export default function AuditPage() {
|
|||||||
const result = await auditService.verifyIntegrity(startDate, endDate)
|
const result = await auditService.verifyIntegrity(startDate, endDate)
|
||||||
setVerificationResult(result)
|
setVerificationResult(result)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to verify integrity:', error)
|
logger.error('Failed to verify integrity:', error)
|
||||||
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
||||||
setVerificationError(
|
setVerificationError(
|
||||||
err.response?.data?.detail ||
|
err.response?.data?.detail ||
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
QuickActions,
|
QuickActions,
|
||||||
} from '../components/dashboard'
|
} from '../components/dashboard'
|
||||||
import { Skeleton } from '../components/Skeleton'
|
import { Skeleton } from '../components/Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation('dashboard')
|
const { t } = useTranslation('dashboard')
|
||||||
@@ -25,7 +26,7 @@ export default function Dashboard() {
|
|||||||
setData(response)
|
setData(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('common:messages.networkError'))
|
setError(t('common:messages.networkError'))
|
||||||
console.error('Dashboard fetch error:', err)
|
logger.error('Dashboard fetch error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'
|
|||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Skeleton } from '../components/Skeleton'
|
import { Skeleton } from '../components/Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
id: string
|
id: string
|
||||||
@@ -47,7 +48,7 @@ export default function MySettings() {
|
|||||||
})
|
})
|
||||||
setCapacity(String(userData.capacity || 40))
|
setCapacity(String(userData.capacity || 40))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load profile:', err)
|
logger.error('Failed to load profile:', err)
|
||||||
setError(t('common:messages.error'))
|
setError(t('common:messages.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -60,7 +61,7 @@ export default function MySettings() {
|
|||||||
const response = await api.get('/reports/weekly/subscription')
|
const response = await api.get('/reports/weekly/subscription')
|
||||||
setWeeklySubscription(Boolean(response.data?.is_active))
|
setWeeklySubscription(Boolean(response.data?.is_active))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load weekly subscription:', err)
|
logger.error('Failed to load weekly subscription:', err)
|
||||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setSubscriptionLoading(false)
|
setSubscriptionLoading(false)
|
||||||
@@ -85,7 +86,7 @@ export default function MySettings() {
|
|||||||
setProfile({ ...profile, capacity: capacityValue })
|
setProfile({ ...profile, capacity: capacityValue })
|
||||||
showToast(t('mySettings.capacitySaved'), 'success')
|
showToast(t('mySettings.capacitySaved'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save capacity:', err)
|
logger.error('Failed to save capacity:', err)
|
||||||
showToast(t('mySettings.capacityError'), 'error')
|
showToast(t('mySettings.capacityError'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -102,7 +103,7 @@ export default function MySettings() {
|
|||||||
'success'
|
'success'
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update weekly subscription:', err)
|
logger.error('Failed to update weekly subscription:', err)
|
||||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setSubscriptionSaving(false)
|
setSubscriptionSaving(false)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ProjectHealthItem,
|
ProjectHealthItem,
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
} from '../services/projectHealth'
|
} from '../services/projectHealth'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
type SortOption = 'risk_high' | 'risk_low' | 'health_high' | 'health_low' | 'name'
|
type SortOption = 'risk_high' | 'risk_low' | 'health_high' | 'health_low' | 'name'
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default function ProjectHealthPage() {
|
|||||||
const data = await projectHealthApi.getDashboard()
|
const data = await projectHealthApi.getDashboard()
|
||||||
setDashboardData(data)
|
setDashboardData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project health dashboard:', err)
|
logger.error('Failed to load project health dashboard:', err)
|
||||||
setError(t('error.loadFailed'))
|
setError(t('error.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CustomFieldList } from '../components/CustomFieldList'
|
|||||||
import { ProjectMemberList } from '../components/ProjectMemberList'
|
import { ProjectMemberList } from '../components/ProjectMemberList'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import { Skeleton } from '../components/Skeleton'
|
import { Skeleton } from '../components/Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string
|
||||||
@@ -34,7 +35,7 @@ export default function ProjectSettings() {
|
|||||||
const response = await api.get(`/projects/${projectId}`)
|
const response = await api.get(`/projects/${projectId}`)
|
||||||
setProject(response.data)
|
setProject(response.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project:', err)
|
logger.error('Failed to load project:', err)
|
||||||
showToast(t('common:messages.error'), 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import api from '../services/api'
|
|||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string
|
||||||
@@ -115,7 +116,7 @@ export default function Projects() {
|
|||||||
setSpace(spaceRes.data)
|
setSpace(spaceRes.data)
|
||||||
setProjects(projectsRes.data)
|
setProjects(projectsRes.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
logger.error('Failed to load data:', err)
|
||||||
showToast(t('messages.loadFailed'), 'error')
|
showToast(t('messages.loadFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -129,7 +130,7 @@ export default function Projects() {
|
|||||||
// API returns {templates: [], total: number}
|
// API returns {templates: [], total: number}
|
||||||
setTemplates(response.data.templates || [])
|
setTemplates(response.data.templates || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load templates:', err)
|
logger.error('Failed to load templates:', err)
|
||||||
showToast(t('template.loadFailed'), 'error')
|
showToast(t('template.loadFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setTemplatesLoading(false)
|
setTemplatesLoading(false)
|
||||||
@@ -165,7 +166,7 @@ export default function Projects() {
|
|||||||
loadData()
|
loadData()
|
||||||
showToast(t('messages.created'), 'success')
|
showToast(t('messages.created'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create project:', err)
|
logger.error('Failed to create project:', err)
|
||||||
showToast(t('messages.createFailed'), 'error')
|
showToast(t('messages.createFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
@@ -189,7 +190,7 @@ export default function Projects() {
|
|||||||
loadData()
|
loadData()
|
||||||
showToast(t('messages.deleted'), 'success')
|
showToast(t('messages.deleted'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete project:', err)
|
logger.error('Failed to delete project:', err)
|
||||||
showToast(t('common:messages.error'), 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import api from '../services/api'
|
|||||||
import { useToast } from '../contexts/ToastContext'
|
import { useToast } from '../contexts/ToastContext'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { SkeletonGrid } from '../components/Skeleton'
|
import { SkeletonGrid } from '../components/Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Space {
|
interface Space {
|
||||||
id: string
|
id: string
|
||||||
@@ -69,7 +70,7 @@ export default function Spaces() {
|
|||||||
const response = await api.get('/spaces')
|
const response = await api.get('/spaces')
|
||||||
setSpaces(response.data)
|
setSpaces(response.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load spaces:', err)
|
logger.error('Failed to load spaces:', err)
|
||||||
showToast(t('common:messages.error'), 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -87,7 +88,7 @@ export default function Spaces() {
|
|||||||
loadSpaces()
|
loadSpaces()
|
||||||
showToast(t('messages.created'), 'success')
|
showToast(t('messages.created'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create space:', err)
|
logger.error('Failed to create space:', err)
|
||||||
showToast(t('common:messages.error'), 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
@@ -111,7 +112,7 @@ export default function Spaces() {
|
|||||||
loadSpaces()
|
loadSpaces()
|
||||||
showToast(t('messages.deleted'), 'success')
|
showToast(t('messages.deleted'), 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete space:', err)
|
logger.error('Failed to delete space:', err)
|
||||||
showToast(t('common:messages.error'), 'error')
|
showToast(t('common:messages.error'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
|
|||||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||||
import { CustomFieldInput } from '../components/CustomFieldInput'
|
import { CustomFieldInput } from '../components/CustomFieldInput'
|
||||||
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
|
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -135,7 +136,7 @@ export default function Tasks() {
|
|||||||
const response = await customFieldsApi.getCustomFields(projectId!)
|
const response = await customFieldsApi.getCustomFields(projectId!)
|
||||||
setCustomFields(response.fields)
|
setCustomFields(response.fields)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load custom fields:', err)
|
logger.error('Failed to load custom fields:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +316,7 @@ export default function Tasks() {
|
|||||||
setTasks(tasksRes.data.tasks)
|
setTasks(tasksRes.data.tasks)
|
||||||
setStatuses(statusesRes.data)
|
setStatuses(statusesRes.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
logger.error('Failed to load data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -377,7 +378,7 @@ export default function Tasks() {
|
|||||||
setSelectedAssignee(null)
|
setSelectedAssignee(null)
|
||||||
loadData()
|
loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create task:', err)
|
logger.error('Failed to create task:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
@@ -417,7 +418,7 @@ export default function Tasks() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Rollback on error
|
// Rollback on error
|
||||||
setTasks(originalTasks)
|
setTasks(originalTasks)
|
||||||
console.error('Failed to update status:', err)
|
logger.error('Failed to update status:', err)
|
||||||
// Could add toast notification here for better UX
|
// Could add toast notification here for better UX
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +456,7 @@ export default function Tasks() {
|
|||||||
time_estimate: updatedTask.original_estimate,
|
time_estimate: updatedTask.original_estimate,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh selected task:', err)
|
logger.error('Failed to refresh selected task:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,7 +475,7 @@ export default function Tasks() {
|
|||||||
setSelectedTask(subtaskWithProject)
|
setSelectedTask(subtaskWithProject)
|
||||||
// Modal is already open, just update the task
|
// Modal is already open, just update the task
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load subtask:', err)
|
logger.error('Failed to load subtask:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
|||||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||||
import { SkeletonTable } from '../components/Skeleton'
|
import { SkeletonTable } from '../components/Skeleton'
|
||||||
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
|
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
// Helper to get Monday of a given week
|
// Helper to get Monday of a given week
|
||||||
function getMonday(date: Date): Date {
|
function getMonday(date: Date): Date {
|
||||||
@@ -49,8 +50,8 @@ export default function WorkloadPage() {
|
|||||||
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek), !showAllUsers)
|
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek), !showAllUsers)
|
||||||
setHeatmapData(data)
|
setHeatmapData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load workload heatmap:', err)
|
logger.error('Failed to load workload heatmap:', err)
|
||||||
setError('Failed to load workload data. Please try again.')
|
setError(t('errors.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { InternalAxiosRequestConfig, AxiosError } from 'axios'
|
import axios, { InternalAxiosRequestConfig, AxiosError } from 'axios'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
// API base URL - using legacy routes until v1 migration is complete
|
// API base URL - using legacy routes until v1 migration is complete
|
||||||
// TODO: Switch to /api/v1 when all routes are migrated
|
// TODO: Switch to /api/v1 when all routes are migrated
|
||||||
@@ -175,7 +176,7 @@ async function fetchCsrfToken(): Promise<string | null> {
|
|||||||
csrfTokenExpiry = Date.now() + CSRF_TOKEN_LIFETIME_MS
|
csrfTokenExpiry = Date.now() + CSRF_TOKEN_LIFETIME_MS
|
||||||
return csrfToken
|
return csrfToken
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch CSRF token:', error)
|
logger.error('Failed to fetch CSRF token:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
frontend/src/utils/logger.ts
Normal file
14
frontend/src/utils/logger.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Environment-aware logging utility.
|
||||||
|
* - debug/info: Only log in development mode
|
||||||
|
* - warn/error: Always log (needed for production debugging)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug: (...args: unknown[]) => isDev && console.log('[DEBUG]', ...args),
|
||||||
|
info: (...args: unknown[]) => isDev && console.info('[INFO]', ...args),
|
||||||
|
warn: (...args: unknown[]) => console.warn('[WARN]', ...args),
|
||||||
|
error: (...args: unknown[]) => console.error('[ERROR]', ...args),
|
||||||
|
}
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
## 1. Create Logging Utility
|
## 1. Create Logging Utility
|
||||||
- [ ] 1.1 Create `utils/logger.ts` with environment-aware logging
|
- [x] 1.1 Create `utils/logger.ts` with environment-aware logging
|
||||||
- [ ] 1.2 Export debug, info, warn, error functions
|
- [x] 1.2 Export debug, info, warn, error functions
|
||||||
- [ ] 1.3 Only output logs when `import.meta.env.DEV` is true
|
- [x] 1.3 Only output logs when `import.meta.env.DEV` is true
|
||||||
|
|
||||||
## 2. Cleanup Context Files
|
## 2. Cleanup Context Files
|
||||||
- [ ] 2.1 Remove/replace console statements in NotificationContext.tsx
|
- [x] 2.1 Remove/replace console statements in NotificationContext.tsx
|
||||||
- [ ] 2.2 Remove/replace console statements in ProjectSyncContext.tsx
|
- [x] 2.2 Remove/replace console statements in ProjectSyncContext.tsx
|
||||||
- [ ] 2.3 Remove/replace console statements in AuthContext.tsx
|
- [x] 2.3 Remove/replace console statements in AuthContext.tsx
|
||||||
|
|
||||||
## 3. Cleanup Component Files
|
## 3. Cleanup Component Files
|
||||||
- [ ] 3.1 Remove/replace console statements in GanttChart.tsx
|
- [x] 3.1 Remove/replace console statements in GanttChart.tsx
|
||||||
- [ ] 3.2 Remove/replace console statements in CalendarView.tsx
|
- [x] 3.2 Remove/replace console statements in CalendarView.tsx
|
||||||
- [ ] 3.3 Audit and clean remaining components
|
- [x] 3.3 Audit and clean remaining components
|
||||||
|
|
||||||
## 4. Cleanup Page Files
|
## 4. Cleanup Page Files
|
||||||
- [ ] 4.1 Remove/replace console statements in Tasks.tsx
|
- [x] 4.1 Remove/replace console statements in Tasks.tsx
|
||||||
- [ ] 4.2 Remove/replace console statements in other page files
|
- [x] 4.2 Remove/replace console statements in other page files
|
||||||
- [ ] 4.3 Audit and clean remaining pages
|
- [x] 4.3 Audit and clean remaining pages
|
||||||
|
|
||||||
## 5. Cleanup Service Files
|
## 5. Cleanup Service Files
|
||||||
- [ ] 5.1 Remove/replace console statements in api.ts
|
- [x] 5.1 Remove/replace console statements in api.ts
|
||||||
- [ ] 5.2 Audit and clean remaining services
|
- [x] 5.2 Audit and clean remaining services
|
||||||
|
|
||||||
## 6. Verification
|
## 6. Verification
|
||||||
- [ ] 6.1 Run frontend build successfully
|
- [x] 6.1 Run frontend build successfully
|
||||||
- [ ] 6.2 Verify no console.log in production build
|
- [x] 6.2 Verify no console.log in production build
|
||||||
- [ ] 6.3 Test error handling still works correctly
|
- [x] 6.3 Test error handling still works correctly
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
## 1. WeeklyReportPreview i18n
|
## 1. WeeklyReportPreview i18n
|
||||||
- [ ] 1.1 Add translation keys for report labels and status names
|
- [x] 1.1 Add translation keys for report labels and status names
|
||||||
- [ ] 1.2 Add translation keys for error and loading messages
|
- [x] 1.2 Add translation keys for error and loading messages
|
||||||
- [ ] 1.3 Replace hardcoded strings with t() calls
|
- [x] 1.3 Replace hardcoded strings with t() calls
|
||||||
- [ ] 1.4 Use dynamic locale for date formatting
|
- [x] 1.4 Use dynamic locale for date formatting
|
||||||
|
|
||||||
## 2. ReportHistory i18n
|
## 2. ReportHistory i18n
|
||||||
- [ ] 2.1 Add translation keys for history labels
|
- [x] 2.1 Add translation keys for history labels
|
||||||
- [ ] 2.2 Add translation keys for loading/empty states
|
- [x] 2.2 Add translation keys for loading/empty states
|
||||||
- [ ] 2.3 Replace hardcoded strings with t() calls
|
- [x] 2.3 Replace hardcoded strings with t() calls
|
||||||
|
|
||||||
## 3. AuditPage Modal i18n
|
## 3. AuditPage Modal i18n
|
||||||
- [ ] 3.1 Add translation keys for detail modal labels
|
- [x] 3.1 Add translation keys for detail modal labels
|
||||||
- [ ] 3.2 Add translation keys for verification modal
|
- [x] 3.2 Add translation keys for verification modal
|
||||||
- [ ] 3.3 Add translation keys for field names and status messages
|
- [x] 3.3 Add translation keys for field names and status messages
|
||||||
- [ ] 3.4 Replace hardcoded strings with t() calls
|
- [x] 3.4 Replace hardcoded strings with t() calls
|
||||||
|
|
||||||
## 4. WorkloadPage i18n
|
## 4. WorkloadPage i18n
|
||||||
- [ ] 4.1 Replace hardcoded error message with t() call
|
- [x] 4.1 Replace hardcoded error message with t() call
|
||||||
|
|
||||||
## 5. Update Locale Files
|
## 5. Update Locale Files
|
||||||
- [ ] 5.1 Add all new keys to en/common.json
|
- [x] 5.1 Add all new keys to en/common.json
|
||||||
- [ ] 5.2 Add all new keys to zh-TW/common.json
|
- [x] 5.2 Add all new keys to zh-TW/common.json
|
||||||
- [ ] 5.3 Verify key structure consistency
|
- [x] 5.3 Verify key structure consistency
|
||||||
|
|
||||||
## 6. Verification
|
## 6. Verification
|
||||||
- [ ] 6.1 Test English locale displays correctly
|
- [x] 6.1 Test English locale displays correctly
|
||||||
- [ ] 6.2 Test Chinese locale displays correctly
|
- [x] 6.2 Test Chinese locale displays correctly
|
||||||
- [ ] 6.3 Verify no untranslated strings remain
|
- [x] 6.3 Verify no untranslated strings remain
|
||||||
- [ ] 6.4 Frontend builds successfully
|
- [x] 6.4 Frontend builds successfully
|
||||||
|
|||||||
Reference in New Issue
Block a user