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
- Sprint 0: Foundation Setup
- Sprint 1: SSO Authentication
- Sprint 2: Workflow Creation Wizard
- Sprint 3: Approval Actions & TAT Tracking
- Sprint 4: Documents & Work Notes
- Sprint 5: Dashboard & Analytics
- 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:
- Complete UI extraction (20% remaining)
- Setup Redux store - CANNOT SKIP
- Configure Axios API layer - CANNOT SKIP
- 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:
- ✅ Backend API ready and documented
- ✅ Postman collection updated
- ✅ Frontend Redux slice created
- ✅ Frontend API service method created
- ✅ Connect UI to Redux action
- ✅ Handle loading state
- ✅ Handle success state
- ✅ Handle error state
- ✅ Add validation
- ✅ Test end-to-end
💡 Development Best Practices
For Frontend:
- Always use Redux for shared state (don't use local state for API data)
- Always show loading states (users need feedback)
- Always handle errors gracefully (show user-friendly messages)
- Always validate inputs (client-side validation first)
- Always test on mobile (responsive from start)
For Backend:
- Always validate inputs (use Zod schemas)
- Always log activities (for audit trail)
- Always send notifications (keep users informed)
- Always handle errors (return consistent error format)
- 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