# RE Workflow Management System - Detailed Sprint Implementation Guide ## Comprehensive Feature Breakdown for Development Team **Version:** 1.0 **Date:** October 23, 2025 **Project:** RE Workflow Management System (Non-Templatized) **Focus:** Core Application Features (Workflow Creation, Approvals, Work Notes, Documents) --- ## 📖 Table of Contents 1. [Sprint 0: Foundation Setup](#sprint-0-foundation-setup) 2. [Sprint 1: SSO Authentication](#sprint-1-sso-authentication) 3. [Sprint 2: Workflow Creation Wizard](#sprint-2-workflow-creation-wizard) 4. [Sprint 3: Approval Actions & TAT Tracking](#sprint-3-approval-actions--tat-tracking) 5. [Sprint 4: Documents & Work Notes](#sprint-4-documents--work-notes) 6. [Sprint 5: Dashboard & Analytics](#sprint-5-dashboard--analytics) 7. [Sprint 6: Testing & Deployment](#sprint-6-testing--deployment) --- ## Sprint 0: Foundation Setup ### Backend Foundation (3 days) **BE-001 to BE-004:** Database, Express, Sequelize setup - See main task document for technical setup details - Focus: Get server running with database connection ### Frontend Foundation (4 days) **Current Status:** - ✅ UI Components: 80% extracted from Figma - ❌ Redux Store: Not created (2-3 days needed) - ❌ API Service Layer: Not configured (1 day needed) - ❌ Route Guards: Not implemented (1 day needed) **Critical Work This Week:** 1. Complete UI extraction (20% remaining) 2. **Setup Redux store - CANNOT SKIP** 3. **Configure Axios API layer - CANNOT SKIP** 4. Implement protected routes --- ## Sprint 1: SSO Authentication (Week 2-3) ### 🔐 SSO Integration Overview **Important:** No login/signup/password reset screens needed! #### Backend SSO Integration (BE-101: 1.5 days) **SSO Flow Implementation:** **Step 1: Configure SSO Bridge** ```typescript // src/config/sso.ts export const ssoConfig = { authorizationURL: process.env.SSO_AUTHORIZATION_URL, // RE SSO Bridge URL tokenURL: process.env.SSO_TOKEN_URL, userInfoURL: process.env.SSO_USERINFO_URL, clientID: process.env.SSO_CLIENT_ID, clientSecret: process.env.SSO_CLIENT_SECRET, callbackURL: process.env.SSO_CALLBACK_URL, // https://workflow.re.com/api/v1/auth/callback scope: ['openid', 'profile', 'email', 'employee_info'] }; ``` **Step 2: Implement SSO Auth Service** ```typescript // src/services/auth.service.ts import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; // Configure Passport OAuth2 strategy passport.use('re-sso', new OAuth2Strategy({ authorizationURL: ssoConfig.authorizationURL, tokenURL: ssoConfig.tokenURL, clientID: ssoConfig.clientID, clientSecret: ssoConfig.clientSecret, callbackURL: ssoConfig.callbackURL }, async (accessToken, refreshToken, profile, done) => { // Get user info from SSO const userInfo = await fetchUserInfoFromSSO(accessToken); // Sync user to local database const user = await syncUserFromAD(userInfo); return done(null, user); })); async function syncUserFromAD(ssoUserInfo) { // Check if user exists in local database let user = await User.findOne({ where: { employee_id: ssoUserInfo.employeeId } }); if (!user) { // Create new user from AD info user = await User.create({ employee_id: ssoUserInfo.employeeId, email: ssoUserInfo.email, first_name: ssoUserInfo.firstName, last_name: ssoUserInfo.lastName, department: ssoUserInfo.department, designation: ssoUserInfo.designation, phone: ssoUserInfo.phone, profile_picture_url: ssoUserInfo.profilePicture }); } else { // Update existing user with latest AD info await user.update({ email: ssoUserInfo.email, first_name: ssoUserInfo.firstName, last_name: ssoUserInfo.lastName, department: ssoUserInfo.department, designation: ssoUserInfo.designation, last_login: new Date() }); } return user; } ``` **Step 3: Implement Auth Controller** ```typescript // src/controllers/auth.controller.ts export const authController = { // Initiate SSO login login: (req, res) => { // Redirect to SSO Bridge passport.authenticate('re-sso')(req, res); }, // Handle SSO callback callback: async (req, res) => { passport.authenticate('re-sso', async (err, user) => { if (err || !user) { return res.redirect('/auth/error?message=authentication_failed'); } // Generate JWT token for app session const jwtToken = generateJWT({ userId: user.user_id, email: user.email, employeeId: user.employee_id }); // Redirect to frontend with token res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${jwtToken}`); })(req, res); }, // Get current user me: async (req, res) => { const user = await User.findByPk(req.user.userId); res.json({ success: true, data: user }); }, // Logout logout: (req, res) => { // Clear session req.logout(); // Redirect to SSO logout const ssoLogoutUrl = `${ssoConfig.logoutURL}?redirect_uri=${process.env.FRONTEND_URL}`; res.json({ success: true, redirectUrl: ssoLogoutUrl }); } }; ``` #### Frontend SSO Integration (FE-101, FE-102: 1.5 days) **Frontend Work (Minimal):** **1. SSO Callback Handler (FE-101: 1 day)** ```typescript // src/pages/Auth/SSOCallback.tsx const SSOCallback = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const [error, setError] = useState(null); useEffect(() => { // Get token from URL params const params = new URLSearchParams(window.location.search); const token = params.get('token'); const errorMsg = params.get('error'); if (errorMsg) { setError('Authentication failed. Please try again.'); setTimeout(() => navigate('/'), 3000); return; } if (token) { // Store JWT token localStorage.setItem('accessToken', token); // Update Redux state dispatch(setAuthenticated(true)); // Fetch user details dispatch(fetchCurrentUser()); // Redirect to dashboard navigate('/dashboard'); } }, []); if (error) { return ; } return (

Authenticating... Please wait.

); }; ``` **2. Protected Routes (FE-101: Included in above)** ```typescript // src/routes/PrivateRoute.tsx const PrivateRoute = ({ children }) => { const { isAuthenticated } = useAppSelector(state => state.auth); if (!isAuthenticated) { // Redirect to SSO login window.location.href = `${API_BASE_URL}/auth/login`; return ; } return <>{children}; }; ``` **3. Logout Button (FE-102: 0.5 days)** ```typescript // In Header component const handleLogout = async () => { try { // Call logout API const response = await authService.logout(); // Clear local state dispatch(clearAuth()); localStorage.removeItem('accessToken'); // Redirect to SSO logout window.location.href = response.data.redirectUrl; } catch (error) { console.error('Logout failed:', error); } }; // Logout button in header ``` **4. User Profile Display (FE-103: 1 day)** ```typescript // Fetch user on app load useEffect(() => { if (isAuthenticated) { dispatch(fetchCurrentUser()); // GET /api/v1/auth/me } }, [isAuthenticated]); // Display in header dropdown {user.display_name} {user.email} {user.department} ID: {user.employee_id} Edit Profile Logout ``` **Sprint 1 Summary:** - ✅ SSO handles authentication (no login screens needed) - ✅ Frontend just handles callback and redirects - ✅ Users auto-synced from Active Directory - ✅ Simple logout button - **Total Frontend Work:** 1.5-2 days (not 4-5 days!) --- ## Sprint 2: Workflow Creation Wizard (Weeks 3-4) ### 🎯 Focus: Enable users to create workflow requests This is the **CORE FEATURE** of the application! --- ### Step 1: Template Selection (FE-202: 0.5 days) **UI Status:** ✅ Already from Figma (80%) **What to Build:** ```typescript // src/components/workflow/TemplateSelector.tsx const TemplateSelector = () => { const [selectedTemplate, setSelectedTemplate] = useState('CUSTOM'); const dispatch = useAppDispatch(); const handleSelect = (type) => { setSelectedTemplate(type); // Save to Redux dispatch(setWorkflowTemplateType(type)); }; return (
handleSelect('CUSTOM')} > 📋 Custom Request (Non-Templatized) Create a flexible workflow tailored to your specific needs. Define approval levels, TAT, and participants dynamically. Available 📄 Template Request Use predefined workflow templates for common processes. Coming Soon
); }; ``` **Deliverable:** User can select "Custom Request" --- ### Step 2: Basic Information (FE-203: 1 day) **UI Status:** ✅ Already from Figma **What to Build:** **1. Request Title Field:** ```typescript const [title, setTitle] = useState(''); const [titleError, setTitleError] = useState(''); const validateTitle = (value) => { if (!value || value.trim() === '') { setTitleError('Request title is required'); return false; } if (value.length > 500) { setTitleError('Title must be less than 500 characters'); return false; } setTitleError(''); return true; }; { setTitle(e.target.value); validateTitle(e.target.value); }} error={!!titleError} helperText={titleError || `${title.length}/500 characters`} placeholder="E.g., Approval for new office location" required fullWidth /> ``` **2. Rich Text Description:** ```typescript // Use React Quill or similar import ReactQuill from 'react-quill'; const [description, setDescription] = useState(''); const quillModules = { toolbar: [ ['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['link'], [{ 'table': 'insert-table' }], ] }; {description.length} / 5000 ``` **3. Priority Selection:** ```typescript const [priority, setPriority] = useState('STANDARD'); setPriority(e.target.value)}> } label={
Standard TAT calculated using working days (Mon-Fri, excluding weekends)
} /> } label={
Express (Urgent) TAT calculated using calendar days (includes weekends)
} />
``` **4. Save to Redux:** ```typescript const handleNext = () => { if (!validateTitle(title) || !description) { toast.error('Please fill all required fields'); return; } // Save to Redux dispatch(updateWorkflowBasicInfo({ title, description, priority })); // Go to next step setActiveStep(2); // Approval Workflow step }; ``` **Deliverable:** Basic information captured and validated --- ### Step 3: Approval Workflow Builder (FE-204: 2-3 days) **UI Status:** ✅ Already from Figma **Complexity:** ⭐⭐⭐⭐⭐ (MOST COMPLEX SCREEN) **What to Build:** **1. Dynamic Approval Levels (Core Logic)** ```typescript // State management for approval levels const [approvalLevels, setApprovalLevels] = useState([ { levelNumber: 1, levelName: '', approverId: null, approverDetails: null, tatValue: '', tatUnit: 'hours' // or 'days' } ]); // Add new level const handleAddLevel = () => { if (approvalLevels.length >= 10) { toast.error('Maximum 10 approval levels allowed'); return; } setApprovalLevels([ ...approvalLevels, { levelNumber: approvalLevels.length + 1, levelName: '', approverId: null, approverDetails: null, tatValue: '', tatUnit: 'hours' } ]); }; // Remove level const handleRemoveLevel = (levelNumber) => { if (approvalLevels.length === 1) { toast.error('At least one approval level is required'); return; } setApprovalLevels( approvalLevels .filter(level => level.levelNumber !== levelNumber) .map((level, index) => ({ ...level, levelNumber: index + 1 })) // Renumber ); }; ``` **2. User Search for Approver (@mention)** ```typescript // User search component const ApproverSearch = ({ levelIndex, onSelectUser }) => { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [loading, setLoading] = useState(false); // Debounced search useEffect(() => { const timer = setTimeout(async () => { if (searchQuery.length >= 2) { setLoading(true); try { // Call user search API const response = await userService.searchUsers(searchQuery); setSearchResults(response.data); } catch (error) { console.error('User search failed:', error); } finally { setLoading(false); } } else { setSearchResults([]); } }, 300); return () => clearTimeout(timer); }, [searchQuery]); return (
setSearchQuery(e.target.value)} InputProps={{ startAdornment: , endAdornment: loading && }} /> {searchResults.length > 0 && ( {searchResults.map(user => ( { onSelectUser(user); setSearchQuery(''); setSearchResults([]); }} > {user.display_name} {user.email} {user.department} • {user.designation} ))} )}
); }; ``` **3. TAT Input and Real-Time Calculation** ```typescript // TAT calculation logic const calculateTotalTAT = () => { let totalHours = 0; approvalLevels.forEach(level => { const tatValue = parseFloat(level.tatValue) || 0; if (level.tatUnit === 'days') { totalHours += tatValue * 24; } else { totalHours += tatValue; } }); return { totalHours, totalDays: Math.ceil(totalHours / 24), displayText: `${totalHours} hours (${Math.ceil(totalHours / 24)} days)` }; }; // Update total TAT when levels change const totalTAT = useMemo(() => calculateTotalTAT(), [approvalLevels]); // TAT display Total TAT {totalTAT.totalHours} hours {totalTAT.totalDays} days Est. Completion: {calculateExpectedDate(totalTAT.totalHours, priority)} ``` **4. Approval Level UI (For Each Level)** ```typescript {approvalLevels.map((level, index) => ( Level {level.levelNumber} {approvalLevels.length > 1 && ( handleRemoveLevel(level.levelNumber)}> )} {/* Level Name (Optional) */} updateLevel(index, 'levelName', e.target.value)} placeholder="E.g., Department Head Approval" fullWidth /> {/* Approver Selection */} updateLevel(index, 'approver', user)} /> {level.approverDetails && ( {level.approverDetails.display_name} {level.approverDetails.department} updateLevel(index, 'approver', null)}> )} {/* TAT Input */} updateLevel(index, 'tatValue', e.target.value)} placeholder="E.g., 48" required /> ))} ``` **5. Approval Flow Summary (Live Preview)** ```typescript Approval Flow Summary {approvalLevels.map((level, index) => ( {level.levelNumber} {level.levelName || `Level ${level.levelNumber}`} {level.approverDetails?.display_name || 'Not selected'} TAT: {level.tatValue} {level.tatUnit} {index < approvalLevels.length - 1 && } ))} Total TAT: {totalTAT.displayText} ``` **6. Validation Before Next Step** ```typescript const validateApprovalWorkflow = () => { // Check at least 1 level if (approvalLevels.length === 0) { toast.error('At least one approval level is required'); return false; } // Check each level for (let i = 0; i < approvalLevels.length; i++) { const level = approvalLevels[i]; // Check approver selected if (!level.approverId) { toast.error(`Please select an approver for Level ${level.levelNumber}`); return false; } // Check TAT value if (!level.tatValue || parseFloat(level.tatValue) <= 0) { toast.error(`Please enter valid TAT for Level ${level.levelNumber}`); return false; } } // Check for duplicate approvers const approverIds = approvalLevels.map(l => l.approverId); const uniqueApproverIds = new Set(approverIds); if (approverIds.length !== uniqueApproverIds.size) { toast.error('Same person cannot be approver at multiple levels'); return false; } return true; }; const handleNext = () => { if (!validateApprovalWorkflow()) { return; } // Save to Redux dispatch(setApprovalLevels(approvalLevels)); // Calculate and save total TAT dispatch(setTotalTAT(calculateTotalTAT())); // Go to next step setActiveStep(3); // Participants step }; ``` **Backend Integration (BE-201, BE-202: 2.5 days):** - POST /api/v1/workflows - Create draft workflow - POST /api/v1/workflows/:id/approvals - Add approval levels - GET /api/v1/users/search - User search for @mention **Deliverable:** Approval hierarchy builder fully functional --- ### Step 4: Participants & Access (FE-205: 1 day) **UI Status:** ✅ Already from Figma **What to Build:** **Spectator Management:** ```typescript const [spectators, setSpectators] = useState([]); // Add spectator (reuse ApproverSearch component) const handleAddSpectator = (user) => { // Check if already added if (spectators.find(s => s.user_id === user.user_id)) { toast.warning('User already added as spectator'); return; } // Check if user is already an approver const isApprover = approvalLevels.some(l => l.approverId === user.user_id); if (isApprover) { toast.warning('User is already an approver'); return; } setSpectators([...spectators, user]); toast.success(`${user.display_name} added as spectator`); }; // Remove spectator const handleRemoveSpectator = (userId) => { setSpectators(spectators.filter(s => s.user_id !== userId)); }; // Display {spectators.map(spectator => ( } label={spectator.display_name} onDelete={() => handleRemoveSpectator(spectator.user_id)} /> ))} // Save to Redux dispatch(setSpectators(spectators)); ``` **Backend Integration (BE-203: 1 day):** - POST /api/v1/workflows/:id/participants - Add spectators **Deliverable:** Spectator management functional --- ### Step 5: Documents & Attachments (FE-206: 1 day) **UI Status:** ✅ Already from Figma **What to Build:** **File Upload with react-dropzone:** ```typescript import { useDropzone } from 'react-dropzone'; const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState({}); const onDrop = useCallback((acceptedFiles, rejectedFiles) => { // Handle rejected files rejectedFiles.forEach(({ file, errors }) => { errors.forEach(error => { if (error.code === 'file-too-large') { toast.error(`${file.name} is too large. Max size: 10MB`); } else if (error.code === 'file-invalid-type') { toast.error(`${file.name} has invalid type`); } }); }); // Add accepted files setFiles(prevFiles => [...prevFiles, ...acceptedFiles]); }, []); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'application/pdf': ['.pdf'], 'application/msword': ['.doc'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], 'application/vnd.ms-excel': ['.xls'], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], 'application/vnd.ms-powerpoint': ['.ppt'], 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/png': ['.png'], 'image/gif': ['.gif'] }, maxSize: 10 * 1024 * 1024, // 10MB multiple: true }); {isDragActive ? (

Drop files here...

) : ( <>

Drag files here or click to browse

Supported: PDF, Word, Excel, PPT, Images (Max 10MB each) )}
``` **File List Display:** ```typescript {files.map((file, index) => ( {file.name} {(file.size / 1024 / 1024).toFixed(2)} MB handleRemoveFile(index)}> ))} ``` **Google Docs/Sheets Links:** ```typescript const [googleDocsLink, setGoogleDocsLink] = useState(''); const [googleSheetsLink, setGoogleSheetsLink] = useState(''); const validateGoogleLink = (url, type) => { if (!url) return true; const pattern = type === 'docs' ? /^https:\/\/docs\.google\.com\/document\// : /^https:\/\/docs\.google\.com\/spreadsheets\//; return pattern.test(url); }; setGoogleDocsLink(e.target.value)} error={!validateGoogleLink(googleDocsLink, 'docs')} helperText={!validateGoogleLink(googleDocsLink, 'docs') && 'Invalid Google Docs URL'} placeholder="https://docs.google.com/document/d/..." fullWidth /> ``` **Backend Integration (BE-401: 2 days):** - POST /api/v1/workflows/:id/documents - Upload files - File upload with Multer - Cloud storage integration (GCP or local) **Deliverable:** Document upload functional --- ### Step 6: Review & Submit (FE-207, FE-208: 2 days) **UI Status:** ✅ Already from Figma **What to Build:** **1. Complete Summary Display:** ```typescript const ReviewStep = () => { // Get all data from Redux const workflowData = useAppSelector(state => state.workflow.currentDraft); const { templateType, title, description, priority } = workflowData.basicInfo; const { levels } = workflowData.approvals; const { spectators } = workflowData.participants; const { files, googleDocsLink, googleSheetsLink } = workflowData.documents; return ( {/* Request Overview */}
Request Overview {templateType}
{/* Basic Information */}
Basic Information setActiveStep(1)}>Edit {title}
{/* Approval Workflow */}
Approval Workflow setActiveStep(2)}>Edit {levels.map((level, index) => ( {level.levelNumber} {level.approverDetails.display_name} {level.tatValue} {level.tatUnit} {index < levels.length - 1 && } ))} Total TAT: {calculateTotalTAT().displayText}
{/* Participants */}
Participants & Access setActiveStep(3)}>Edit {spectators.length} spectators {spectators.map(s => ( ))}
{/* Documents */}
Documents & Attachments setActiveStep(4)}>Edit {files.length} files uploaded {files.map((file, index) => ( {file.name} {(file.size / 1024 / 1024).toFixed(2)} MB ))} {googleDocsLink && Google Doc} {googleSheetsLink && Google Sheet}
); }; ``` **2. Submit Workflow (Complex Multi-Step API)** ```typescript const handleSubmitWorkflow = async () => { setSubmitting(true); try { // Step 1: Create workflow draft const workflowResponse = await workflowService.createWorkflow({ title: workflowData.basicInfo.title, description: workflowData.basicInfo.description, priority: workflowData.basicInfo.priority, total_levels: workflowData.approvals.levels.length, total_tat_hours: calculateTotalTAT().totalHours, is_draft: false }); const workflowId = workflowResponse.data.request_id; // Step 2: Upload documents if (files.length > 0) { for (const file of files) { const formData = new FormData(); formData.append('file', file); await documentService.uploadDocument(workflowId, formData, { onUploadProgress: (progressEvent) => { const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); setUploadProgress(prev => ({ ...prev, [file.name]: progress })); } }); } } // Step 3: Create approval levels for (const level of workflowData.approvals.levels) { await approvalService.createApprovalLevel(workflowId, { level_number: level.levelNumber, level_name: level.levelName, approver_id: level.approverId, tat_hours: level.tatUnit === 'days' ? level.tatValue * 24 : level.tatValue }); } // Step 4: Add spectators if (spectators.length > 0) { for (const spectator of spectators) { await participantService.addSpectator(workflowId, { user_id: spectator.user_id, participant_type: 'SPECTATOR' }); } } // Step 5: Submit workflow for approval await workflowService.submitWorkflow(workflowId); // Success! toast.success('Workflow submitted successfully!'); // Clear wizard state dispatch(clearWorkflowDraft()); // Navigate to request detail navigate(`/workflows/${workflowId}`); } catch (error) { console.error('Workflow submission failed:', error); toast.error(error.response?.data?.message || 'Failed to submit workflow'); } finally { setSubmitting(false); } }; ``` **3. Save as Draft:** ```typescript const handleSaveDraft = async () => { setSavingDraft(true); try { const response = await workflowService.createWorkflow({ ...workflowData, is_draft: true }); toast.success('Draft saved successfully!'); navigate('/workflows/my-requests'); } catch (error) { toast.error('Failed to save draft'); } finally { setSavingDraft(false); } }; ``` **Deliverable:** Complete workflow submission with error handling --- ## Sprint 3: Approval Actions & TAT Tracking (Weeks 5-6) ### 🎯 Focus: Enable approvers to approve/reject workflows --- ### Request Detail Page Structure (FE-303: 2 days) **UI Status:** ✅ Already from Figma **What to Build:** **1. Fetch Workflow Data on Load:** ```typescript const RequestDetail = () => { const { id } = useParams(); const dispatch = useAppDispatch(); const { currentWorkflow, loading, error } = useAppSelector(state => state.workflow); useEffect(() => { // Fetch workflow details dispatch(fetchWorkflowById(id)); // Fetch approvals dispatch(fetchApprovals(id)); // Fetch documents dispatch(fetchDocuments(id)); // Fetch activities dispatch(fetchActivities(id)); // Fetch TAT status dispatch(fetchTATStatus(id)); }, [id]); if (loading) return ; if (error) return ; if (!currentWorkflow) return ; return ( {/* Tab content */} ); }; ``` **2. TAT Progress Bar (Key Feature):** ```typescript const TATProgressBar = ({ workflow }) => { // Calculate TAT status const tatStatus = useMemo(() => { if (!workflow.tat_start_date) return null; const startTime = new Date(workflow.tat_start_date); const currentTime = new Date(); const totalTAT = workflow.total_tat_hours; const elapsedHours = (currentTime - startTime) / (1000 * 60 * 60); const percentageUsed = (elapsedHours / totalTAT) * 100; const remainingHours = totalTAT - elapsedHours; let status = 'ON_TRACK'; let color = 'success'; if (percentageUsed >= 100) { status = 'BREACHED'; color = 'error'; } else if (percentageUsed >= 80) { status = 'APPROACHING'; color = 'warning'; } return { elapsedHours: Math.floor(elapsedHours), remainingHours: Math.max(0, Math.floor(remainingHours)), percentageUsed: Math.min(100, percentageUsed), status, color }; }, [workflow]); if (!tatStatus) return null; return ( TAT Progress {tatStatus.elapsedHours} hours {tatStatus.remainingHours > 0 ? `${tatStatus.remainingHours} hours` : `${Math.abs(tatStatus.remainingHours)} hours overdue` } {tatStatus.status === 'ON_TRACK' && '✓ On Track'} {tatStatus.status === 'APPROACHING' && '⚠ Approaching Deadline'} {tatStatus.status === 'BREACHED' && '🚨 TAT Breached'} ); }; ``` --- ### Approve/Reject Modals (FE-307: 1.5 days) **UI Status:** ✅ Already from Figma **What to Build:** **Approve Request Modal:** ```typescript const ApproveModal = ({ open, onClose, workflow, currentLevel }) => { const [comments, setComments] = useState(''); const [submitting, setSubmitting] = useState(false); const dispatch = useAppDispatch(); const handleApprove = async () => { // Validate comments if (!comments || comments.trim() === '') { toast.error('Comments are required for approval'); return; } if (comments.length > 500) { toast.error('Comments must be less than 500 characters'); return; } setSubmitting(true); try { // Call approve API await approvalService.approveRequest(workflow.request_id, currentLevel.level_id, { comments: comments.trim() }); // Refresh workflow data dispatch(fetchWorkflowById(workflow.request_id)); // Success notification toast.success('Request approved successfully!'); // Close modal onClose(); } catch (error) { toast.error(error.response?.data?.message || 'Failed to approve request'); } finally { setSubmitting(false); } }; return ( Approve Request

Title: {workflow.title}

Initiator: {workflow.initiator_name}

Current Level: Level {currentLevel.level_number}

{currentLevel.is_final_approver ? "⚠️ As final approver, your approval will close this request" : "Your approval will move this request to the next level" } setComments(e.target.value)} multiline rows={4} fullWidth required error={comments.length > 500} helperText={`${comments.length}/500 characters`} />
); }; ``` **Reject Request Modal (Similar Structure):** ```typescript // Same structure as Approve, but: // - Red/danger styling // - Warning message about closure // - "Rejection Reason" instead of "Comments" // - Confirmation prompt: "Are you sure?" ``` **Backend Integration (BE-301: 2 days):** - PATCH /api/v1/workflows/:id/approvals/:levelId/approve - PATCH /api/v1/workflows/:id/approvals/:levelId/reject - Complex approval state machine logic - TAT tracking updates - Notifications to next approver/initiator **Deliverable:** Approval/rejection functionality working --- ### TAT Monitoring Cron Job (BE-302: 2 days) **Backend Implementation:** ```typescript // src/jobs/tatMonitor.job.ts import cron from 'node-cron'; // Run every 30 minutes cron.schedule('*/30 * * * *', async () => { console.log('Running TAT monitoring job...'); // Get all active workflows const activeWorkflows = await WorkflowRequest.findAll({ where: { status: ['PENDING', 'IN_PROGRESS'] }, include: [ { model: ApprovalLevel, where: { status: 'IN_PROGRESS' }, required: true } ] }); for (const workflow of activeWorkflows) { const currentLevel = workflow.approval_levels[0]; // Calculate elapsed time const elapsedHours = calculateElapsedHours( currentLevel.level_start_time, new Date(), workflow.priority // STANDARD or EXPRESS ); const percentageUsed = (elapsedHours / currentLevel.tat_hours) * 100; // Determine TAT status let tatStatus = 'ON_TRACK'; if (percentageUsed >= 100) { tatStatus = 'BREACHED'; } else if (percentageUsed >= 80) { tatStatus = 'APPROACHING'; } // Update TAT tracking await TATTracking.upsert({ request_id: workflow.request_id, level_id: currentLevel.level_id, tat_status: tatStatus, percentage_used: percentageUsed, remaining_hours: currentLevel.tat_hours - elapsedHours, checked_at: new Date() }); // Send notifications based on status if (percentageUsed >= 100 && !currentLevel.breach_alert_sent) { // TAT Breached - send urgent notification await notificationService.create({ user_id: currentLevel.approver_id, request_id: workflow.request_id, notification_type: 'TAT_BREACH', title: 'TAT BREACHED!', message: `Request ${workflow.request_number} has exceeded TAT deadline`, priority: 'URGENT' }); // Mark alert sent await currentLevel.update({ breach_alert_sent: true }); } else if (percentageUsed >= 80 && !currentLevel.warning_alert_sent) { // TAT Warning - send high priority notification await notificationService.create({ user_id: currentLevel.approver_id, request_id: workflow.request_id, notification_type: 'TAT_WARNING', title: 'TAT Warning: Action Required', message: `Request ${workflow.request_number} has used 80% of TAT`, priority: 'HIGH' }); // Mark alert sent await currentLevel.update({ warning_alert_sent: true }); } } console.log(`TAT monitoring completed. Checked ${activeWorkflows.length} workflows.`); }); ``` **Deliverable:** Automated TAT monitoring and alerts --- ## Sprint 4: Documents & Work Notes (Week 7) ### 📎 Focus: Document management and collaboration --- ### Document Management (BE-401, FE-305: 2 days total) **Backend Document Upload (BE-401: 1.5 days):** ```typescript // src/services/storage.service.ts import { Storage } from '@google-cloud/storage'; import multer from 'multer'; const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID, keyFilename: process.env.GCP_KEY_FILE }); const bucket = storage.bucket(process.env.GCP_BUCKET_NAME); export const uploadToGCP = async (file) => { const fileName = `${Date.now()}-${file.originalname}`; const blob = bucket.file(fileName); const blobStream = blob.createWriteStream({ metadata: { contentType: file.mimetype } }); return new Promise((resolve, reject) => { blobStream.on('error', reject); blobStream.on('finish', async () => { const publicUrl = `https://storage.googleapis.com/${bucket.name}/${blob.name}`; resolve({ fileName, publicUrl }); }); blobStream.end(file.buffer); }); }; // src/controllers/document.controller.ts export const documentController = { upload: async (req, res) => { const { id: requestId } = req.params; const file = req.file; // Upload to GCP const { fileName, publicUrl } = await uploadToGCP(file); // Save metadata to database const document = await Document.create({ request_id: requestId, uploaded_by: req.user.userId, file_name: file.originalname, file_type: file.mimetype, file_size: file.size, file_path: fileName, storage_url: publicUrl }); // Create activity log await Activity.create({ request_id: requestId, user_id: req.user.userId, activity_type: 'DOCUMENT_UPLOADED', activity_description: `Uploaded document: ${file.originalname}` }); res.json({ success: true, data: document }); } }; ``` **Frontend Document Display (FE-305: 0.5 days):** ```typescript const DocumentsTab = ({ workflowId }) => { const dispatch = useAppDispatch(); const { documents, loading } = useAppSelector(state => state.document); const [previewModal, setPreviewModal] = useState({ open: false, document: null }); useEffect(() => { dispatch(fetchDocuments(workflowId)); }, [workflowId]); const handlePreview = (document) => { if (document.file_type.includes('pdf') || document.file_type.includes('image')) { setPreviewModal({ open: true, document }); } else { toast.info('Preview not available. Please download to view.'); } }; const handleDownload = async (documentId) => { try { const response = await documentService.getDownloadUrl(documentId); window.open(response.data.downloadUrl, '_blank'); } catch (error) { toast.error('Failed to download document'); } }; return ( {loading && } {!loading && documents.length === 0 && (

No documents uploaded yet

)} {documents.map(doc => ( {doc.file_name} {(doc.file_size / 1024 / 1024).toFixed(2)} MB Uploaded by {doc.uploader_name}
{formatDate(doc.uploaded_at)}
{(doc.file_type.includes('pdf') || doc.file_type.includes('image')) && ( handlePreview(doc)}> )} handleDownload(doc.document_id)}>
))}
setPreviewModal({ open: false, document: null })} />
); }; ``` --- ### Work Notes / Chat (BE-402, FE-308: 2 days total) **UI Status:** ✅ Already from Figma + Partial functionality **Current:** Document selection and emoji working **Needs:** Backend API integration, @mentions, real data **Backend Work Notes API (BE-402: 1.5 days):** ```typescript // src/controllers/workNote.controller.ts export const workNoteController = { getAll: async (req, res) => { const { id: requestId } = req.params; const workNotes = await WorkNote.findAll({ where: { request_id: requestId }, include: [{ model: User, attributes: ['user_id', 'display_name', 'profile_picture_url'] }], order: [['created_at', 'ASC']] }); res.json({ success: true, data: workNotes }); }, create: async (req, res) => { const { id: requestId } = req.params; const { message } = req.body; // Parse @mentions from message const mentionedUserIds = parseMentions(message); // Create work note const workNote = await WorkNote.create({ request_id: requestId, user_id: req.user.userId, message, mentioned_users: mentionedUserIds, has_attachment: false }); // Send notifications to mentioned users for (const mentionedUserId of mentionedUserIds) { await Notification.create({ user_id: mentionedUserId, request_id: requestId, notification_type: 'WORK_NOTE_MENTION', title: `${req.user.displayName} mentioned you`, message: `"${message.substring(0, 100)}..."`, priority: 'NORMAL', action_url: `/workflows/${requestId}#work-notes` }); } // Create activity await Activity.create({ request_id: requestId, user_id: req.user.userId, activity_type: 'WORK_NOTE_ADDED', activity_description: `Added a work note` }); res.json({ success: true, data: workNote }); }, addReaction: async (req, res) => { const { noteId } = req.params; const { emoji } = req.body; const workNote = await WorkNote.findByPk(noteId); // Update reactions (JSONB column) const reactions = workNote.reactions || {}; if (!reactions[emoji]) { reactions[emoji] = []; } // Toggle reaction const userIdIndex = reactions[emoji].indexOf(req.user.userId); if (userIdIndex > -1) { reactions[emoji].splice(userIdIndex, 1); // Remove } else { reactions[emoji].push(req.user.userId); // Add } await workNote.update({ reactions }); res.json({ success: true, data: workNote }); } }; // Helper function to parse @mentions function parseMentions(message) { const mentionPattern = /@(\w+)|@"([^"]+)"/g; const mentions = []; let match; while ((match = mentionPattern.exec(message)) !== null) { const username = match[1] || match[2]; // Look up user in database const user = await User.findOne({ where: { [Op.or]: [ { email: { [Op.like]: `%${username}%` } }, { display_name: { [Op.like]: `%${username}%` } } ] } }); if (user) { mentions.push(user.user_id); } } return [...new Set(mentions)]; // Remove duplicates } ``` **Frontend Work Notes Integration (FE-308: 0.5 days):** Since UI and emoji/doc selection already work, just connect to backend: ```typescript const WorkNotes = ({ workflowId }) => { const dispatch = useAppDispatch(); const { notes, loading } = useAppSelector(state => state.workNote); const [message, setMessage] = useState(''); const [sending, setSending] = useState(false); const messagesEndRef = useRef(null); // Fetch notes on load useEffect(() => { dispatch(fetchWorkNotes(workflowId)); // Poll for new messages every 10 seconds const interval = setInterval(() => { dispatch(fetchWorkNotes(workflowId)); }, 10000); return () => clearInterval(interval); }, [workflowId]); // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [notes]); const handleSendMessage = async () => { if (!message.trim()) return; setSending(true); try { await dispatch(createWorkNote({ workflowId, message: message.trim() })); setMessage(''); // Clear input } catch (error) { toast.error('Failed to send message'); } finally { setSending(false); } }; const handleAddReaction = async (noteId, emoji) => { try { await dispatch(addReactionToNote({ noteId, emoji })); } catch (error) { toast.error('Failed to add reaction'); } }; return ( {notes.map(note => ( {note.user.display_name} {getUserRole(note.user_id)} {formatRelativeTime(note.created_at)} {note.message} {note.has_attachment && } {Object.entries(note.reactions || {}).map(([emoji, userIds]) => ( handleAddReaction(note.note_id, emoji)} /> ))} showEmojiPicker(note.note_id)} /> ))}