RE_Documents/RE_Workflow_Detailed_Sprint_Guide.md

62 KiB

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
  2. Sprint 1: SSO Authentication
  3. Sprint 2: Workflow Creation Wizard
  4. Sprint 3: Approval Actions & TAT Tracking
  5. Sprint 4: Documents & Work Notes
  6. Sprint 5: Dashboard & Analytics
  7. 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

// 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

// 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

// 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)

// 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 <ErrorMessage message={error} />;
  }
  
  return (
    <div className="sso-callback-page">
      <Spinner />
      <p>Authenticating... Please wait.</p>
    </div>
  );
};

2. Protected Routes (FE-101: Included in above)

// 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 <LoadingSpinner />;
  }
  
  return <>{children}</>;
};

3. Logout Button (FE-102: 0.5 days)

// 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
<Button onClick={handleLogout}>Logout</Button>

4. User Profile Display (FE-103: 1 day)

// Fetch user on app load
useEffect(() => {
  if (isAuthenticated) {
    dispatch(fetchCurrentUser()); // GET /api/v1/auth/me
  }
}, [isAuthenticated]);

// Display in header dropdown
<UserDropdown>
  <Avatar src={user.profile_picture_url} />
  <UserInfo>
    <Name>{user.display_name}</Name>
    <Email>{user.email}</Email>
    <Department>{user.department}</Department>
    <EmployeeID>ID: {user.employee_id}</EmployeeID>
  </UserInfo>
  <Divider />
  <MenuItem onClick={openEditProfile}>Edit Profile</MenuItem>
  <MenuItem onClick={handleLogout}>Logout</MenuItem>
</UserDropdown>

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:

// 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 (
    <div className="template-selection">
      <Card 
        className={selectedTemplate === 'CUSTOM' ? 'selected' : ''}
        onClick={() => handleSelect('CUSTOM')}
      >
        <Icon>📋</Icon>
        <Title>Custom Request (Non-Templatized)</Title>
        <Description>
          Create a flexible workflow tailored to your specific needs.
          Define approval levels, TAT, and participants dynamically.
        </Description>
        <Badge>Available</Badge>
      </Card>
      
      <Card className="disabled">
        <Icon>📄</Icon>
        <Title>Template Request</Title>
        <Description>
          Use predefined workflow templates for common processes.
        </Description>
        <Badge>Coming Soon</Badge>
      </Card>
    </div>
  );
};

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:

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;
};

<TextField
  label="Request Title"
  value={title}
  onChange={(e) => {
    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:

// 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' }],
  ]
};

<ReactQuill
  theme="snow"
  value={description}
  onChange={setDescription}
  modules={quillModules}
  placeholder="Provide detailed description of your request..."
/>
<CharacterCount>{description.length} / 5000</CharacterCount>

3. Priority Selection:

const [priority, setPriority] = useState('STANDARD');

<RadioGroup value={priority} onChange={(e) => setPriority(e.target.value)}>
  <FormControlLabel 
    value="STANDARD" 
    control={<Radio />} 
    label={
      <div>
        <strong>Standard</strong>
        <Typography variant="caption">
          TAT calculated using working days (Mon-Fri, excluding weekends)
        </Typography>
      </div>
    }
  />
  <FormControlLabel 
    value="EXPRESS" 
    control={<Radio />} 
    label={
      <div>
        <strong>Express (Urgent)</strong>
        <Typography variant="caption">
          TAT calculated using calendar days (includes weekends)
        </Typography>
      </div>
    }
  />
</RadioGroup>

4. Save to Redux:

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)

// 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)

// 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 (
    <div>
      <TextField
        placeholder="Type @ or search user name/email..."
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        InputProps={{
          startAdornment: <SearchIcon />,
          endAdornment: loading && <CircularProgress size={20} />
        }}
      />
      
      {searchResults.length > 0 && (
        <UserDropdown>
          {searchResults.map(user => (
            <UserItem 
              key={user.user_id}
              onClick={() => {
                onSelectUser(user);
                setSearchQuery('');
                setSearchResults([]);
              }}
            >
              <Avatar src={user.profile_picture_url} />
              <UserDetails>
                <Name>{user.display_name}</Name>
                <Email>{user.email}</Email>
                <Department>{user.department}  {user.designation}</Department>
              </UserDetails>
            </UserItem>
          ))}
        </UserDropdown>
      )}
    </div>
  );
};

3. TAT Input and Real-Time Calculation

// 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
<TATSummaryCard>
  <Title>Total TAT</Title>
  <TATValue>{totalTAT.totalHours} hours</TATValue>
  <TATSubtext>{totalTAT.totalDays} days</TATSubtext>
  <EstimatedCompletion>
    Est. Completion: {calculateExpectedDate(totalTAT.totalHours, priority)}
  </EstimatedCompletion>
</TATSummaryCard>

4. Approval Level UI (For Each Level)

{approvalLevels.map((level, index) => (
  <ApprovalLevelCard key={level.levelNumber}>
    <LevelHeader>
      <LevelBadge>Level {level.levelNumber}</LevelBadge>
      {approvalLevels.length > 1 && (
        <IconButton onClick={() => handleRemoveLevel(level.levelNumber)}>
          <DeleteIcon />
        </IconButton>
      )}
    </LevelHeader>
    
    {/* Level Name (Optional) */}
    <TextField
      label="Level Name (Optional)"
      value={level.levelName}
      onChange={(e) => updateLevel(index, 'levelName', e.target.value)}
      placeholder="E.g., Department Head Approval"
      fullWidth
    />
    
    {/* Approver Selection */}
    <ApproverSearch
      levelIndex={index}
      onSelectUser={(user) => updateLevel(index, 'approver', user)}
    />
    
    {level.approverDetails && (
      <SelectedApprover>
        <Avatar src={level.approverDetails.profile_picture_url} />
        <ApproverInfo>
          <Name>{level.approverDetails.display_name}</Name>
          <Department>{level.approverDetails.department}</Department>
        </ApproverInfo>
        <IconButton onClick={() => updateLevel(index, 'approver', null)}>
          <ClearIcon />
        </IconButton>
      </SelectedApprover>
    )}
    
    {/* TAT Input */}
    <TATInput>
      <TextField
        label="TAT"
        type="number"
        value={level.tatValue}
        onChange={(e) => updateLevel(index, 'tatValue', e.target.value)}
        placeholder="E.g., 48"
        required
      />
      <Select
        value={level.tatUnit}
        onChange={(e) => updateLevel(index, 'tatUnit', e.target.value)}
      >
        <MenuItem value="hours">Hours</MenuItem>
        <MenuItem value="days">Days</MenuItem>
      </Select>
    </TATInput>
  </ApprovalLevelCard>
))}

<Button onClick={handleAddLevel} disabled={approvalLevels.length >= 10}>
  + Add Approval Level
</Button>

5. Approval Flow Summary (Live Preview)

<ApprovalFlowSummary>
  <Title>Approval Flow Summary</Title>
  <Timeline>
    {approvalLevels.map((level, index) => (
      <TimelineItem key={level.levelNumber}>
        <TimelineMarker>{level.levelNumber}</TimelineMarker>
        <TimelineContent>
          <LevelName>
            {level.levelName || `Level ${level.levelNumber}`}
          </LevelName>
          <Approver>
            {level.approverDetails?.display_name || 'Not selected'}
          </Approver>
          <TAT>TAT: {level.tatValue} {level.tatUnit}</TAT>
        </TimelineContent>
        {index < approvalLevels.length - 1 && <Connector></Connector>}
      </TimelineItem>
    ))}
  </Timeline>
  <TotalTAT>
    Total TAT: {totalTAT.displayText}
  </TotalTAT>
</ApprovalFlowSummary>

6. Validation Before Next Step

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:

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
<SpectatorList>
  {spectators.map(spectator => (
    <SpectatorChip 
      key={spectator.user_id}
      avatar={<Avatar src={spectator.profile_picture_url} />}
      label={spectator.display_name}
      onDelete={() => handleRemoveSpectator(spectator.user_id)}
    />
  ))}
</SpectatorList>

// 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:

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
});

<DropZone {...getRootProps()}>
  <input {...getInputProps()} />
  {isDragActive ? (
    <p>Drop files here...</p>
  ) : (
    <>
      <UploadIcon />
      <p>Drag files here or click to browse</p>
      <small>Supported: PDF, Word, Excel, PPT, Images (Max 10MB each)</small>
    </>
  )}
</DropZone>

File List Display:

<FileList>
  {files.map((file, index) => (
    <FileItem key={index}>
      <FileIcon type={file.type} />
      <FileName>{file.name}</FileName>
      <FileSize>{(file.size / 1024 / 1024).toFixed(2)} MB</FileSize>
      <DeleteButton onClick={() => handleRemoveFile(index)}>
        <DeleteIcon />
      </DeleteButton>
    </FileItem>
  ))}
</FileList>

Google Docs/Sheets Links:

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);
};

<TextField
  label="Google Docs Link (Optional)"
  value={googleDocsLink}
  onChange={(e) => 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:

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 (
    <ReviewContainer>
      {/* Request Overview */}
      <Section>
        <SectionTitle>Request Overview</SectionTitle>
        <InfoGrid>
          <InfoItem>
            <Label>Request Type:</Label>
            <Value>{templateType}</Value>
          </InfoItem>
          <InfoItem>
            <Label>Priority:</Label>
            <Value><PriorityBadge priority={priority} /></Value>
          </InfoItem>
        </InfoGrid>
      </Section>
      
      {/* Basic Information */}
      <Section>
        <SectionTitle>
          Basic Information
          <EditButton onClick={() => setActiveStep(1)}>Edit</EditButton>
        </SectionTitle>
        <Title>{title}</Title>
        <Description dangerouslySetInnerHTML={{ __html: description }} />
      </Section>
      
      {/* Approval Workflow */}
      <Section>
        <SectionTitle>
          Approval Workflow
          <EditButton onClick={() => setActiveStep(2)}>Edit</EditButton>
        </SectionTitle>
        <ApprovalTimeline>
          {levels.map((level, index) => (
            <TimelineItem key={index}>
              <LevelNumber>{level.levelNumber}</LevelNumber>
              <ApproverName>{level.approverDetails.display_name}</ApproverName>
              <TAT>{level.tatValue} {level.tatUnit}</TAT>
              {index < levels.length - 1 && <Arrow></Arrow>}
            </TimelineItem>
          ))}
        </ApprovalTimeline>
        <TotalTAT>Total TAT: {calculateTotalTAT().displayText}</TotalTAT>
      </Section>
      
      {/* Participants */}
      <Section>
        <SectionTitle>
          Participants & Access
          <EditButton onClick={() => setActiveStep(3)}>Edit</EditButton>
        </SectionTitle>
        <SpectatorCount>{spectators.length} spectators</SpectatorCount>
        <SpectatorList>
          {spectators.map(s => (
            <Chip key={s.user_id} label={s.display_name} />
          ))}
        </SpectatorList>
      </Section>
      
      {/* Documents */}
      <Section>
        <SectionTitle>
          Documents & Attachments
          <EditButton onClick={() => setActiveStep(4)}>Edit</EditButton>
        </SectionTitle>
        <DocumentCount>{files.length} files uploaded</DocumentCount>
        <FileList>
          {files.map((file, index) => (
            <FileItem key={index}>
              <FileIcon type={file.type} />
              <FileName>{file.name}</FileName>
              <FileSize>{(file.size / 1024 / 1024).toFixed(2)} MB</FileSize>
            </FileItem>
          ))}
        </FileList>
        {googleDocsLink && <GoogleLink href={googleDocsLink}>Google Doc</GoogleLink>}
        {googleSheetsLink && <GoogleLink href={googleSheetsLink}>Google Sheet</GoogleLink>}
      </Section>
    </ReviewContainer>
  );
};

2. Submit Workflow (Complex Multi-Step API)

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:

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:

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 <RequestDetailSkeleton />;
  if (error) return <ErrorState message={error} />;
  if (!currentWorkflow) return <NotFound />;
  
  return (
    <DetailPageLayout>
      <RequestHeader workflow={currentWorkflow} />
      <TATProgressBar workflow={currentWorkflow} />
      <TabsContainer>
        <Tab label="Overview" />
        <Tab label="Workflow" />
        <Tab label="Documents" />
        <Tab label="Activity" />
      </TabsContainer>
      <TabPanels>
        {/* Tab content */}
      </TabPanels>
      <Sidebar>
        <SpectatorsList />
        <QuickActions />
      </Sidebar>
    </DetailPageLayout>
  );
};

2. TAT Progress Bar (Key Feature):

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 (
    <TATCard>
      <TATLabel>TAT Progress</TATLabel>
      <LinearProgress 
        variant="determinate" 
        value={tatStatus.percentageUsed}
        color={tatStatus.color}
      />
      <TATStats>
        <Stat>
          <Label>Elapsed:</Label>
          <Value>{tatStatus.elapsedHours} hours</Value>
        </Stat>
        <Stat>
          <Label>Remaining:</Label>
          <Value>
            {tatStatus.remainingHours > 0 
              ? `${tatStatus.remainingHours} hours`
              : `${Math.abs(tatStatus.remainingHours)} hours overdue`
            }
          </Value>
        </Stat>
        <Stat>
          <Label>Status:</Label>
          <StatusBadge status={tatStatus.status}>
            {tatStatus.status === 'ON_TRACK' && '✓ On Track'}
            {tatStatus.status === 'APPROACHING' && '⚠ Approaching Deadline'}
            {tatStatus.status === 'BREACHED' && '🚨 TAT Breached'}
          </StatusBadge>
        </Stat>
      </TATStats>
    </TATCard>
  );
};

Approve/Reject Modals (FE-307: 1.5 days)

UI Status: Already from Figma

What to Build:

Approve Request Modal:

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 (
    <Dialog open={open} onClose={onClose} maxWidth="md">
      <DialogTitle>
        Approve Request
        <Chip label={workflow.request_number} size="small" />
      </DialogTitle>
      
      <DialogContent>
        <RequestInfo>
          <p><strong>Title:</strong> {workflow.title}</p>
          <p><strong>Initiator:</strong> {workflow.initiator_name}</p>
          <p><strong>Current Level:</strong> Level {currentLevel.level_number}</p>
        </RequestInfo>
        
        <Divider />
        
        <ContextualMessage>
          {currentLevel.is_final_approver 
            ? "⚠️ As final approver, your approval will close this request"
            : "Your approval will move this request to the next level"
          }
        </ContextualMessage>
        
        <TextField
          label="Comments & Remarks *"
          placeholder="Add your approval comments..."
          value={comments}
          onChange={(e) => setComments(e.target.value)}
          multiline
          rows={4}
          fullWidth
          required
          error={comments.length > 500}
          helperText={`${comments.length}/500 characters`}
        />
      </DialogContent>
      
      <DialogActions>
        <Button onClick={onClose} disabled={submitting}>
          Cancel
        </Button>
        <Button 
          onClick={handleApprove} 
          variant="contained" 
          color="success"
          disabled={submitting || !comments.trim()}
        >
          {submitting ? <CircularProgress size={20} /> : 'Approve Request'}
        </Button>
      </DialogActions>
    </Dialog>
  );
};

Reject Request Modal (Similar Structure):

// 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:

// 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):

// 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):

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 (
    <TabPanel>
      {loading && <LoadingSkeleton />}
      
      {!loading && documents.length === 0 && (
        <EmptyState>
          <DocumentIcon />
          <p>No documents uploaded yet</p>
        </EmptyState>
      )}
      
      <DocumentGrid>
        {documents.map(doc => (
          <DocumentCard key={doc.document_id}>
            <FileTypeIcon type={doc.file_type} size="large" />
            <FileName>{doc.file_name}</FileName>
            <FileSize>{(doc.file_size / 1024 / 1024).toFixed(2)} MB</FileSize>
            <UploadInfo>
              Uploaded by {doc.uploader_name}
              <br />
              {formatDate(doc.uploaded_at)}
            </UploadInfo>
            <Actions>
              {(doc.file_type.includes('pdf') || doc.file_type.includes('image')) && (
                <IconButton onClick={() => handlePreview(doc)}>
                  <PreviewIcon />
                </IconButton>
              )}
              <IconButton onClick={() => handleDownload(doc.document_id)}>
                <DownloadIcon />
              </IconButton>
            </Actions>
          </DocumentCard>
        ))}
      </DocumentGrid>
      
      <PreviewModal 
        open={previewModal.open}
        document={previewModal.document}
        onClose={() => setPreviewModal({ open: false, document: null })}
      />
    </TabPanel>
  );
};

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):

// 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:

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 (
    <ChatContainer>
      <MessageList>
        {notes.map(note => (
          <MessageItem key={note.note_id}>
            <Avatar src={note.user.profile_picture_url} />
            <MessageContent>
              <MessageHeader>
                <UserName>{note.user.display_name}</UserName>
                <RoleBadge>{getUserRole(note.user_id)}</RoleBadge>
                <Timestamp>{formatRelativeTime(note.created_at)}</Timestamp>
              </MessageHeader>
              <MessageText>{note.message}</MessageText>
              {note.has_attachment && <AttachmentDisplay />}
              <ReactionsBar>
                {Object.entries(note.reactions || {}).map(([emoji, userIds]) => (
                  <ReactionChip 
                    key={emoji}
                    emoji={emoji}
                    count={userIds.length}
                    active={userIds.includes(currentUserId)}
                    onClick={() => handleAddReaction(note.note_id, emoji)}
                  />
                ))}
                <AddReactionButton onClick={() => showEmojiPicker(note.note_id)} />
              </ReactionsBar>
            </MessageContent>
          </MessageItem>
        ))}
        <div ref={messagesEndRef} />
      </MessageList>
      
      <MessageComposer>
        <TextArea
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder="Type your message... Use @ to mention users"
          onKeyPress={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              handleSendMessage();
            }
          }}
        />
        <Actions>
          <IconButton><EmojiIcon /></IconButton>
          <IconButton><AttachIcon /></IconButton>
          <SendButton 
            onClick={handleSendMessage}
            disabled={!message.trim() || sending}
          >
            {sending ? <CircularProgress size={20} /> : <SendIcon />}
          </SendButton>
        </Actions>
        <CharacterCount>{message.length} / 2000</CharacterCount>
      </MessageComposer>
    </ChatContainer>
  );
};

Deliverable: Fully functional work notes with @mentions, emojis, real-time updates


Sprint 5: Dashboard & Analytics (Week 8)

📊 Focus: Statistics, charts, AI conclusions


Dashboard Statistics (BE-501, FE-402: 2 days total)

Backend Dashboard API (BE-501: 1 day):

// src/services/dashboard.service.ts
export const dashboardService = {
  getStats: async (userId) => {
    // Total requests by user
    const total_requests = await WorkflowRequest.count({
      where: { initiator_id: userId }
    });
    
    // Pending approvals (where user is current approver)
    const pending_approvals = await WorkflowRequest.count({
      include: [{
        model: ApprovalLevel,
        where: {
          approver_id: userId,
          status: 'IN_PROGRESS'
        },
        required: true
      }]
    });
    
    // Approved/Rejected counts
    const approved_requests = await WorkflowRequest.count({
      where: { initiator_id: userId, status: 'APPROVED' }
    });
    
    const rejected_requests = await WorkflowRequest.count({
      where: { initiator_id: userId, status: 'REJECTED' }
    });
    
    // TAT breached
    const tat_breached = await TATTracking.count({
      where: { tat_status: 'BREACHED' }
    });
    
    // Requests by status
    const requests_by_status = await WorkflowRequest.findAll({
      attributes: [
        'status',
        [sequelize.fn('COUNT', sequelize.col('request_id')), 'count']
      ],
      where: { initiator_id: userId },
      group: ['status']
    });
    
    // Approval trend (last 30 days)
    const approval_trend = await WorkflowRequest.findAll({
      attributes: [
        [sequelize.fn('DATE', sequelize.col('submission_date')), 'date'],
        [sequelize.fn('COUNT', sequelize.col('request_id')), 'count']
      ],
      where: {
        submission_date: {
          [Op.gte]: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
        }
      },
      group: [sequelize.fn('DATE', sequelize.col('submission_date'))]
    });
    
    return {
      total_requests,
      pending_approvals,
      approved_requests,
      rejected_requests,
      tat_breached,
      requests_by_status,
      approval_trend
    };
  }
};

Frontend Dashboard (FE-402: 1 day):

import { PieChart, BarChart, LineChart } from 'recharts';

const Dashboard = () => {
  const dispatch = useAppDispatch();
  const stats = useAppSelector(state => state.dashboard.stats);
  
  useEffect(() => {
    dispatch(fetchDashboardStats());
  }, []);
  
  return (
    <DashboardLayout>
      {/* Metric Cards */}
      <MetricsRow>
        <MetricCard color="primary">
          <MetricValue>{stats.total_requests}</MetricValue>
          <MetricLabel>Total Requests</MetricLabel>
          <MetricIcon><ChartIcon /></MetricIcon>
        </MetricCard>
        
        <MetricCard color="warning">
          <MetricValue>{stats.pending_approvals}</MetricValue>
          <MetricLabel>Pending Approvals</MetricLabel>
          <MetricIcon><ClockIcon /></MetricIcon>
        </MetricCard>
        
        <MetricCard color="success">
          <MetricValue>{stats.approved_requests}</MetricValue>
          <MetricLabel>Approved</MetricLabel>
          <MetricIcon><CheckIcon /></MetricIcon>
        </MetricCard>
        
        <MetricCard color="error">
          <MetricValue>{stats.tat_breached}</MetricValue>
          <MetricLabel>TAT Breached</MetricLabel>
          <MetricIcon><AlertIcon /></MetricIcon>
        </MetricCard>
      </MetricsRow>
      
      {/* Charts */}
      <ChartsRow>
        <Card>
          <CardHeader title="Requests by Status" />
          <PieChart width={400} height={300} data={stats.requests_by_status}>
            <Pie dataKey="count" nameKey="status" />
            <Tooltip />
            <Legend />
          </PieChart>
        </Card>
        
        <Card>
          <CardHeader title="Approval Trend (30 Days)" />
          <LineChart width={600} height={300} data={stats.approval_trend}>
            <XAxis dataKey="date" />
            <YAxis />
            <Tooltip />
            <Line type="monotone" dataKey="count" stroke="#8884d8" />
          </LineChart>
        </Card>
      </ChartsRow>
    </DashboardLayout>
  );
};

Deliverable: Dashboard with real-time statistics and charts


AI Conclusion Generation (BE-502, FE-403: 2 days total)

Backend AI Service (BE-502: 1 day):

// src/services/ai.service.ts
import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY
});

export const generateConclusion = async (workflowId) => {
  // Get workflow data
  const workflow = await WorkflowRequest.findByPk(workflowId, {
    include: [
      { model: ApprovalLevel, include: [User] },
      { model: WorkNote, include: [User] }
    ]
  });
  
  // Compile approval comments
  const approvalComments = workflow.approval_levels
    .filter(l => l.status === 'APPROVED' && l.comments)
    .map(l => `Level ${l.level_number} (${l.approver.display_name}): ${l.comments}`)
    .join('\n');
  
  // Compile work notes
  const discussionPoints = workflow.work_notes
    .slice(0, 10) // Last 10 notes
    .map(n => `${n.user.display_name}: ${n.message}`)
    .join('\n');
  
  // Create AI prompt
  const prompt = `
Generate a professional conclusion for this workflow request:

Title: ${workflow.title}
Description: ${workflow.description}

Approval Journey:
${approvalComments}

Key Discussion Points:
${discussionPoints}

Please generate a concise conclusion (max 300 words) that summarizes:
1. What was requested
2. Key decision points
3. Final outcome
4. Next steps (if applicable)
  `;
  
  // Call OpenAI API
  const completion = await openai.chat.completions.create({
    model: 'gpt-4',
    messages: [{ role: 'user', content: prompt }],
    max_tokens: 500,
    temperature: 0.7
  });
  
  const conclusion = completion.choices[0].message.content;
  
  // Save to database
  await ConclusionRemark.create({
    request_id: workflowId,
    ai_generated_remark: conclusion,
    final_remark: conclusion,
    is_edited: false
  });
  
  return conclusion;
};

Frontend Conclusion Display (FE-403: 1 day):

const ConclusionSection = ({ workflowId, isInitiator }) => {
  const [conclusion, setConclusion] = useState(null);
  const [editing, setEditing] = useState(false);
  const [editedText, setEditedText] = useState('');
  
  useEffect(() => {
    fetchConclusion();
  }, [workflowId]);
  
  const fetchConclusion = async () => {
    try {
      const response = await conclusionService.getConclusion(workflowId);
      setConclusion(response.data);
      setEditedText(response.data.final_remark);
    } catch (error) {
      console.error('Failed to fetch conclusion');
    }
  };
  
  const handleSaveEdit = async () => {
    try {
      await conclusionService.updateConclusion(workflowId, {
        final_remark: editedText
      });
      
      toast.success('Conclusion updated successfully');
      setEditing(false);
      fetchConclusion();
    } catch (error) {
      toast.error('Failed to update conclusion');
    }
  };
  
  if (!conclusion) {
    return (
      <EmptyState>
        <p>Conclusion will be generated after final approval</p>
      </EmptyState>
    );
  }
  
  return (
    <ConclusionCard>
      <CardHeader>
        <Title>Conclusion Remark</Title>
        {isInitiator && !editing && (
          <IconButton onClick={() => setEditing(true)}>
            <EditIcon />
          </IconButton>
        )}
      </CardHeader>
      
      {editing ? (
        <>
          <TextField
            value={editedText}
            onChange={(e) => setEditedText(e.target.value)}
            multiline
            rows={8}
            fullWidth
          />
          <Actions>
            <Button onClick={() => setEditing(false)}>Cancel</Button>
            <Button variant="contained" onClick={handleSaveEdit}>Save</Button>
          </Actions>
        </>
      ) : (
        <>
          <ConclusionText>{conclusion.final_remark}</ConclusionText>
          {conclusion.is_edited && (
            <EditedBadge>Edited by {conclusion.edited_by_name}</EditedBadge>
          )}
        </>
      )}
    </ConclusionCard>
  );
};

Deliverable: AI-powered conclusion generation with edit capability


🎯 Key Integration Points

Frontend-Backend Integration Checklist:

For Each Feature:

  1. Backend API ready and documented
  2. Postman collection updated
  3. Frontend Redux slice created
  4. Frontend API service method created
  5. Connect UI to Redux action
  6. Handle loading state
  7. Handle success state
  8. Handle error state
  9. Add validation
  10. Test end-to-end

💡 Development Best Practices

For Frontend:

  1. Always use Redux for shared state (don't use local state for API data)
  2. Always show loading states (users need feedback)
  3. Always handle errors gracefully (show user-friendly messages)
  4. Always validate inputs (client-side validation first)
  5. Always test on mobile (responsive from start)

For Backend:

  1. Always validate inputs (use Zod schemas)
  2. Always log activities (for audit trail)
  3. Always send notifications (keep users informed)
  4. Always handle errors (return consistent error format)
  5. Always document APIs (Swagger documentation)

📈 Effort Estimation per Feature

Feature UI (Figma) Redux API Logic Testing Total
Workflow Creation Wizard Done 1 day 2 days 2 days 1 day 6 days
Request Detail Page Done 0.5 day 1.5 days 1 day 0.5 day 3.5 days
Approval Actions Done 0.5 day 1 day 1 day 0.5 day 3 days
Work Notes Done 0.5 day 0.5 day 0.5 day 0.5 day 2 days
Documents Done 0.5 day 1 day 0.5 day 0.5 day 2.5 days
Dashboard Done 0.5 day 1 day 1 day 0.5 day 3 days
Notifications Done 0.5 day 1 day 0.5 day 0.5 day 2.5 days

Total Frontend Integration Work: ~23 days (not including Sprint 0 foundation)


Document Focus: Core application features (workflow creation, approvals, work notes, documents, dashboard)
Authentication: SSO-based (no login/signup/password screens)
Prepared By: .NET Expert Team
Last Updated: October 23, 2025