diff --git a/.gitignore b/.gitignore index 968b10a..0e237d2 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ opencodetmp # Spec Workflow MCP .spec-workflow +.claude-context diff --git a/package-lock.json b/package-lock.json index 23e458b..46ea890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pimzino/spec-workflow-mcp", - "version": "2.1.6", + "version": "2.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pimzino/spec-workflow-mcp", - "version": "2.1.6", + "version": "2.1.7", "license": "GPL-3.0", "dependencies": { "@dnd-kit/core": "^6.1.0", diff --git a/src/core/__tests__/security-utils.test.ts b/src/core/__tests__/security-utils.test.ts index 627e637..fc85457 100644 --- a/src/core/__tests__/security-utils.test.ts +++ b/src/core/__tests__/security-utils.test.ts @@ -5,7 +5,9 @@ import { tmpdir } from 'os'; import { isLocalhostAddress, getSecurityConfig, + generateAllowedOrigins, DEFAULT_SECURITY_CONFIG, + VITE_DEV_PORT, RateLimiter, AuditLogger, AuditLogEntry, @@ -66,10 +68,61 @@ describe('security-utils', () => { }); }); + describe('generateAllowedOrigins', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('should include dashboard port origins', () => { + const origins = generateAllowedOrigins(5000); + expect(origins).toContain('http://localhost:5000'); + expect(origins).toContain('http://127.0.0.1:5000'); + }); + + it('should include Vite dev port in non-production environments', () => { + process.env.NODE_ENV = 'development'; + const origins = generateAllowedOrigins(5000); + expect(origins).toContain(`http://localhost:${VITE_DEV_PORT}`); + expect(origins).toContain(`http://127.0.0.1:${VITE_DEV_PORT}`); + }); + + it('should include Vite dev port when NODE_ENV is undefined', () => { + // This is the key test - when NODE_ENV is not set, we should still include Vite dev port + // because we check !== 'production' rather than === 'development' + delete process.env.NODE_ENV; + const origins = generateAllowedOrigins(5000); + expect(origins).toContain(`http://localhost:${VITE_DEV_PORT}`); + expect(origins).toContain(`http://127.0.0.1:${VITE_DEV_PORT}`); + }); + + it('should NOT include Vite dev port in production', () => { + process.env.NODE_ENV = 'production'; + const origins = generateAllowedOrigins(5000); + expect(origins).not.toContain(`http://localhost:${VITE_DEV_PORT}`); + expect(origins).not.toContain(`http://127.0.0.1:${VITE_DEV_PORT}`); + }); + + it('should use custom port for dashboard origins', () => { + const origins = generateAllowedOrigins(3000); + expect(origins).toContain('http://localhost:3000'); + expect(origins).toContain('http://127.0.0.1:3000'); + }); + }); + describe('getSecurityConfig', () => { it('should return defaults when no config provided', () => { const config = getSecurityConfig(); - expect(config).toEqual(DEFAULT_SECURITY_CONFIG); + // In non-production, dynamic origins include Vite dev server ports + expect(config.rateLimitEnabled).toBe(DEFAULT_SECURITY_CONFIG.rateLimitEnabled); + expect(config.rateLimitPerMinute).toBe(DEFAULT_SECURITY_CONFIG.rateLimitPerMinute); + expect(config.auditLogEnabled).toBe(DEFAULT_SECURITY_CONFIG.auditLogEnabled); + expect(config.auditLogRetentionDays).toBe(DEFAULT_SECURITY_CONFIG.auditLogRetentionDays); + expect(config.corsEnabled).toBe(DEFAULT_SECURITY_CONFIG.corsEnabled); + // allowedOrigins includes default port + Vite dev port (5173) in non-production + expect(config.allowedOrigins).toContain('http://localhost:5000'); + expect(config.allowedOrigins).toContain('http://127.0.0.1:5000'); }); it('should merge user config with defaults', () => { diff --git a/src/core/security-utils.ts b/src/core/security-utils.ts index 29459d6..f8680fe 100644 --- a/src/core/security-utils.ts +++ b/src/core/security-utils.ts @@ -22,13 +22,25 @@ export const DEFAULT_SECURITY_CONFIG: SecurityConfig = { allowedOrigins: [`http://localhost:${DEFAULT_DASHBOARD_PORT}`, `http://127.0.0.1:${DEFAULT_DASHBOARD_PORT}`] }; +// Default Vite dev server port (used when running frontend in dev mode) +export const VITE_DEV_PORT = 5173; + /** * Generate allowed origins for CORS based on the actual port * @param port - The port the dashboard is running on * @returns Array of allowed origin URLs */ export function generateAllowedOrigins(port: number): string[] { - return [`http://localhost:${port}`, `http://127.0.0.1:${port}`]; + const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`]; + + // In non-production environments, also allow Vite dev server origin (port 5173) + // The Vite proxy forwards requests but preserves the Origin header + // Use !== 'production' to be permissive by default for local dev tools + if (process.env.NODE_ENV !== 'production') { + origins.push(`http://localhost:${VITE_DEV_PORT}`, `http://127.0.0.1:${VITE_DEV_PORT}`); + } + + return origins; } /** @@ -254,6 +266,14 @@ export class AuditLogger { export function createSecurityHeadersMiddleware(port?: number) { const actualPort = port || DEFAULT_DASHBOARD_PORT; + // Build connect-src directive with WebSocket endpoints + let connectSrc = `'self' ws://localhost:${actualPort} ws://127.0.0.1:${actualPort}`; + + // In non-production environments, also allow Vite dev server connections + if (process.env.NODE_ENV !== 'production') { + connectSrc += ` ws://localhost:${VITE_DEV_PORT} ws://127.0.0.1:${VITE_DEV_PORT}`; + } + return async (request: FastifyRequest, reply: FastifyReply) => { // Add security headers reply.header('X-Content-Type-Options', 'nosniff'); // Prevent MIME type sniffing @@ -266,7 +286,7 @@ export function createSecurityHeadersMiddleware(port?: number) { // connect-src allows WebSocket connections to the dashboard on the actual port reply.header( 'Content-Security-Policy', - `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self' ws://localhost:${actualPort} ws://127.0.0.1:${actualPort};` + `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src ${connectSrc};` ); }; } diff --git a/src/dashboard/approval-storage.ts b/src/dashboard/approval-storage.ts index e2edaab..deab830 100644 --- a/src/dashboard/approval-storage.ts +++ b/src/dashboard/approval-storage.ts @@ -303,6 +303,32 @@ export class ApprovalStorage extends EventEmitter { await fs.writeFile(filePath, JSON.stringify(approval, null, 2), 'utf-8'); } + /** + * Revert an approval back to pending status, clearing response and timestamp + * Used for undo operations after batch approvals/rejections + */ + async revertToPending(id: string): Promise { + const approval = await this.getApproval(id); + if (!approval) { + throw new Error(`Approval ${id} not found`); + } + + // Revert to pending state + approval.status = 'pending'; + + // Clear response fields + delete approval.response; + delete approval.respondedAt; + delete approval.annotations; + delete approval.comments; + + const filePath = await this.findApprovalPath(id); + if (!filePath) { + throw new Error(`Approval ${id} file not found`); + } + await fs.writeFile(filePath, JSON.stringify(approval, null, 2), 'utf-8'); + } + async createRevision( originalId: string, newContent: string, diff --git a/src/dashboard/multi-server.ts b/src/dashboard/multi-server.ts index 3aa7e40..1ce63ff 100644 --- a/src/dashboard/multi-server.ts +++ b/src/dashboard/multi-server.ts @@ -660,11 +660,113 @@ export class MultiProjectDashboardServer { // Approval actions (approve, reject, needs-revision) this.app.post('/api/projects/:projectId/approvals/:id/:action', async (request, reply) => { - const { projectId, id, action } = request.params as { projectId: string; id: string; action: string }; - const { response, annotations, comments } = request.body as { - response: string; - annotations?: string; - comments?: any[]; + try { + const { projectId, id, action } = request.params as { projectId: string; id: string; action: string }; + + const { response, annotations, comments } = (request.body || {}) as { + response: string; + annotations?: string; + comments?: any[]; + }; + + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const validActions = ['approve', 'reject', 'needs-revision']; + if (!validActions.includes(action)) { + return reply.code(400).send({ error: 'Invalid action' }); + } + + // Convert action name to status value + const actionToStatus: Record = { + 'approve': 'approved', + 'reject': 'rejected', + 'needs-revision': 'needs-revision' + }; + const status = actionToStatus[action]; + + await project.approvalStorage.updateApproval(id, status, response, annotations, comments); + return { success: true }; + } catch (error: any) { + return reply.code(500).send({ error: error.message || 'Internal server error' }); + } + }); + + // Undo batch operations - revert items back to pending + // IMPORTANT: This route MUST be defined BEFORE the /batch/:action route + // because Fastify matches routes in order of registration + this.app.post('/api/projects/:projectId/approvals/batch/undo', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + const { ids } = request.body as { ids: string[] }; + + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Validate ids array + if (!Array.isArray(ids) || ids.length === 0) { + return reply.code(400).send({ error: 'ids must be a non-empty array' }); + } + + // Batch size limit + const BATCH_SIZE_LIMIT = 100; + if (ids.length > BATCH_SIZE_LIMIT) { + return reply.code(400).send({ + error: `Batch size exceeds limit. Maximum ${BATCH_SIZE_LIMIT} items allowed.` + }); + } + + // Validate ID format + const idPattern = /^[a-zA-Z0-9_-]+$/; + const invalidIds = ids.filter(id => !idPattern.test(id)); + if (invalidIds.length > 0) { + return reply.code(400).send({ + error: `Invalid ID format: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}` + }); + } + + // Process all undo operations with continue-on-error + const results: { succeeded: string[]; failed: Array<{ id: string; error: string }> } = { + succeeded: [], + failed: [] + }; + + for (const id of ids) { + try { + // Revert to pending status, clear response and respondedAt + await project.approvalStorage.revertToPending(id); + results.succeeded.push(id); + } catch (error: any) { + results.failed.push({ id, error: error.message }); + } + } + + // Broadcast WebSocket update for successful undos + if (results.succeeded.length > 0) { + this.broadcastToProject(projectId, { + type: 'batch-approval-undo', + ids: results.succeeded, + count: results.succeeded.length + }); + } + + return { + success: results.failed.length === 0, + total: ids.length, + succeeded: results.succeeded, + failed: results.failed + }; + }); + + // Batch approval actions (approve, reject only - no batch needs-revision) + this.app.post('/api/projects/:projectId/approvals/batch/:action', async (request, reply) => { + const { projectId, action } = request.params as { projectId: string; action: string }; + const { ids, response } = request.body as { + ids: string[]; + response?: string; }; const project = this.projectManager.getProject(projectId); @@ -672,25 +774,76 @@ export class MultiProjectDashboardServer { return reply.code(404).send({ error: 'Project not found' }); } - const validActions = ['approve', 'reject', 'needs-revision']; - if (!validActions.includes(action)) { - return reply.code(400).send({ error: 'Invalid action' }); + // Only allow approve and reject for batch operations (UX recommendation) + const validBatchActions = ['approve', 'reject']; + if (!validBatchActions.includes(action)) { + return reply.code(400).send({ + error: 'Invalid batch action. Only "approve" and "reject" are allowed for batch operations.' + }); + } + + // Validate ids array + if (!Array.isArray(ids) || ids.length === 0) { + return reply.code(400).send({ error: 'ids must be a non-empty array' }); + } + + // Batch size limit (PE recommendation) + const BATCH_SIZE_LIMIT = 100; + if (ids.length > BATCH_SIZE_LIMIT) { + return reply.code(400).send({ + error: `Batch size exceeds limit. Maximum ${BATCH_SIZE_LIMIT} items allowed.` + }); + } + + // Validate ID format (PE recommendation - alphanumeric with hyphens/underscores) + const idPattern = /^[a-zA-Z0-9_-]+$/; + const invalidIds = ids.filter(id => !idPattern.test(id)); + if (invalidIds.length > 0) { + return reply.code(400).send({ + error: `Invalid ID format: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}` + }); } - // Convert action name to status value - const actionToStatus: Record = { + const actionToStatus: Record = { 'approve': 'approved', - 'reject': 'rejected', - 'needs-revision': 'needs-revision' + 'reject': 'rejected' }; const status = actionToStatus[action]; + const batchResponse = response || `Batch ${action}d`; - try { - await project.approvalStorage.updateApproval(id, status, response, annotations, comments); - return { success: true }; - } catch (error: any) { - return reply.code(404).send({ error: error.message }); + // Process all approvals with continue-on-error (PE recommendation) + const results: { succeeded: string[]; failed: Array<{ id: string; error: string }> } = { + succeeded: [], + failed: [] + }; + + // Debounce WebSocket broadcasts - collect all updates first (use results.succeeded) + + for (const id of ids) { + try { + await project.approvalStorage.updateApproval(id, status, batchResponse); + results.succeeded.push(id); + } catch (error: any) { + results.failed.push({ id, error: error.message }); + } } + + // Single consolidated WebSocket broadcast for all successful updates + if (results.succeeded.length > 0) { + this.broadcastToProject(projectId, { + type: 'batch-approval-update', + action: action, + ids: results.succeeded, + count: results.succeeded.length + }); + } + + return { + success: results.failed.length === 0, + total: ids.length, + succeeded: results.succeeded, + failed: results.failed + }; }); // Get all snapshots for an approval diff --git a/src/dashboard_frontend/src/locales/ar.json b/src/dashboard_frontend/src/locales/ar.json index ad7016a..e200465 100644 --- a/src/dashboard_frontend/src/locales/ar.json +++ b/src/dashboard_frontend/src/locales/ar.json @@ -362,7 +362,8 @@ "quickReject": "رفض سريع", "reject": "رفض", "requestRevisions": "طلب مراجعات", - "revisions": "مراجعات" + "revisions": "مراجعات", + "useBatchActions": "استخدم الإجراءات المجمعة للعناصر المتعددة" }, "tooltips": { "goToAnnotations": "الذهاب للتعليقات التوضيحية", @@ -384,6 +385,11 @@ "placeholder": "يرجى تقديم تغذية راجعة توضح سبب الرفض...", "submit": "رفض" }, + "batchReject": { + "title": "رفض {{count}} عنصر", + "placeholder": "أدخل سبب الرفض الذي سيتم تطبيقه على جميع العناصر المحددة (مثال: 'يتطلب مراجعة المهندس الرئيسي')...", + "submit": "رفض الكل" + }, "approvalWarning": { "title": "لا يمكن الموافقة", "message": "لا يمكن الموافقة عند وجود تعليقات. استخدم \"طلب مراجعات\" لإرسال التغذية الراجعة." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "لم تُضاف تعليقات", "noCommentsMessage": "يرجى إضافة تعليق واحد على الأقل قبل طلب المراجعات." + }, + "selection": { + "enterMode": "تحديد العناصر", + "exitMode": "إلغاء التحديد", + "select": "تحديد", + "selectAll": "تحديد الكل", + "selectedCount_one": "{{count}} محدد", + "selectedCount_other": "{{count}} محددة", + "selectItem": "تحديد {{title}}", + "approveSelected": "الموافقة على المحدد", + "rejectSelected": "رفض المحدد" + }, + "batchConfirm": { + "title": "تأكيد الإجراء المجمع", + "message": "أنت على وشك {{action}} {{count}} عناصر. لا يمكن التراجع عن هذا الإجراء. هل أنت متأكد أنك تريد المتابعة؟", + "confirm": "نعم، تابع" + }, + "batchResult": { + "title": "تمت معالجة {{succeeded}} من {{total}} عنصر", + "failedCount_one": "فشل {{count}} عنصر", + "failedCount_other": "فشل {{count}} عناصر", + "undo": "تراجع" + }, + "notifications": { + "approved": "تمت الموافقة على \"{{title}}\"", + "approveFailed": "فشل في الموافقة على \"{{title}}\"", + "rejected": "تم رفض \"{{title}}\"", + "rejectFailed": "فشل في رفض \"{{title}}\"", + "revisionRequested": "تم طلب مراجعات لـ \"{{title}}\"", + "revisionFailed": "فشل في طلب مراجعات لـ \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/de.json b/src/dashboard_frontend/src/locales/de.json index 61c45ca..272a6ca 100644 --- a/src/dashboard_frontend/src/locales/de.json +++ b/src/dashboard_frontend/src/locales/de.json @@ -362,7 +362,8 @@ "quickReject": "Schnell ablehnen", "reject": "Ablehnen", "requestRevisions": "Überarbeitungen anfordern", - "revisions": "Überarbeitungen" + "revisions": "Überarbeitungen", + "useBatchActions": "Batch-Aktionen für mehrere Elemente verwenden" }, "tooltips": { "goToAnnotations": "Zu Annotationen gehen", @@ -384,6 +385,11 @@ "placeholder": "Bitte geben Sie Feedback an warum dies abgelehnt wird...", "submit": "Ablehnen" }, + "batchReject": { + "title": "{{count}} Elemente ablehnen", + "placeholder": "Geben Sie einen Ablehnungsgrund ein, der für alle ausgewählten Elemente gilt (z.B. 'Benötigt Überprüfung durch leitenden Ingenieur')...", + "submit": "Alle ablehnen" + }, "approvalWarning": { "title": "Kann nicht genehmigen", "message": "Kann nicht genehmigen wenn Kommentare existieren. Verwenden Sie \"Überarbeitungen anfordern\" um Feedback zu senden." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "Keine Kommentare hinzugefügt", "noCommentsMessage": "Bitte fügen Sie mindestens einen Kommentar hinzu bevor Sie Überarbeitungen anfordern." + }, + "selection": { + "enterMode": "Elemente Auswählen", + "exitMode": "Auswahl Abbrechen", + "select": "Auswählen", + "selectAll": "Alle Auswählen", + "selectedCount_one": "{{count}} ausgewählt", + "selectedCount_other": "{{count}} ausgewählt", + "selectItem": "{{title}} auswählen", + "approveSelected": "Ausgewählte Genehmigen", + "rejectSelected": "Ausgewählte Ablehnen" + }, + "batchConfirm": { + "title": "Massenaktion Bestätigen", + "message": "Sie sind dabei, {{count}} Elemente zu {{action}}. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher, dass Sie fortfahren möchten?", + "confirm": "Ja, fortfahren" + }, + "batchResult": { + "title": "{{succeeded}} von {{total}} Elementen verarbeitet", + "failedCount_one": "{{count}} Element fehlgeschlagen", + "failedCount_other": "{{count}} Elemente fehlgeschlagen", + "undo": "Rückgängig" + }, + "notifications": { + "approved": "\"{{title}}\" wurde genehmigt", + "approveFailed": "Genehmigung von \"{{title}}\" fehlgeschlagen", + "rejected": "\"{{title}}\" wurde abgelehnt", + "rejectFailed": "Ablehnung von \"{{title}}\" fehlgeschlagen", + "revisionRequested": "Überarbeitung für \"{{title}}\" angefordert", + "revisionFailed": "Anforderung der Überarbeitung für \"{{title}}\" fehlgeschlagen" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/en.json b/src/dashboard_frontend/src/locales/en.json index e30fbba..91f5869 100644 --- a/src/dashboard_frontend/src/locales/en.json +++ b/src/dashboard_frontend/src/locales/en.json @@ -362,7 +362,8 @@ "quickReject": "Quick Reject", "reject": "Reject", "requestRevisions": "Request Revisions", - "revisions": "Revisions" + "revisions": "Revisions", + "useBatchActions": "Use batch actions for multiple items" }, "tooltips": { "goToAnnotations": "Go to annotations", @@ -384,6 +385,11 @@ "placeholder": "Please provide feedback explaining why this is being rejected...", "submit": "Reject" }, + "batchReject": { + "title": "Reject {{count}} Items", + "placeholder": "Enter a rejection reason that will apply to all selected items (e.g., 'Needs Principal Engineer review')...", + "submit": "Reject All" + }, "approvalWarning": { "title": "Cannot Approve", "message": "Cannot approve when comments exist. Use \"Request Revisions\" to send feedback." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "No Comments Added", "noCommentsMessage": "Please add at least one comment before requesting revisions." + }, + "selection": { + "enterMode": "Select Items", + "exitMode": "Cancel Selection", + "select": "Select", + "selectAll": "Select All", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", + "selectItem": "Select {{title}}", + "approveSelected": "Approve Selected", + "rejectSelected": "Reject Selected" + }, + "batchConfirm": { + "title": "Confirm Batch Action", + "message": "You are about to {{action}} {{count}} items. This action cannot be undone. Are you sure you want to continue?", + "confirm": "Yes, proceed" + }, + "batchResult": { + "title": "{{succeeded}} of {{total}} items processed", + "failedCount_one": "{{count}} item failed", + "failedCount_other": "{{count}} items failed", + "undo": "Undo" + }, + "notifications": { + "approved": "\"{{title}}\" has been approved", + "approveFailed": "Failed to approve \"{{title}}\"", + "rejected": "\"{{title}}\" has been rejected", + "rejectFailed": "Failed to reject \"{{title}}\"", + "revisionRequested": "Revisions requested for \"{{title}}\"", + "revisionFailed": "Failed to request revisions for \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/es.json b/src/dashboard_frontend/src/locales/es.json index ac766e2..833b247 100644 --- a/src/dashboard_frontend/src/locales/es.json +++ b/src/dashboard_frontend/src/locales/es.json @@ -362,7 +362,8 @@ "quickReject": "Rechazo Rápido", "reject": "Rechazar", "requestRevisions": "Solicitar Revisiones", - "revisions": "Revisiones" + "revisions": "Revisiones", + "useBatchActions": "Usar acciones por lotes para múltiples elementos" }, "tooltips": { "goToAnnotations": "Ir a anotaciones", @@ -384,6 +385,11 @@ "placeholder": "Por favor proporciona comentarios explicando por qué esto está siendo rechazado...", "submit": "Rechazar" }, + "batchReject": { + "title": "Rechazar {{count}} elementos", + "placeholder": "Ingrese un motivo de rechazo que se aplicará a todos los elementos seleccionados (ej.: 'Requiere revisión del Ingeniero Principal')...", + "submit": "Rechazar todo" + }, "approvalWarning": { "title": "No Se Puede Aprobar", "message": "No se puede aprobar cuando existen comentarios. Usa \"Solicitar Revisiones\" para enviar comentarios." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "No Se Agregaron Comentarios", "noCommentsMessage": "Por favor agrega al menos un comentario antes de solicitar revisiones." + }, + "selection": { + "enterMode": "Seleccionar Elementos", + "exitMode": "Cancelar Selección", + "select": "Seleccionar", + "selectAll": "Seleccionar Todo", + "selectedCount_one": "{{count}} seleccionado", + "selectedCount_other": "{{count}} seleccionados", + "selectItem": "Seleccionar {{title}}", + "approveSelected": "Aprobar Seleccionados", + "rejectSelected": "Rechazar Seleccionados" + }, + "batchConfirm": { + "title": "Confirmar Acción en Lote", + "message": "Está a punto de {{action}} {{count}} elementos. Esta acción no se puede deshacer. ¿Está seguro de que desea continuar?", + "confirm": "Sí, continuar" + }, + "batchResult": { + "title": "{{succeeded}} de {{total}} elementos procesados", + "failedCount_one": "{{count}} elemento falló", + "failedCount_other": "{{count}} elementos fallaron", + "undo": "Deshacer" + }, + "notifications": { + "approved": "\"{{title}}\" ha sido aprobado", + "approveFailed": "Error al aprobar \"{{title}}\"", + "rejected": "\"{{title}}\" ha sido rechazado", + "rejectFailed": "Error al rechazar \"{{title}}\"", + "revisionRequested": "Revisiones solicitadas para \"{{title}}\"", + "revisionFailed": "Error al solicitar revisiones para \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/fr.json b/src/dashboard_frontend/src/locales/fr.json index c7afd67..fe34545 100644 --- a/src/dashboard_frontend/src/locales/fr.json +++ b/src/dashboard_frontend/src/locales/fr.json @@ -362,7 +362,8 @@ "quickReject": "Rejet Rapide", "reject": "Rejeter", "requestRevisions": "Demander des Révisions", - "revisions": "Révisions" + "revisions": "Révisions", + "useBatchActions": "Utiliser les actions par lot pour plusieurs éléments" }, "tooltips": { "goToAnnotations": "Aller aux annotations", @@ -384,6 +385,11 @@ "placeholder": "Veuillez fournir des commentaires expliquant pourquoi ceci est rejeté...", "submit": "Rejeter" }, + "batchReject": { + "title": "Rejeter {{count}} éléments", + "placeholder": "Entrez un motif de rejet qui s'appliquera à tous les éléments sélectionnés (ex. : 'Nécessite une révision de l'ingénieur principal')...", + "submit": "Tout rejeter" + }, "approvalWarning": { "title": "Impossible d'Approuver", "message": "Impossible d'approuver lorsque des commentaires existent. Utilisez \"Demander des Révisions\" pour envoyer des commentaires." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "Aucun Commentaire Ajouté", "noCommentsMessage": "Veuillez ajouter au moins un commentaire avant de demander des révisions." + }, + "selection": { + "enterMode": "Sélectionner des Éléments", + "exitMode": "Annuler la Sélection", + "select": "Sélectionner", + "selectAll": "Tout Sélectionner", + "selectedCount_one": "{{count}} sélectionné", + "selectedCount_other": "{{count}} sélectionnés", + "selectItem": "Sélectionner {{title}}", + "approveSelected": "Approuver la Sélection", + "rejectSelected": "Rejeter la Sélection" + }, + "batchConfirm": { + "title": "Confirmer l'Action Groupée", + "message": "Vous êtes sur le point de {{action}} {{count}} éléments. Cette action ne peut pas être annulée. Êtes-vous sûr de vouloir continuer ?", + "confirm": "Oui, continuer" + }, + "batchResult": { + "title": "{{succeeded}} sur {{total}} éléments traités", + "failedCount_one": "{{count}} élément a échoué", + "failedCount_other": "{{count}} éléments ont échoué", + "undo": "Annuler" + }, + "notifications": { + "approved": "\"{{title}}\" a été approuvé", + "approveFailed": "Échec de l'approbation de \"{{title}}\"", + "rejected": "\"{{title}}\" a été rejeté", + "rejectFailed": "Échec du rejet de \"{{title}}\"", + "revisionRequested": "Révisions demandées pour \"{{title}}\"", + "revisionFailed": "Échec de la demande de révisions pour \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/it.json b/src/dashboard_frontend/src/locales/it.json index 1524246..f469599 100644 --- a/src/dashboard_frontend/src/locales/it.json +++ b/src/dashboard_frontend/src/locales/it.json @@ -362,7 +362,8 @@ "quickReject": "Rifiuta Veloce", "reject": "Rifiuta", "requestRevisions": "Richiedi Revisioni", - "revisions": "Revisioni" + "revisions": "Revisioni", + "useBatchActions": "Usa azioni batch per più elementi" }, "tooltips": { "goToAnnotations": "Vai alle annotazioni", @@ -384,6 +385,11 @@ "placeholder": "Fornisci feedback spiegando perché questo viene rifiutato...", "submit": "Rifiuta" }, + "batchReject": { + "title": "Rifiuta {{count}} elementi", + "placeholder": "Inserisci un motivo di rifiuto che verrà applicato a tutti gli elementi selezionati (es. 'Richiede revisione dell'Ingegnere Principale')...", + "submit": "Rifiuta tutto" + }, "approvalWarning": { "title": "Impossibile Approvare", "message": "Impossibile approvare quando esistono commenti. Usa \"Richiedi Revisioni\" per inviare feedback." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "Nessun Commento Aggiunto", "noCommentsMessage": "Aggiungi almeno un commento prima di richiedere revisioni." + }, + "selection": { + "enterMode": "Seleziona Elementi", + "exitMode": "Annulla Selezione", + "select": "Seleziona", + "selectAll": "Seleziona Tutto", + "selectedCount_one": "{{count}} selezionato", + "selectedCount_other": "{{count}} selezionati", + "selectItem": "Seleziona {{title}}", + "approveSelected": "Approva Selezionati", + "rejectSelected": "Rifiuta Selezionati" + }, + "batchConfirm": { + "title": "Conferma Azione di Massa", + "message": "Stai per {{action}} {{count}} elementi. Questa azione non può essere annullata. Sei sicuro di voler continuare?", + "confirm": "Sì, procedi" + }, + "batchResult": { + "title": "{{succeeded}} di {{total}} elementi elaborati", + "failedCount_one": "{{count}} elemento fallito", + "failedCount_other": "{{count}} elementi falliti", + "undo": "Annulla" + }, + "notifications": { + "approved": "\"{{title}}\" è stato approvato", + "approveFailed": "Impossibile approvare \"{{title}}\"", + "rejected": "\"{{title}}\" è stato rifiutato", + "rejectFailed": "Impossibile rifiutare \"{{title}}\"", + "revisionRequested": "Revisioni richieste per \"{{title}}\"", + "revisionFailed": "Impossibile richiedere revisioni per \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/ja.json b/src/dashboard_frontend/src/locales/ja.json index 500ec73..cec8d4c 100644 --- a/src/dashboard_frontend/src/locales/ja.json +++ b/src/dashboard_frontend/src/locales/ja.json @@ -362,7 +362,8 @@ "quickReject": "即時却下", "reject": "却下", "requestRevisions": "修正を依頼", - "revisions": "修正" + "revisions": "修正", + "useBatchActions": "複数アイテムにはバッチアクションを使用" }, "tooltips": { "goToAnnotations": "注釈に移動", @@ -384,6 +385,11 @@ "placeholder": "却下する理由やフィードバックを入力してください...", "submit": "却下" }, + "batchReject": { + "title": "{{count}}件のアイテムを却下", + "placeholder": "選択したすべてのアイテムに適用される却下理由を入力してください(例:'主任エンジニアのレビューが必要')...", + "submit": "すべて却下" + }, "approvalWarning": { "title": "承認できません", "message": "コメントが存在する場合は承認できません。\"修正を依頼\"を使用してフィードバックを送信してください。" @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "コメントが追加されていません", "noCommentsMessage": "修正を依頼する前に少なくとも1つコメントを追加してください。" + }, + "selection": { + "enterMode": "項目を選択", + "exitMode": "選択をキャンセル", + "select": "選択", + "selectAll": "すべて選択", + "selectedCount_one": "{{count}}件選択", + "selectedCount_other": "{{count}}件選択", + "selectItem": "{{title}}を選択", + "approveSelected": "選択項目を承認", + "rejectSelected": "選択項目を却下" + }, + "batchConfirm": { + "title": "一括操作の確認", + "message": "{{count}}件の項目を{{action}}しようとしています。この操作は取り消せません。続行しますか?", + "confirm": "はい、続行します" + }, + "batchResult": { + "title": "{{total}}件中{{succeeded}}件処理完了", + "failedCount_one": "{{count}}件失敗", + "failedCount_other": "{{count}}件失敗", + "undo": "元に戻す" + }, + "notifications": { + "approved": "「{{title}}」が承認されました", + "approveFailed": "「{{title}}」の承認に失敗しました", + "rejected": "「{{title}}」が却下されました", + "rejectFailed": "「{{title}}」の却下に失敗しました", + "revisionRequested": "「{{title}}」の修正が要求されました", + "revisionFailed": "「{{title}}」の修正要求に失敗しました" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/ko.json b/src/dashboard_frontend/src/locales/ko.json index 44b26db..dc7e392 100644 --- a/src/dashboard_frontend/src/locales/ko.json +++ b/src/dashboard_frontend/src/locales/ko.json @@ -362,7 +362,8 @@ "quickReject": "빠른 거부", "reject": "거부", "requestRevisions": "수정 요청", - "revisions": "수정" + "revisions": "수정", + "useBatchActions": "여러 항목에는 일괄 작업 사용" }, "tooltips": { "goToAnnotations": "주석으로 이동", @@ -384,6 +385,11 @@ "placeholder": "이것이 거부되는 이유에 대한 피드백을 제공해 주세요...", "submit": "거부" }, + "batchReject": { + "title": "{{count}}개 항목 거부", + "placeholder": "선택한 모든 항목에 적용될 거부 사유를 입력하세요 (예: '수석 엔지니어 검토 필요')...", + "submit": "모두 거부" + }, "approvalWarning": { "title": "승인할 수 없음", "message": "코멘트가 있을 때는 승인할 수 없습니다. 피드백을 보내려면 \"수정 요청\"을 사용하세요." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "추가된 코멘트 없음", "noCommentsMessage": "수정을 요청하기 전에 최소 하나의 코멘트를 추가해 주세요." + }, + "selection": { + "enterMode": "항목 선택", + "exitMode": "선택 취소", + "select": "선택", + "selectAll": "모두 선택", + "selectedCount_one": "{{count}}개 선택됨", + "selectedCount_other": "{{count}}개 선택됨", + "selectItem": "{{title}} 선택", + "approveSelected": "선택 항목 승인", + "rejectSelected": "선택 항목 거부" + }, + "batchConfirm": { + "title": "일괄 작업 확인", + "message": "{{count}}개 항목을 {{action}}하려고 합니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?", + "confirm": "예, 계속합니다" + }, + "batchResult": { + "title": "{{total}}개 중 {{succeeded}}개 처리 완료", + "failedCount_one": "{{count}}개 실패", + "failedCount_other": "{{count}}개 실패", + "undo": "실행 취소" + }, + "notifications": { + "approved": "\"{{title}}\"이(가) 승인되었습니다", + "approveFailed": "\"{{title}}\" 승인에 실패했습니다", + "rejected": "\"{{title}}\"이(가) 거부되었습니다", + "rejectFailed": "\"{{title}}\" 거부에 실패했습니다", + "revisionRequested": "\"{{title}}\"에 대한 수정이 요청되었습니다", + "revisionFailed": "\"{{title}}\" 수정 요청에 실패했습니다" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/pt.json b/src/dashboard_frontend/src/locales/pt.json index 8dbe88e..1db892f 100644 --- a/src/dashboard_frontend/src/locales/pt.json +++ b/src/dashboard_frontend/src/locales/pt.json @@ -362,7 +362,8 @@ "quickReject": "Rejeição Rápida", "reject": "Rejeitar", "requestRevisions": "Solicitar Revisões", - "revisions": "Revisões" + "revisions": "Revisões", + "useBatchActions": "Usar ações em lote para vários itens" }, "tooltips": { "goToAnnotations": "Ir para anotações", @@ -384,6 +385,11 @@ "placeholder": "Por favor, forneça feedback explicando por que isso está sendo rejeitado...", "submit": "Rejeitar" }, + "batchReject": { + "title": "Rejeitar {{count}} itens", + "placeholder": "Digite um motivo de rejeição que será aplicado a todos os itens selecionados (ex.: 'Necessita revisão do Engenheiro Principal')...", + "submit": "Rejeitar tudo" + }, "approvalWarning": { "title": "Não É Possível Aprovar", "message": "Não é possível aprovar quando existem comentários. Use \"Solicitar Revisões\" para enviar feedback." @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "Nenhum Comentário Adicionado", "noCommentsMessage": "Por favor, adicione pelo menos um comentário antes de solicitar revisões." + }, + "selection": { + "enterMode": "Selecionar Itens", + "exitMode": "Cancelar Seleção", + "select": "Selecionar", + "selectAll": "Selecionar Tudo", + "selectedCount_one": "{{count}} selecionado", + "selectedCount_other": "{{count}} selecionados", + "selectItem": "Selecionar {{title}}", + "approveSelected": "Aprovar Selecionados", + "rejectSelected": "Rejeitar Selecionados" + }, + "batchConfirm": { + "title": "Confirmar Ação em Lote", + "message": "Você está prestes a {{action}} {{count}} itens. Esta ação não pode ser desfeita. Tem certeza de que deseja continuar?", + "confirm": "Sim, continuar" + }, + "batchResult": { + "title": "{{succeeded}} de {{total}} itens processados", + "failedCount_one": "{{count}} item falhou", + "failedCount_other": "{{count}} itens falharam", + "undo": "Desfazer" + }, + "notifications": { + "approved": "\"{{title}}\" foi aprovado", + "approveFailed": "Falha ao aprovar \"{{title}}\"", + "rejected": "\"{{title}}\" foi rejeitado", + "rejectFailed": "Falha ao rejeitar \"{{title}}\"", + "revisionRequested": "Revisões solicitadas para \"{{title}}\"", + "revisionFailed": "Falha ao solicitar revisões para \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/ru.json b/src/dashboard_frontend/src/locales/ru.json index 2f33e39..b57c35d 100644 --- a/src/dashboard_frontend/src/locales/ru.json +++ b/src/dashboard_frontend/src/locales/ru.json @@ -367,7 +367,8 @@ "quickReject": "Быстро отклонить", "reject": "Отклонить", "requestRevisions": "Запросить доработки", - "revisions": "Доработки" + "revisions": "Доработки", + "useBatchActions": "Используйте пакетные действия для нескольких элементов" }, "tooltips": { "goToAnnotations": "Перейти к аннотациям", @@ -389,6 +390,11 @@ "placeholder": "Пожалуйста, предоставьте отзыв объясняющий почему это отклоняется...", "submit": "Отклонить" }, + "batchReject": { + "title": "Отклонить {{count}} элементов", + "placeholder": "Введите причину отклонения, которая будет применена ко всем выбранным элементам (например: 'Требуется проверка главным инженером')...", + "submit": "Отклонить все" + }, "approvalWarning": { "title": "Невозможно одобрить", "message": "Невозможно одобрить когда существуют комментарии. Используйте \"Запросить доработки\" для отправки отзыва." @@ -396,6 +402,40 @@ "revision": { "noCommentsTitle": "Комментарии не добавлены", "noCommentsMessage": "Пожалуйста, добавьте хотя бы один комментарий перед запросом доработок." + }, + "selection": { + "enterMode": "Выбрать элементы", + "exitMode": "Отменить выбор", + "select": "Выбрать", + "selectAll": "Выбрать все", + "selectedCount_one": "{{count}} выбран", + "selectedCount_few": "{{count}} выбрано", + "selectedCount_many": "{{count}} выбрано", + "selectedCount_other": "{{count}} выбрано", + "selectItem": "Выбрать {{title}}", + "approveSelected": "Одобрить выбранные", + "rejectSelected": "Отклонить выбранные" + }, + "batchConfirm": { + "title": "Подтвердить массовое действие", + "message": "Вы собираетесь {{action}} {{count}} элементов. Это действие нельзя отменить. Вы уверены что хотите продолжить?", + "confirm": "Да, продолжить" + }, + "batchResult": { + "title": "Обработано {{succeeded}} из {{total}} элементов", + "failedCount_one": "{{count}} элемент не удался", + "failedCount_few": "{{count}} элемента не удались", + "failedCount_many": "{{count}} элементов не удалось", + "failedCount_other": "{{count}} элементов не удалось", + "undo": "Отменить" + }, + "notifications": { + "approved": "\"{{title}}\" одобрен", + "approveFailed": "Не удалось одобрить \"{{title}}\"", + "rejected": "\"{{title}}\" отклонён", + "rejectFailed": "Не удалось отклонить \"{{title}}\"", + "revisionRequested": "Запрошены исправления для \"{{title}}\"", + "revisionFailed": "Не удалось запросить исправления для \"{{title}}\"" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/locales/zh.json b/src/dashboard_frontend/src/locales/zh.json index 2e9e2c4..7f90a2d 100644 --- a/src/dashboard_frontend/src/locales/zh.json +++ b/src/dashboard_frontend/src/locales/zh.json @@ -362,7 +362,8 @@ "quickReject": "快速拒绝", "reject": "拒绝", "requestRevisions": "请求修订", - "revisions": "修订" + "revisions": "修订", + "useBatchActions": "使用批量操作处理多个项目" }, "tooltips": { "goToAnnotations": "跳转到注释", @@ -384,6 +385,11 @@ "placeholder": "请输入拒绝原因或反馈...", "submit": "拒绝" }, + "batchReject": { + "title": "拒绝 {{count}} 项", + "placeholder": "输入将应用于所有选定项的拒绝原因(例如:'需要首席工程师审核')...", + "submit": "全部拒绝" + }, "approvalWarning": { "title": "无法批准", "message": "存在评论时无法批准。请使用“请求修订”发送反馈。" @@ -391,6 +397,36 @@ "revision": { "noCommentsTitle": "尚未添加评论", "noCommentsMessage": "在请求修订之前,请至少添加一条评论。" + }, + "selection": { + "enterMode": "选择项目", + "exitMode": "取消选择", + "select": "选择", + "selectAll": "全选", + "selectedCount_one": "已选择 {{count}} 项", + "selectedCount_other": "已选择 {{count}} 项", + "selectItem": "选择 {{title}}", + "approveSelected": "批准所选", + "rejectSelected": "拒绝所选" + }, + "batchConfirm": { + "title": "确认批量操作", + "message": "您即将{{action}} {{count}} 个项目。此操作无法撤销。确定要继续吗?", + "confirm": "是的,继续" + }, + "batchResult": { + "title": "已处理 {{succeeded}}/{{total}} 个项目", + "failedCount_one": "{{count}} 项失败", + "failedCount_other": "{{count}} 项失败", + "undo": "撤销" + }, + "notifications": { + "approved": "\"{{title}}\" 已批准", + "approveFailed": "批准 \"{{title}}\" 失败", + "rejected": "\"{{title}}\" 已拒绝", + "rejectFailed": "拒绝 \"{{title}}\" 失败", + "revisionRequested": "已请求修订 \"{{title}}\"", + "revisionFailed": "请求修订 \"{{title}}\" 失败" } }, "logsPage": { diff --git a/src/dashboard_frontend/src/modules/api/api.tsx b/src/dashboard_frontend/src/modules/api/api.tsx index df7cb0c..4943431 100644 --- a/src/dashboard_frontend/src/modules/api/api.tsx +++ b/src/dashboard_frontend/src/modules/api/api.tsx @@ -67,6 +67,13 @@ export interface DiffLine { content: string; } +export interface BatchApprovalResult { + success: boolean; + total: number; + succeeded: string[]; + failed: Array<{ id: string; error: string }>; +} + async function getJson(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`); @@ -78,6 +85,12 @@ async function postJson(url: string, body: any) { return { ok: res.ok, status: res.status }; } +async function postJsonWithData(url: string, body: any): Promise<{ ok: boolean; status: number; data?: T }> { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const data = res.ok ? await res.json() : undefined; + return { ok: res.ok, status: res.status, data }; +} + async function putJson(url: string, body: any) { const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return { ok: res.ok, status: res.status, data: res.ok ? await res.json() : null }; @@ -102,6 +115,8 @@ type ApiActionsContextType = { getSpecTasksProgress: (name: string) => Promise; updateTaskStatus: (specName: string, taskId: string, status: 'pending' | 'in-progress' | 'completed') => Promise<{ ok: boolean; status: number; data?: any }>; approvalsAction: (id: string, action: 'approve' | 'reject' | 'needs-revision', payload: any) => Promise<{ ok: boolean; status: number }>; + approvalsActionBatch: (ids: string[], action: 'approve' | 'reject', response?: string) => Promise<{ ok: boolean; status: number; data?: BatchApprovalResult }>; + approvalsUndoBatch: (ids: string[]) => Promise<{ ok: boolean; status: number; data?: BatchApprovalResult }>; getApprovalContent: (id: string) => Promise<{ content: string; filePath?: string }>; getApprovalSnapshots: (id: string) => Promise; getApprovalSnapshot: (id: string, version: number) => Promise; @@ -270,6 +285,8 @@ export function ApiProvider({ initial, projectId, children }: ApiProviderProps) getSpecTasksProgress: async () => ({}), updateTaskStatus: async () => ({ ok: false, status: 400 }), approvalsAction: async () => ({ ok: false, status: 400 }), + approvalsActionBatch: async () => ({ ok: false, status: 400 }), + approvalsUndoBatch: async () => ({ ok: false, status: 400 }), getApprovalContent: async () => ({ content: '' }), getApprovalSnapshots: async () => [], getApprovalSnapshot: async () => ({} as any), @@ -298,6 +315,8 @@ export function ApiProvider({ initial, projectId, children }: ApiProviderProps) updateTaskStatus: (specName: string, taskId: string, status: 'pending' | 'in-progress' | 'completed') => putJson(`${prefix}/specs/${encodeURIComponent(specName)}/tasks/${encodeURIComponent(taskId)}/status`, { status }), approvalsAction: (id, action, body) => postJson(`${prefix}/approvals/${encodeURIComponent(id)}/${action}`, body), + approvalsActionBatch: (ids, action, response) => postJsonWithData(`${prefix}/approvals/batch/${action}`, { ids, response }), + approvalsUndoBatch: (ids) => postJsonWithData(`${prefix}/approvals/batch/undo`, { ids }), getApprovalContent: (id: string) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/content`), getApprovalSnapshots: (id: string) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/snapshots`), getApprovalSnapshot: (id: string, version: number) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/snapshots/${version}`), diff --git a/src/dashboard_frontend/src/modules/modals/ConfirmationModal.tsx b/src/dashboard_frontend/src/modules/modals/ConfirmationModal.tsx index a8771b3..8dab5d0 100644 --- a/src/dashboard_frontend/src/modules/modals/ConfirmationModal.tsx +++ b/src/dashboard_frontend/src/modules/modals/ConfirmationModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; interface ConfirmationModalProps { isOpen: boolean; onClose: () => void; - onConfirm: () => void; + onConfirm: () => void | Promise; title: string; message: string; confirmText?: string; @@ -23,10 +23,29 @@ export function ConfirmationModal({ variant = 'default' }: ConfirmationModalProps) { const { t } = useTranslation(); - - const handleConfirm = () => { - onConfirm(); - onClose(); + + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + // Clear error when modal is closed/reopened + React.useEffect(() => { + if (isOpen) { + setError(null); + } + }, [isOpen]); + + const handleConfirm = async () => { + setIsLoading(true); + setError(null); + try { + await onConfirm(); + onClose(); + } catch (err: any) { + // Keep modal open and show error to user + setError(err?.message || 'An error occurred'); + } finally { + setIsLoading(false); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -73,6 +92,11 @@ export function ConfirmationModal({

{message}

+ {error && ( +
+ {error} +
+ )} {/* Footer */} @@ -86,9 +110,18 @@ export function ConfirmationModal({ diff --git a/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx b/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx index 4bf28a2..696cccd 100644 --- a/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useApi, DocumentSnapshot, DiffResult } from '../api/api'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; +import { useApi, DocumentSnapshot, DiffResult, BatchApprovalResult } from '../api/api'; import { ApprovalsAnnotator, ApprovalComment } from '../approvals/ApprovalsAnnotator'; -import { NotificationProvider } from '../notifications/NotificationProvider'; +import { useNotifications } from '../notifications/NotificationProvider'; import { TextInputModal } from '../modals/TextInputModal'; import { AlertModal } from '../modals/AlertModal'; +import { ConfirmationModal } from '../modals/ConfirmationModal'; import { DiffViewer } from '../diff/DiffViewer'; import { DiffStats, DiffStatsBadge } from '../diff/DiffStats'; import { formatSnapshotTimestamp, createVersionLabel, hasDiffChanges, getSnapshotTriggerDescription } from '../diff/utils'; @@ -20,8 +21,17 @@ function formatDate(dateStr?: string, t?: (k: string, o?: any) => string) { } -function ApprovalItem({ a }: { a: any }) { - const { approvalsAction, getApprovalContent, getApprovalSnapshots, getApprovalDiff } = useApi(); +interface ApprovalItemProps { + a: any; + selectionMode: boolean; + isSelected: boolean; + selectedCount: number; + onToggleSelection: (id: string) => void; +} + +function ApprovalItem({ a, selectionMode, isSelected, selectedCount, onToggleSelection }: ApprovalItemProps) { + const { approvalsAction, getApprovalContent, getApprovalSnapshots, getApprovalDiff, reloadAll } = useApi(); + const { showNotification } = useNotifications(); const { t } = useTranslation(); const [content, setContent] = useState(''); const [loading, setLoading] = useState(false); @@ -131,9 +141,17 @@ function ApprovalItem({ a }: { a: any }) { } setActionLoading('approve'); try { - await approvalsAction(a.id, 'approve', { response: t('approvalsPage.messages.approvedViaDashboard') }); - setOpen(false); + const result = await approvalsAction(a.id, 'approve', { response: t('approvalsPage.messages.approvedViaDashboard') }); + if (result.ok) { + showNotification(t('approvalsPage.notifications.approved', { title: a.title }), 'success'); + setOpen(false); + await reloadAll(); + } else { + showNotification(t('approvalsPage.notifications.approveFailed', { title: a.title }), 'error'); + console.error('Approval failed with status:', result.status); + } } catch (error) { + showNotification(t('approvalsPage.notifications.approveFailed', { title: a.title }), 'error'); console.error('Failed to approve:', error); } finally { setActionLoading(null); @@ -147,9 +165,17 @@ function ApprovalItem({ a }: { a: any }) { const handleRejectWithFeedback = async (feedback: string) => { setActionLoading('reject'); try { - await approvalsAction(a.id, 'reject', { response: feedback }); - setOpen(false); + const result = await approvalsAction(a.id, 'reject', { response: feedback }); + if (result.ok) { + showNotification(t('approvalsPage.notifications.rejected', { title: a.title }), 'success'); + setOpen(false); + await reloadAll(); + } else { + showNotification(t('approvalsPage.notifications.rejectFailed', { title: a.title }), 'error'); + console.error('Rejection failed with status:', result.status); + } } catch (error) { + showNotification(t('approvalsPage.notifications.rejectFailed', { title: a.title }), 'error'); console.error('Failed to reject:', error); } finally { setActionLoading(null); @@ -193,10 +219,18 @@ function ApprovalItem({ a }: { a: any }) { setActionLoading('revision'); try { - await approvalsAction(a.id, 'needs-revision', payload); - setOpen(false); - setComments([]); + const result = await approvalsAction(a.id, 'needs-revision', payload); + if (result.ok) { + showNotification(t('approvalsPage.notifications.revisionRequested', { title: a.title }), 'success'); + setOpen(false); + setComments([]); + await reloadAll(); + } else { + showNotification(t('approvalsPage.notifications.revisionFailed', { title: a.title }), 'error'); + console.error('Request revision failed with status:', result.status); + } } catch (error) { + showNotification(t('approvalsPage.notifications.revisionFailed', { title: a.title }), 'error'); console.error('Failed to request revision:', error); } finally { setActionLoading(null); @@ -204,9 +238,21 @@ function ApprovalItem({ a }: { a: any }) { }; return ( -
+
+ {/* Selection Checkbox */} + {selectionMode && ( +
+ onToggleSelection(a.id)} + className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" + aria-label={t('approvalsPage.selection.selectItem', { title: a.title })} + /> +
+ )}

{a.title} @@ -277,7 +323,8 @@ function ApprovalItem({ a }: { a: any }) { + )}

+ {/* Selection Mode Toolbar */} + {selectionMode && ( +
+
+ {/* Select All Checkbox */} + + + {t('approvalsPage.selection.selectedCount', { count: selectedIds.size })} + +
+ + {/* Batch Action Buttons */} +
+ + +
+
+ )} + {/* Filter Dropdown */} {categories.length > 1 && (
@@ -661,10 +952,105 @@ function Content() { ) : (
{filteredApprovals.map((a) => ( - + ))}
)} + + {/* Batch Confirmation Modal (for >5 items) */} + { + setConfirmModalOpen(false); + setPendingAction(null); + }} + onConfirm={handleConfirmBatchAction} + title={t('approvalsPage.batchConfirm.title')} + message={t('approvalsPage.batchConfirm.message', { + count: selectedIds.size, + action: pendingAction === 'approve' ? t('approvalsPage.actions.approve').toLowerCase() : t('approvalsPage.actions.reject').toLowerCase() + })} + confirmText={t('approvalsPage.batchConfirm.confirm')} + cancelText={t('common.cancel')} + variant={pendingAction === 'reject' ? 'danger' : 'default'} + /> + + {/* Batch Rejection Feedback Modal */} + setBatchRejectModalOpen(false)} + onSubmit={handleBatchRejectWithFeedback} + title={t('approvalsPage.batchReject.title', { count: selectedIds.size })} + placeholder={t('approvalsPage.batchReject.placeholder')} + submitText={t('approvalsPage.batchReject.submit')} + multiline={true} + /> + + {/* Batch Result Toast */} + {showResultToast && batchResult && ( +
+
0 + ? 'bg-yellow-50 dark:bg-yellow-900/50 border border-yellow-200 dark:border-yellow-800' + : 'bg-green-50 dark:bg-green-900/50 border border-green-200 dark:border-green-800' + }`}> +
+ {batchResult.failed.length > 0 ? ( + + + + ) : ( + + + + )} +
+

0 + ? 'text-yellow-800 dark:text-yellow-200' + : 'text-green-800 dark:text-green-200' + }`}> + {t('approvalsPage.batchResult.title', { + succeeded: batchResult.succeeded.length, + total: batchResult.total + })} +

+ {batchResult.failed.length > 0 && ( +

+ {t('approvalsPage.batchResult.failedCount', { count: batchResult.failed.length })} +

+ )} +
+
+ {lastBatchOperation && ( + + )} + +
+
+
+
+ )}
); } diff --git a/src/dashboard_frontend/vite.config.ts b/src/dashboard_frontend/vite.config.ts index fd933f3..1a1bcbd 100644 --- a/src/dashboard_frontend/vite.config.ts +++ b/src/dashboard_frontend/vite.config.ts @@ -3,6 +3,10 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; import react from '@vitejs/plugin-react'; +// Dashboard port - matches DEFAULT_DASHBOARD_PORT in security-utils.ts +// Can be overridden via VITE_DASHBOARD_PORT environment variable +const dashboardPort = process.env.VITE_DASHBOARD_PORT || '5000'; + // Dynamically import Tailwind CSS v4 plugin async function createConfig() { const { default: tailwindcss } = await import('@tailwindcss/vite'); @@ -16,6 +20,18 @@ async function createConfig() { outDir: 'dist', emptyOutDir: true, }, + server: { + proxy: { + '/api': { + target: `http://localhost:${dashboardPort}`, + changeOrigin: true, + }, + '/ws': { + target: `ws://localhost:${dashboardPort}`, + ws: true, + }, + }, + }, }; } diff --git a/vscode-extension/.gitignore b/vscode-extension/.gitignore index 0b60dfa..56483b5 100644 --- a/vscode-extension/.gitignore +++ b/vscode-extension/.gitignore @@ -3,3 +3,4 @@ dist node_modules .vscode-test/ *.vsix +.claude-context diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 51d384b..069393f 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "spec-workflow-mcp", - "version": "0.0.6", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spec-workflow-mcp", - "version": "0.0.6", + "version": "1.1.3", "license": "GPL-3.0", "dependencies": { "@jaames/iro": "^5.5.2", diff --git a/vscode-extension/src/extension/providers/SidebarProvider.ts b/vscode-extension/src/extension/providers/SidebarProvider.ts index 28126ab..4a83fac 100644 --- a/vscode-extension/src/extension/providers/SidebarProvider.ts +++ b/vscode-extension/src/extension/providers/SidebarProvider.ts @@ -105,6 +105,15 @@ export class SidebarProvider implements vscode.WebviewViewProvider { case 'request-revision-request': await this.requestRevisionRequest(message.id, message.response, message.annotations, message.comments); break; + case 'batch-approve': + await this.batchApprove(message.ids, message.response); + break; + case 'batch-reject': + await this.batchReject(message.ids, message.response); + break; + case 'batch-request-revision': + await this.batchRequestRevision(message.ids, message.response); + break; case 'get-approval-content': await this.sendApprovalContent(message.id); break; @@ -462,6 +471,87 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } } + // Batch size limit to prevent abuse (matches dashboard backend limit) + private static readonly BATCH_SIZE_LIMIT = 100; + + /** + * Generic batch operation handler - DRY refactoring of batch methods + * @param ids - Array of approval IDs to process + * @param action - The action function to call for each ID + * @param actionVerb - The verb to use in notifications (e.g., 'approved', 'rejected') + * @param actionPastTense - Past tense for mixed results (e.g., 'approved', 'rejected') + */ + private async executeBatchOperation( + ids: string[], + action: (id: string, response: string) => Promise, + actionVerb: string, + actionPastTense: string, + response: string + ): Promise { + // Backend batch size validation + if (ids.length > SidebarProvider.BATCH_SIZE_LIMIT) { + this.sendError(`Batch size exceeds limit. Maximum ${SidebarProvider.BATCH_SIZE_LIMIT} items allowed.`); + return; + } + + try { + console.log(`SidebarProvider: Batch ${actionVerb} ${ids.length} requests`); + let successCount = 0; + let failedCount = 0; + + for (const id of ids) { + try { + await action(id, response); + successCount++; + } catch (error) { + console.error(`Failed to ${actionVerb} request ${id}:`, error); + failedCount++; + } + } + + await this.sendApprovals(); + await this.sendApprovalCategories(); + + if (failedCount === 0) { + this.sendNotification(`${successCount} requests ${actionPastTense}`, 'success'); + } else { + this.sendNotification(`${successCount} ${actionPastTense}, ${failedCount} failed`, 'warning'); + } + } catch (error) { + this.sendError(`Failed to batch ${actionVerb}: ` + (error as Error).message); + } + } + + private async batchApprove(ids: string[], response: string) { + await this.executeBatchOperation( + ids, + (id, resp) => this._specWorkflowService.approveRequest(id, resp), + 'approving', + 'approved', + response + ); + } + + private async batchReject(ids: string[], response: string) { + await this.executeBatchOperation( + ids, + (id, resp) => this._specWorkflowService.rejectRequest(id, resp), + 'rejecting', + 'rejected', + response + ); + } + + private async batchRequestRevision(ids: string[], response: string) { + await this.executeBatchOperation( + ids, + (id, resp) => this._specWorkflowService.requestRevisionRequest(id, resp), + 'requesting revision for', + 'revised', + response + ); + } + private async sendApprovalContent(id: string) { try { // Open approval in editor instead of sending content to webview diff --git a/vscode-extension/src/extension/services/ImplementationLogService.ts b/vscode-extension/src/extension/services/ImplementationLogService.ts index 87cef68..4e77f90 100644 --- a/vscode-extension/src/extension/services/ImplementationLogService.ts +++ b/vscode-extension/src/extension/services/ImplementationLogService.ts @@ -252,7 +252,7 @@ export class ImplementationLogService { // Helper function to normalize markdown keys to camelCase const normalizeKey = (key: string): string => { const words = key.toLowerCase().trim().split(/\s+/); - if (words.length === 0) return ''; + if (words.length === 0) {return '';} return words[0] + words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''); }; @@ -266,9 +266,9 @@ export class ImplementationLogService { // Helper function to convert string values to appropriate types const convertValue = (key: string, value: string): any => { - if (value === 'Yes' || value === 'yes') return true; - if (value === 'No' || value === 'no') return false; - if (value === 'N/A' || value === 'n/a') return ''; + if (value === 'Yes' || value === 'yes') {return true;} + if (value === 'No' || value === 'no') {return false;} + if (value === 'N/A' || value === 'n/a') {return '';} return value; }; @@ -327,7 +327,7 @@ export class ImplementationLogService { // Parse artifact subsections (### headers) else if (line.startsWith('### ')) { if (Object.keys(currentItem).length > 0 && currentArtifactType) { - if (!artifacts[currentArtifactType]) artifacts[currentArtifactType] = []; + if (!artifacts[currentArtifactType]) {artifacts[currentArtifactType] = [];} (artifacts[currentArtifactType] as any).push(currentItem); currentItem = {}; } @@ -348,7 +348,7 @@ export class ImplementationLogService { // Parse artifact item headers (#### for individual items) else if (line.startsWith('#### ') && currentArtifactType) { if (Object.keys(currentItem).length > 0) { - if (!artifacts[currentArtifactType]) artifacts[currentArtifactType] = []; + if (!artifacts[currentArtifactType]) {artifacts[currentArtifactType] = [];} (artifacts[currentArtifactType] as any).push(currentItem); } currentItem = {}; @@ -396,7 +396,7 @@ export class ImplementationLogService { // Save last artifact item if (Object.keys(currentItem).length > 0 && currentArtifactType) { - if (!artifacts[currentArtifactType]) artifacts[currentArtifactType] = []; + if (!artifacts[currentArtifactType]) {artifacts[currentArtifactType] = [];} (artifacts[currentArtifactType] as any).push(currentItem); } diff --git a/vscode-extension/src/webview/App.tsx b/vscode-extension/src/webview/App.tsx index d85b1c9..0db7e24 100644 --- a/vscode-extension/src/webview/App.tsx +++ b/vscode-extension/src/webview/App.tsx @@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Activity, CheckSquare, + Square, AlertCircle, RefreshCw, BookOpen, @@ -21,7 +22,11 @@ import { ChevronDown, ChevronRight, Bot, - FileText + FileText, + Check, + X, + Minus, + RotateCcw } from 'lucide-react'; import { vscodeApi, type SpecData, type TaskProgressData, type ApprovalData, type SteeringStatus, type DocumentInfo, type SoundNotificationConfig } from '@/lib/vscode-api'; import { cn, formatDistanceToNow } from '@/lib/utils'; @@ -29,6 +34,10 @@ import { useVSCodeTheme } from '@/hooks/useVSCodeTheme'; import { useSoundNotifications } from '@/hooks/useSoundNotifications'; import { LogsPage } from '@/pages/LogsPage'; +// Constants for batch operations - matches dashboard limits +const BATCH_SIZE_LIMIT = 100; +const BATCH_OPERATION_FEEDBACK_DELAY = 2000; + function App() { const { t, i18n } = useTranslation(); console.log('=== WEBVIEW APP.TSX STARTING ==='); @@ -49,6 +58,13 @@ function App() { const [notification, setNotification] = useState<{message: string, level: 'info' | 'warning' | 'error' | 'success'} | null>(null); const [processingApproval, setProcessingApproval] = useState(null); const [copiedTaskId, setCopiedTaskId] = useState(null); + + // Batch selection mode state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedApprovalIds, setSelectedApprovalIds] = useState>(new Set()); + const [batchProcessing, setBatchProcessing] = useState(false); + // Track if we're expecting a batch operation completion - used to detect backend confirmation + const pendingBatchOperation = useRef(false); const [copiedSteering, setCopiedSteering] = useState(false); const [expandedPrompts, setExpandedPrompts] = useState>(new Set()); const [showScrollTop, setShowScrollTop] = useState(false); @@ -136,6 +152,89 @@ function App() { document.body.removeChild(textArea); }; + // Batch selection mode handlers + const toggleSelectionMode = () => { + if (selectionMode) { + // Exiting selection mode - clear selections + setSelectedApprovalIds(new Set()); + } + setSelectionMode(!selectionMode); + }; + + const toggleApprovalSelection = (id: string) => { + setSelectedApprovalIds(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const selectAllApprovals = (approvalIds: string[]) => { + if (selectedApprovalIds.size === approvalIds.length) { + // All selected, deselect all + setSelectedApprovalIds(new Set()); + } else { + // Select all + setSelectedApprovalIds(new Set(approvalIds)); + } + }; + + // Helper to start a batch operation with proper tracking + const startBatchOperation = ( + apiCall: () => void + ) => { + setBatchProcessing(true); + pendingBatchOperation.current = true; + apiCall(); + // Fallback timeout in case backend notification doesn't arrive (e.g., network issue) + // The notification handler will clear state earlier if confirmation arrives + setTimeout(() => { + if (pendingBatchOperation.current) { + pendingBatchOperation.current = false; + setBatchProcessing(false); + setSelectedApprovalIds(new Set()); + setSelectionMode(false); + } + }, BATCH_OPERATION_FEEDBACK_DELAY * 5); // 10 seconds fallback + }; + + const handleBatchApprove = () => { + if (selectedApprovalIds.size === 0) {return;} + if (selectedApprovalIds.size > BATCH_SIZE_LIMIT) { + setNotification({ message: t('approvals.batch.tooMany', { limit: BATCH_SIZE_LIMIT }), level: 'warning' }); + return; + } + startBatchOperation(() => + vscodeApi.batchApprove(Array.from(selectedApprovalIds), t('approvals.response.approved')) + ); + }; + + const handleBatchReject = () => { + if (selectedApprovalIds.size === 0) {return;} + if (selectedApprovalIds.size > BATCH_SIZE_LIMIT) { + setNotification({ message: t('approvals.batch.tooMany', { limit: BATCH_SIZE_LIMIT }), level: 'warning' }); + return; + } + startBatchOperation(() => + vscodeApi.batchReject(Array.from(selectedApprovalIds), t('approvals.response.rejected')) + ); + }; + + const handleBatchRevision = () => { + if (selectedApprovalIds.size === 0) {return;} + if (selectedApprovalIds.size > BATCH_SIZE_LIMIT) { + setNotification({ message: t('approvals.batch.tooMany', { limit: BATCH_SIZE_LIMIT }), level: 'warning' }); + return; + } + startBatchOperation(() => + vscodeApi.batchRequestRevision(Array.from(selectedApprovalIds), t('approvals.response.needsRevision')) + ); + }; + // Language change handler const handleLanguageChange = (language: string) => { setCurrentLanguage(language); @@ -275,6 +374,17 @@ Review the existing steering documents (if any) and help me improve or complete setNotification({ message: message.message, level: message.level }); // Auto-hide notification after 3 seconds setTimeout(() => setNotification(null), 3000); + + // Handle batch operation completion - detect by checking if we're expecting one + // and if the notification indicates a batch result (contains "requests" or "failed") + if (pendingBatchOperation.current && + (message.message.includes('requests') || message.message.includes('failed'))) { + // Backend confirmed the batch operation - clear state immediately + pendingBatchOperation.current = false; + setBatchProcessing(false); + setSelectedApprovalIds(new Set()); + setSelectionMode(false); + } }), vscodeApi.onMessage('config-updated', (message: any) => { setSoundConfig(message.data || { @@ -1012,8 +1122,9 @@ Review the existing steering documents (if any) and help me improve or complete {/* Approvals Tab */} - +
+ {/* Category Filter */}
+ + {/* Selection Mode Header */} + {selectedApprovalCategory && (() => { + const pendingApprovals = selectedApprovalCategory === 'all' + ? approvals.filter(approval => approval.status === 'pending') + : approvals.filter(approval => + approval.status === 'pending' && approval.categoryName === selectedApprovalCategory + ); + + if (pendingApprovals.length === 0) {return null;} + + const allSelected = pendingApprovals.length > 0 && + pendingApprovals.every(a => selectedApprovalIds.has(a.id)); + const someSelected = pendingApprovals.some(a => selectedApprovalIds.has(a.id)); + + return ( +
+
+ {selectionMode && ( + + )} + + {selectionMode + ? t('approvals.selectedCount', { count: selectedApprovalIds.size }) + : t('approvals.pendingCount', { count: pendingApprovals.length }) + } + +
+ +
+ ); + })()}
{selectedApprovalCategory ? ( (() => { // Filter approvals based on selected category - const pendingApprovals = selectedApprovalCategory === 'all' + const pendingApprovals = selectedApprovalCategory === 'all' ? approvals.filter(approval => approval.status === 'pending') - : approvals.filter(approval => + : approvals.filter(approval => approval.status === 'pending' && approval.categoryName === selectedApprovalCategory ); - + return pendingApprovals.length > 0 ? ( -
- {pendingApprovals.map(approval => ( - - -
-
-

{approval.title}

- - {t('approvals.status.pending')} - -
- {approval.description && ( -

{approval.description}

- )} - {approval.filePath && ( -

- {approval.filePath} -

- )} -
- {t('approvals.created', { time: formatDistanceToNow(approval.createdAt) })} -
- -
- - - +
0 && "pb-16")}> + {pendingApprovals.map(approval => { + const isSelected = selectedApprovalIds.has(approval.id); + + return ( + toggleApprovalSelection(approval.id) : undefined} + > + +
+
+ {/* Selection Checkbox */} + {selectionMode && ( + + )} +
+

{approval.title}

+ + {t('approvals.status.pending')} + +
+
+ {approval.description && ( +

{approval.description}

+ )} {approval.filePath && ( - +

+ {approval.filePath} +

+ )} +
+ {t('approvals.created', { time: formatDistanceToNow(approval.createdAt) })} +
+ + {/* Individual action buttons - hidden in selection mode */} + {!selectionMode && ( +
+ + + + {approval.filePath && ( + + )} +
)}
-
- - - ))} + + + ); + })}
) : (
@@ -1140,6 +1349,48 @@ Review the existing steering documents (if any) and help me improve or complete {approvalCategories.length <= 1 ? t('approvals.noPendingDocuments') : t('approvals.selectCategory')}
)} + + {/* Sticky Footer for Batch Actions */} + {selectionMode && selectedApprovalIds.size > 0 && ( +
+
+ + {t('approvals.selectedCount', { count: selectedApprovalIds.size })} + +
+ + + +
+
+
+ )} {/* Specs Tab */} diff --git a/vscode-extension/src/webview/lib/vscode-api.ts b/vscode-extension/src/webview/lib/vscode-api.ts index df3a12a..a6e860e 100644 --- a/vscode-extension/src/webview/lib/vscode-api.ts +++ b/vscode-extension/src/webview/lib/vscode-api.ts @@ -47,6 +47,9 @@ export type WebviewMessage = | { type: 'reject-request'; id: string; response: string } | { type: 'request-revision-request'; id: string; response: string; annotations?: string; comments?: any[] } | { type: 'get-approval-content'; id: string } + | { type: 'batch-approve'; ids: string[]; response: string } + | { type: 'batch-reject'; ids: string[]; response: string } + | { type: 'batch-request-revision'; ids: string[]; response: string } | { type: 'get-selected-spec' } | { type: 'set-selected-spec'; specName: string } | { type: 'get-config' } @@ -346,6 +349,19 @@ class VsCodeApiService { this.postMessage({ type: 'get-approval-content', id }); } + // Batch approval methods + batchApprove(ids: string[], response: string) { + this.postMessage({ type: 'batch-approve', ids, response }); + } + + batchReject(ids: string[], response: string) { + this.postMessage({ type: 'batch-reject', ids, response }); + } + + batchRequestRevision(ids: string[], response: string) { + this.postMessage({ type: 'batch-request-revision', ids, response }); + } + getSteering() { this.postMessage({ type: 'get-steering' }); } diff --git a/vscode-extension/src/webview/locales/ar.json b/vscode-extension/src/webview/locales/ar.json index bfe951e..5065752 100644 --- a/vscode-extension/src/webview/locales/ar.json +++ b/vscode-extension/src/webview/locales/ar.json @@ -78,7 +78,19 @@ "openInEditor": "فتح في المحرر", "noPending": "لا توجد موافقات معلقة لهذه المواصفة", "noPendingDocuments": "لم توجد وثائق بموافقات معلقة", - "selectCategory": "اختر فئة أعلاه لعرض الموافقات المعلقة" + "selectCategory": "اختر فئة أعلاه لعرض الموافقات المعلقة", + "select": "تحديد", + "cancel": "إلغاء", + "selectAll": "تحديد الكل", + "deselectAll": "إلغاء تحديد الكل", + "selectedCount": "{{count}} محدد", + "pendingCount": "{{count}} معلق", + "approveAll": "موافقة على الكل", + "revisionAll": "مراجعة الكل", + "rejectAll": "رفض الكل", + "batch": { + "tooMany": "لا يمكن معالجة أكثر من {{limit}} عنصر في المرة الواحدة" + } }, "specs": { "active": "نشط", diff --git a/vscode-extension/src/webview/locales/de.json b/vscode-extension/src/webview/locales/de.json index 1655008..5cb18e8 100644 --- a/vscode-extension/src/webview/locales/de.json +++ b/vscode-extension/src/webview/locales/de.json @@ -78,7 +78,19 @@ "openInEditor": "Im Editor öffnen", "noPending": "Keine ausstehenden Genehmigungen für diese Spezifikation", "noPendingDocuments": "Keine Dokumente mit ausstehenden Genehmigungen gefunden", - "selectCategory": "Wählen Sie oben eine Kategorie aus um ausstehende Genehmigungen anzuzeigen" + "selectCategory": "Wählen Sie oben eine Kategorie aus um ausstehende Genehmigungen anzuzeigen", + "select": "Auswählen", + "cancel": "Abbrechen", + "selectAll": "Alle auswählen", + "deselectAll": "Auswahl aufheben", + "selectedCount": "{{count}} ausgewählt", + "pendingCount": "{{count}} ausstehend", + "approveAll": "Alle genehmigen", + "revisionAll": "Alle überarbeiten", + "rejectAll": "Alle ablehnen", + "batch": { + "tooMany": "Es können nicht mehr als {{limit}} Elemente gleichzeitig verarbeitet werden" + } }, "specs": { "active": "Aktiv", diff --git a/vscode-extension/src/webview/locales/en.json b/vscode-extension/src/webview/locales/en.json index c1da048..a5e168e 100644 --- a/vscode-extension/src/webview/locales/en.json +++ b/vscode-extension/src/webview/locales/en.json @@ -78,7 +78,19 @@ "openInEditor": "Open in Editor", "noPending": "No pending approvals for this specification", "noPendingDocuments": "No documents with pending approvals found", - "selectCategory": "Select a category above to view pending approvals" + "selectCategory": "Select a category above to view pending approvals", + "select": "Select", + "cancel": "Cancel", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "selectedCount": "{{count}} selected", + "pendingCount": "{{count}} pending", + "approveAll": "Approve All", + "revisionAll": "Revise All", + "rejectAll": "Reject All", + "batch": { + "tooMany": "Cannot process more than {{limit}} items at once" + } }, "specs": { "active": "Active", diff --git a/vscode-extension/src/webview/locales/es.json b/vscode-extension/src/webview/locales/es.json index 78d6621..ee66b99 100644 --- a/vscode-extension/src/webview/locales/es.json +++ b/vscode-extension/src/webview/locales/es.json @@ -78,7 +78,19 @@ "openInEditor": "Abrir en Editor", "noPending": "No hay aprobaciones pendientes para esta especificación", "noPendingDocuments": "No se encontraron documentos con aprobaciones pendientes", - "selectCategory": "Selecciona una categoría arriba para ver aprobaciones pendientes" + "selectCategory": "Selecciona una categoría arriba para ver aprobaciones pendientes", + "select": "Seleccionar", + "cancel": "Cancelar", + "selectAll": "Seleccionar todo", + "deselectAll": "Deseleccionar todo", + "selectedCount": "{{count}} seleccionados", + "pendingCount": "{{count}} pendientes", + "approveAll": "Aprobar todo", + "revisionAll": "Revisar todo", + "rejectAll": "Rechazar todo", + "batch": { + "tooMany": "No se pueden procesar más de {{limit}} elementos a la vez" + } }, "specs": { "active": "Activas", diff --git a/vscode-extension/src/webview/locales/fr.json b/vscode-extension/src/webview/locales/fr.json index 966345c..a0ffde0 100644 --- a/vscode-extension/src/webview/locales/fr.json +++ b/vscode-extension/src/webview/locales/fr.json @@ -78,7 +78,19 @@ "openInEditor": "Ouvrir dans l'Éditeur", "noPending": "Aucune approbation en attente pour cette spécification", "noPendingDocuments": "Aucun document avec approbations en attente trouvé", - "selectCategory": "Sélectionner une catégorie ci-dessus pour voir les approbations en attente" + "selectCategory": "Sélectionner une catégorie ci-dessus pour voir les approbations en attente", + "select": "Sélectionner", + "cancel": "Annuler", + "selectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "selectedCount": "{{count}} sélectionnés", + "pendingCount": "{{count}} en attente", + "approveAll": "Tout approuver", + "revisionAll": "Tout réviser", + "rejectAll": "Tout rejeter", + "batch": { + "tooMany": "Impossible de traiter plus de {{limit}} éléments à la fois" + } }, "specs": { "active": "Actif", diff --git a/vscode-extension/src/webview/locales/it.json b/vscode-extension/src/webview/locales/it.json index a91f2c2..7734863 100644 --- a/vscode-extension/src/webview/locales/it.json +++ b/vscode-extension/src/webview/locales/it.json @@ -78,7 +78,19 @@ "openInEditor": "Apri nell'Editor", "noPending": "Nessuna approvazione in attesa per questa specifica", "noPendingDocuments": "Nessun documento con approvazioni in attesa trovato", - "selectCategory": "Seleziona una categoria sopra per visualizzare approvazioni in attesa" + "selectCategory": "Seleziona una categoria sopra per visualizzare approvazioni in attesa", + "select": "Seleziona", + "cancel": "Annulla", + "selectAll": "Seleziona tutto", + "deselectAll": "Deseleziona tutto", + "selectedCount": "{{count}} selezionati", + "pendingCount": "{{count}} in attesa", + "approveAll": "Approva tutto", + "revisionAll": "Rivedi tutto", + "rejectAll": "Rifiuta tutto", + "batch": { + "tooMany": "Non è possibile elaborare più di {{limit}} elementi alla volta" + } }, "specs": { "active": "Attive", diff --git a/vscode-extension/src/webview/locales/ja.json b/vscode-extension/src/webview/locales/ja.json index 1d278d6..2a5300c 100644 --- a/vscode-extension/src/webview/locales/ja.json +++ b/vscode-extension/src/webview/locales/ja.json @@ -78,7 +78,19 @@ "openInEditor": "エディタで開く", "noPending": "この仕様書には保留中の承認はありません", "noPendingDocuments": "保留中の承認があるドキュメントが見つかりません", - "selectCategory": "保留中の承認を表示するには、上のカテゴリを選択してください" + "selectCategory": "保留中の承認を表示するには、上のカテゴリを選択してください", + "select": "選択", + "cancel": "キャンセル", + "selectAll": "すべて選択", + "deselectAll": "すべて解除", + "selectedCount": "{{count}}件選択中", + "pendingCount": "{{count}}件保留中", + "approveAll": "すべて承認", + "revisionAll": "すべて修正依頼", + "rejectAll": "すべて拒否", + "batch": { + "tooMany": "一度に{{limit}}件を超えるアイテムを処理することはできません" + } }, "specs": { "active": "アクティブ", diff --git a/vscode-extension/src/webview/locales/ko.json b/vscode-extension/src/webview/locales/ko.json index dd1723f..6afddc2 100644 --- a/vscode-extension/src/webview/locales/ko.json +++ b/vscode-extension/src/webview/locales/ko.json @@ -78,7 +78,19 @@ "openInEditor": "편집기에서 열기", "noPending": "이 명세서에 대한 대기 중인 승인이 없습니다", "noPendingDocuments": "승인 대기 중인 문서를 찾을 수 없습니다", - "selectCategory": "승인 대기 중인 항목을 보려면 위에서 카테고리를 선택하세요" + "selectCategory": "승인 대기 중인 항목을 보려면 위에서 카테고리를 선택하세요", + "select": "선택", + "cancel": "취소", + "selectAll": "모두 선택", + "deselectAll": "모두 해제", + "selectedCount": "{{count}}개 선택됨", + "pendingCount": "{{count}}개 대기 중", + "approveAll": "모두 승인", + "revisionAll": "모두 수정 요청", + "rejectAll": "모두 거부", + "batch": { + "tooMany": "한 번에 {{limit}}개 이상의 항목을 처리할 수 없습니다" + } }, "specs": { "active": "활성", diff --git a/vscode-extension/src/webview/locales/pt.json b/vscode-extension/src/webview/locales/pt.json index b18480e..f19b86c 100644 --- a/vscode-extension/src/webview/locales/pt.json +++ b/vscode-extension/src/webview/locales/pt.json @@ -78,7 +78,19 @@ "openInEditor": "Abrir no Editor", "noPending": "Nenhuma aprovação pendente para esta especificação", "noPendingDocuments": "Nenhum documento com aprovações pendentes encontrado", - "selectCategory": "Selecione uma categoria acima para ver aprovações pendentes" + "selectCategory": "Selecione uma categoria acima para ver aprovações pendentes", + "select": "Selecionar", + "cancel": "Cancelar", + "selectAll": "Selecionar tudo", + "deselectAll": "Desmarcar tudo", + "selectedCount": "{{count}} selecionados", + "pendingCount": "{{count}} pendentes", + "approveAll": "Aprovar tudo", + "revisionAll": "Revisar tudo", + "rejectAll": "Rejeitar tudo", + "batch": { + "tooMany": "Não é possível processar mais de {{limit}} itens de uma vez" + } }, "specs": { "active": "Ativas", diff --git a/vscode-extension/src/webview/locales/ru.json b/vscode-extension/src/webview/locales/ru.json index 64258a3..0117c78 100644 --- a/vscode-extension/src/webview/locales/ru.json +++ b/vscode-extension/src/webview/locales/ru.json @@ -78,7 +78,19 @@ "openInEditor": "Открыть в редакторе", "noPending": "Нет ожидающих одобрений для этой спецификации", "noPendingDocuments": "Документы с ожидающими одобрениями не найдены", - "selectCategory": "Выберите категорию выше для просмотра ожидающих одобрений" + "selectCategory": "Выберите категорию выше для просмотра ожидающих одобрений", + "select": "Выбрать", + "cancel": "Отмена", + "selectAll": "Выбрать всё", + "deselectAll": "Снять выделение", + "selectedCount": "{{count}} выбрано", + "pendingCount": "{{count}} ожидает", + "approveAll": "Одобрить все", + "revisionAll": "Доработать все", + "rejectAll": "Отклонить все", + "batch": { + "tooMany": "Невозможно обработать более {{limit}} элементов за раз" + } }, "specs": { "active": "Активные", diff --git a/vscode-extension/src/webview/locales/zh.json b/vscode-extension/src/webview/locales/zh.json index 3843769..bc6b0f5 100644 --- a/vscode-extension/src/webview/locales/zh.json +++ b/vscode-extension/src/webview/locales/zh.json @@ -73,7 +73,19 @@ "openInEditor": "在编辑器中打开", "noPending": "此规范没有待处理的审批", "noPendingDocuments": "未找到有待处理审批的文档", - "selectCategory": "请在上方选择类别以查看待处理的审批" + "selectCategory": "请在上方选择类别以查看待处理的审批", + "select": "选择", + "cancel": "取消", + "selectAll": "全选", + "deselectAll": "取消全选", + "selectedCount": "已选择 {{count}} 项", + "pendingCount": "{{count}} 项待处理", + "approveAll": "全部批准", + "revisionAll": "全部请求修订", + "rejectAll": "全部拒绝", + "batch": { + "tooMany": "一次不能处理超过{{limit}}个项目" + } }, "specs": { "active": "活跃",