2286 lines
62 KiB
Markdown
2286 lines
62 KiB
Markdown
# RE Workflow Management System - Detailed Sprint Implementation Guide
|
|
## Comprehensive Feature Breakdown for Development Team
|
|
|
|
**Version:** 1.0
|
|
**Date:** October 23, 2025
|
|
**Project:** RE Workflow Management System (Non-Templatized)
|
|
**Focus:** Core Application Features (Workflow Creation, Approvals, Work Notes, Documents)
|
|
|
|
---
|
|
|
|
## 📖 Table of Contents
|
|
1. [Sprint 0: Foundation Setup](#sprint-0-foundation-setup)
|
|
2. [Sprint 1: SSO Authentication](#sprint-1-sso-authentication)
|
|
3. [Sprint 2: Workflow Creation Wizard](#sprint-2-workflow-creation-wizard)
|
|
4. [Sprint 3: Approval Actions & TAT Tracking](#sprint-3-approval-actions--tat-tracking)
|
|
5. [Sprint 4: Documents & Work Notes](#sprint-4-documents--work-notes)
|
|
6. [Sprint 5: Dashboard & Analytics](#sprint-5-dashboard--analytics)
|
|
7. [Sprint 6: Testing & Deployment](#sprint-6-testing--deployment)
|
|
|
|
---
|
|
|
|
## Sprint 0: Foundation Setup
|
|
|
|
### Backend Foundation (3 days)
|
|
|
|
**BE-001 to BE-004:** Database, Express, Sequelize setup
|
|
- See main task document for technical setup details
|
|
- Focus: Get server running with database connection
|
|
|
|
### Frontend Foundation (4 days)
|
|
|
|
**Current Status:**
|
|
- ✅ UI Components: 80% extracted from Figma
|
|
- ❌ Redux Store: Not created (2-3 days needed)
|
|
- ❌ API Service Layer: Not configured (1 day needed)
|
|
- ❌ Route Guards: Not implemented (1 day needed)
|
|
|
|
**Critical Work This Week:**
|
|
1. Complete UI extraction (20% remaining)
|
|
2. **Setup Redux store - CANNOT SKIP**
|
|
3. **Configure Axios API layer - CANNOT SKIP**
|
|
4. Implement protected routes
|
|
|
|
---
|
|
|
|
## Sprint 1: SSO Authentication (Week 2-3)
|
|
|
|
### 🔐 SSO Integration Overview
|
|
|
|
**Important:** No login/signup/password reset screens needed!
|
|
|
|
#### Backend SSO Integration (BE-101: 1.5 days)
|
|
|
|
**SSO Flow Implementation:**
|
|
|
|
**Step 1: Configure SSO Bridge**
|
|
```typescript
|
|
// src/config/sso.ts
|
|
export const ssoConfig = {
|
|
authorizationURL: process.env.SSO_AUTHORIZATION_URL, // RE SSO Bridge URL
|
|
tokenURL: process.env.SSO_TOKEN_URL,
|
|
userInfoURL: process.env.SSO_USERINFO_URL,
|
|
clientID: process.env.SSO_CLIENT_ID,
|
|
clientSecret: process.env.SSO_CLIENT_SECRET,
|
|
callbackURL: process.env.SSO_CALLBACK_URL, // https://workflow.re.com/api/v1/auth/callback
|
|
scope: ['openid', 'profile', 'email', 'employee_info']
|
|
};
|
|
```
|
|
|
|
**Step 2: Implement SSO Auth Service**
|
|
```typescript
|
|
// src/services/auth.service.ts
|
|
import passport from 'passport';
|
|
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
|
|
|
|
// Configure Passport OAuth2 strategy
|
|
passport.use('re-sso', new OAuth2Strategy({
|
|
authorizationURL: ssoConfig.authorizationURL,
|
|
tokenURL: ssoConfig.tokenURL,
|
|
clientID: ssoConfig.clientID,
|
|
clientSecret: ssoConfig.clientSecret,
|
|
callbackURL: ssoConfig.callbackURL
|
|
}, async (accessToken, refreshToken, profile, done) => {
|
|
// Get user info from SSO
|
|
const userInfo = await fetchUserInfoFromSSO(accessToken);
|
|
|
|
// Sync user to local database
|
|
const user = await syncUserFromAD(userInfo);
|
|
|
|
return done(null, user);
|
|
}));
|
|
|
|
async function syncUserFromAD(ssoUserInfo) {
|
|
// Check if user exists in local database
|
|
let user = await User.findOne({ where: { employee_id: ssoUserInfo.employeeId } });
|
|
|
|
if (!user) {
|
|
// Create new user from AD info
|
|
user = await User.create({
|
|
employee_id: ssoUserInfo.employeeId,
|
|
email: ssoUserInfo.email,
|
|
first_name: ssoUserInfo.firstName,
|
|
last_name: ssoUserInfo.lastName,
|
|
department: ssoUserInfo.department,
|
|
designation: ssoUserInfo.designation,
|
|
phone: ssoUserInfo.phone,
|
|
profile_picture_url: ssoUserInfo.profilePicture
|
|
});
|
|
} else {
|
|
// Update existing user with latest AD info
|
|
await user.update({
|
|
email: ssoUserInfo.email,
|
|
first_name: ssoUserInfo.firstName,
|
|
last_name: ssoUserInfo.lastName,
|
|
department: ssoUserInfo.department,
|
|
designation: ssoUserInfo.designation,
|
|
last_login: new Date()
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
```
|
|
|
|
**Step 3: Implement Auth Controller**
|
|
```typescript
|
|
// src/controllers/auth.controller.ts
|
|
export const authController = {
|
|
// Initiate SSO login
|
|
login: (req, res) => {
|
|
// Redirect to SSO Bridge
|
|
passport.authenticate('re-sso')(req, res);
|
|
},
|
|
|
|
// Handle SSO callback
|
|
callback: async (req, res) => {
|
|
passport.authenticate('re-sso', async (err, user) => {
|
|
if (err || !user) {
|
|
return res.redirect('/auth/error?message=authentication_failed');
|
|
}
|
|
|
|
// Generate JWT token for app session
|
|
const jwtToken = generateJWT({
|
|
userId: user.user_id,
|
|
email: user.email,
|
|
employeeId: user.employee_id
|
|
});
|
|
|
|
// Redirect to frontend with token
|
|
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${jwtToken}`);
|
|
})(req, res);
|
|
},
|
|
|
|
// Get current user
|
|
me: async (req, res) => {
|
|
const user = await User.findByPk(req.user.userId);
|
|
res.json({ success: true, data: user });
|
|
},
|
|
|
|
// Logout
|
|
logout: (req, res) => {
|
|
// Clear session
|
|
req.logout();
|
|
|
|
// Redirect to SSO logout
|
|
const ssoLogoutUrl = `${ssoConfig.logoutURL}?redirect_uri=${process.env.FRONTEND_URL}`;
|
|
res.json({ success: true, redirectUrl: ssoLogoutUrl });
|
|
}
|
|
};
|
|
```
|
|
|
|
#### Frontend SSO Integration (FE-101, FE-102: 1.5 days)
|
|
|
|
**Frontend Work (Minimal):**
|
|
|
|
**1. SSO Callback Handler (FE-101: 1 day)**
|
|
```typescript
|
|
// src/pages/Auth/SSOCallback.tsx
|
|
const SSOCallback = () => {
|
|
const navigate = useNavigate();
|
|
const dispatch = useAppDispatch();
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
// Get token from URL params
|
|
const params = new URLSearchParams(window.location.search);
|
|
const token = params.get('token');
|
|
const errorMsg = params.get('error');
|
|
|
|
if (errorMsg) {
|
|
setError('Authentication failed. Please try again.');
|
|
setTimeout(() => navigate('/'), 3000);
|
|
return;
|
|
}
|
|
|
|
if (token) {
|
|
// Store JWT token
|
|
localStorage.setItem('accessToken', token);
|
|
|
|
// Update Redux state
|
|
dispatch(setAuthenticated(true));
|
|
|
|
// Fetch user details
|
|
dispatch(fetchCurrentUser());
|
|
|
|
// Redirect to dashboard
|
|
navigate('/dashboard');
|
|
}
|
|
}, []);
|
|
|
|
if (error) {
|
|
return <ErrorMessage message={error} />;
|
|
}
|
|
|
|
return (
|
|
<div className="sso-callback-page">
|
|
<Spinner />
|
|
<p>Authenticating... Please wait.</p>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
**2. Protected Routes (FE-101: Included in above)**
|
|
```typescript
|
|
// src/routes/PrivateRoute.tsx
|
|
const PrivateRoute = ({ children }) => {
|
|
const { isAuthenticated } = useAppSelector(state => state.auth);
|
|
|
|
if (!isAuthenticated) {
|
|
// Redirect to SSO login
|
|
window.location.href = `${API_BASE_URL}/auth/login`;
|
|
return <LoadingSpinner />;
|
|
}
|
|
|
|
return <>{children}</>;
|
|
};
|
|
```
|
|
|
|
**3. Logout Button (FE-102: 0.5 days)**
|
|
```typescript
|
|
// In Header component
|
|
const handleLogout = async () => {
|
|
try {
|
|
// Call logout API
|
|
const response = await authService.logout();
|
|
|
|
// Clear local state
|
|
dispatch(clearAuth());
|
|
localStorage.removeItem('accessToken');
|
|
|
|
// Redirect to SSO logout
|
|
window.location.href = response.data.redirectUrl;
|
|
} catch (error) {
|
|
console.error('Logout failed:', error);
|
|
}
|
|
};
|
|
|
|
// Logout button in header
|
|
<Button onClick={handleLogout}>Logout</Button>
|
|
```
|
|
|
|
**4. User Profile Display (FE-103: 1 day)**
|
|
```typescript
|
|
// Fetch user on app load
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
dispatch(fetchCurrentUser()); // GET /api/v1/auth/me
|
|
}
|
|
}, [isAuthenticated]);
|
|
|
|
// Display in header dropdown
|
|
<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:**
|
|
```typescript
|
|
// src/components/workflow/TemplateSelector.tsx
|
|
const TemplateSelector = () => {
|
|
const [selectedTemplate, setSelectedTemplate] = useState('CUSTOM');
|
|
const dispatch = useAppDispatch();
|
|
|
|
const handleSelect = (type) => {
|
|
setSelectedTemplate(type);
|
|
// Save to Redux
|
|
dispatch(setWorkflowTemplateType(type));
|
|
};
|
|
|
|
return (
|
|
<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:**
|
|
```typescript
|
|
const [title, setTitle] = useState('');
|
|
const [titleError, setTitleError] = useState('');
|
|
|
|
const validateTitle = (value) => {
|
|
if (!value || value.trim() === '') {
|
|
setTitleError('Request title is required');
|
|
return false;
|
|
}
|
|
if (value.length > 500) {
|
|
setTitleError('Title must be less than 500 characters');
|
|
return false;
|
|
}
|
|
setTitleError('');
|
|
return true;
|
|
};
|
|
|
|
<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:**
|
|
```typescript
|
|
// Use React Quill or similar
|
|
import ReactQuill from 'react-quill';
|
|
|
|
const [description, setDescription] = useState('');
|
|
|
|
const quillModules = {
|
|
toolbar: [
|
|
['bold', 'italic', 'underline'],
|
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
['link'],
|
|
[{ 'table': 'insert-table' }],
|
|
]
|
|
};
|
|
|
|
<ReactQuill
|
|
theme="snow"
|
|
value={description}
|
|
onChange={setDescription}
|
|
modules={quillModules}
|
|
placeholder="Provide detailed description of your request..."
|
|
/>
|
|
<CharacterCount>{description.length} / 5000</CharacterCount>
|
|
```
|
|
|
|
**3. Priority Selection:**
|
|
```typescript
|
|
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:**
|
|
```typescript
|
|
const handleNext = () => {
|
|
if (!validateTitle(title) || !description) {
|
|
toast.error('Please fill all required fields');
|
|
return;
|
|
}
|
|
|
|
// Save to Redux
|
|
dispatch(updateWorkflowBasicInfo({
|
|
title,
|
|
description,
|
|
priority
|
|
}));
|
|
|
|
// Go to next step
|
|
setActiveStep(2); // Approval Workflow step
|
|
};
|
|
```
|
|
|
|
**Deliverable:** Basic information captured and validated
|
|
|
|
---
|
|
|
|
### Step 3: Approval Workflow Builder (FE-204: 2-3 days)
|
|
|
|
**UI Status:** ✅ Already from Figma
|
|
**Complexity:** ⭐⭐⭐⭐⭐ (MOST COMPLEX SCREEN)
|
|
|
|
**What to Build:**
|
|
|
|
**1. Dynamic Approval Levels (Core Logic)**
|
|
|
|
```typescript
|
|
// State management for approval levels
|
|
const [approvalLevels, setApprovalLevels] = useState([
|
|
{
|
|
levelNumber: 1,
|
|
levelName: '',
|
|
approverId: null,
|
|
approverDetails: null,
|
|
tatValue: '',
|
|
tatUnit: 'hours' // or 'days'
|
|
}
|
|
]);
|
|
|
|
// Add new level
|
|
const handleAddLevel = () => {
|
|
if (approvalLevels.length >= 10) {
|
|
toast.error('Maximum 10 approval levels allowed');
|
|
return;
|
|
}
|
|
|
|
setApprovalLevels([
|
|
...approvalLevels,
|
|
{
|
|
levelNumber: approvalLevels.length + 1,
|
|
levelName: '',
|
|
approverId: null,
|
|
approverDetails: null,
|
|
tatValue: '',
|
|
tatUnit: 'hours'
|
|
}
|
|
]);
|
|
};
|
|
|
|
// Remove level
|
|
const handleRemoveLevel = (levelNumber) => {
|
|
if (approvalLevels.length === 1) {
|
|
toast.error('At least one approval level is required');
|
|
return;
|
|
}
|
|
|
|
setApprovalLevels(
|
|
approvalLevels
|
|
.filter(level => level.levelNumber !== levelNumber)
|
|
.map((level, index) => ({ ...level, levelNumber: index + 1 })) // Renumber
|
|
);
|
|
};
|
|
```
|
|
|
|
**2. User Search for Approver (@mention)**
|
|
|
|
```typescript
|
|
// User search component
|
|
const ApproverSearch = ({ levelIndex, onSelectUser }) => {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Debounced search
|
|
useEffect(() => {
|
|
const timer = setTimeout(async () => {
|
|
if (searchQuery.length >= 2) {
|
|
setLoading(true);
|
|
try {
|
|
// Call user search API
|
|
const response = await userService.searchUsers(searchQuery);
|
|
setSearchResults(response.data);
|
|
} catch (error) {
|
|
console.error('User search failed:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
} else {
|
|
setSearchResults([]);
|
|
}
|
|
}, 300);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery]);
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// TAT calculation logic
|
|
const calculateTotalTAT = () => {
|
|
let totalHours = 0;
|
|
|
|
approvalLevels.forEach(level => {
|
|
const tatValue = parseFloat(level.tatValue) || 0;
|
|
|
|
if (level.tatUnit === 'days') {
|
|
totalHours += tatValue * 24;
|
|
} else {
|
|
totalHours += tatValue;
|
|
}
|
|
});
|
|
|
|
return {
|
|
totalHours,
|
|
totalDays: Math.ceil(totalHours / 24),
|
|
displayText: `${totalHours} hours (${Math.ceil(totalHours / 24)} days)`
|
|
};
|
|
};
|
|
|
|
// Update total TAT when levels change
|
|
const totalTAT = useMemo(() => calculateTotalTAT(), [approvalLevels]);
|
|
|
|
// TAT display
|
|
<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)**
|
|
|
|
```typescript
|
|
{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)**
|
|
|
|
```typescript
|
|
<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**
|
|
|
|
```typescript
|
|
const validateApprovalWorkflow = () => {
|
|
// Check at least 1 level
|
|
if (approvalLevels.length === 0) {
|
|
toast.error('At least one approval level is required');
|
|
return false;
|
|
}
|
|
|
|
// Check each level
|
|
for (let i = 0; i < approvalLevels.length; i++) {
|
|
const level = approvalLevels[i];
|
|
|
|
// Check approver selected
|
|
if (!level.approverId) {
|
|
toast.error(`Please select an approver for Level ${level.levelNumber}`);
|
|
return false;
|
|
}
|
|
|
|
// Check TAT value
|
|
if (!level.tatValue || parseFloat(level.tatValue) <= 0) {
|
|
toast.error(`Please enter valid TAT for Level ${level.levelNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check for duplicate approvers
|
|
const approverIds = approvalLevels.map(l => l.approverId);
|
|
const uniqueApproverIds = new Set(approverIds);
|
|
if (approverIds.length !== uniqueApproverIds.size) {
|
|
toast.error('Same person cannot be approver at multiple levels');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (!validateApprovalWorkflow()) {
|
|
return;
|
|
}
|
|
|
|
// Save to Redux
|
|
dispatch(setApprovalLevels(approvalLevels));
|
|
|
|
// Calculate and save total TAT
|
|
dispatch(setTotalTAT(calculateTotalTAT()));
|
|
|
|
// Go to next step
|
|
setActiveStep(3); // Participants step
|
|
};
|
|
```
|
|
|
|
**Backend Integration (BE-201, BE-202: 2.5 days):**
|
|
- POST /api/v1/workflows - Create draft workflow
|
|
- POST /api/v1/workflows/:id/approvals - Add approval levels
|
|
- GET /api/v1/users/search - User search for @mention
|
|
|
|
**Deliverable:** Approval hierarchy builder fully functional
|
|
|
|
---
|
|
|
|
### Step 4: Participants & Access (FE-205: 1 day)
|
|
|
|
**UI Status:** ✅ Already from Figma
|
|
|
|
**What to Build:**
|
|
|
|
**Spectator Management:**
|
|
```typescript
|
|
const [spectators, setSpectators] = useState([]);
|
|
|
|
// Add spectator (reuse ApproverSearch component)
|
|
const handleAddSpectator = (user) => {
|
|
// Check if already added
|
|
if (spectators.find(s => s.user_id === user.user_id)) {
|
|
toast.warning('User already added as spectator');
|
|
return;
|
|
}
|
|
|
|
// Check if user is already an approver
|
|
const isApprover = approvalLevels.some(l => l.approverId === user.user_id);
|
|
if (isApprover) {
|
|
toast.warning('User is already an approver');
|
|
return;
|
|
}
|
|
|
|
setSpectators([...spectators, user]);
|
|
toast.success(`${user.display_name} added as spectator`);
|
|
};
|
|
|
|
// Remove spectator
|
|
const handleRemoveSpectator = (userId) => {
|
|
setSpectators(spectators.filter(s => s.user_id !== userId));
|
|
};
|
|
|
|
// Display
|
|
<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:**
|
|
```typescript
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
|
const [files, setFiles] = useState([]);
|
|
const [uploadProgress, setUploadProgress] = useState({});
|
|
|
|
const onDrop = useCallback((acceptedFiles, rejectedFiles) => {
|
|
// Handle rejected files
|
|
rejectedFiles.forEach(({ file, errors }) => {
|
|
errors.forEach(error => {
|
|
if (error.code === 'file-too-large') {
|
|
toast.error(`${file.name} is too large. Max size: 10MB`);
|
|
} else if (error.code === 'file-invalid-type') {
|
|
toast.error(`${file.name} has invalid type`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add accepted files
|
|
setFiles(prevFiles => [...prevFiles, ...acceptedFiles]);
|
|
}, []);
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept: {
|
|
'application/pdf': ['.pdf'],
|
|
'application/msword': ['.doc'],
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
|
'application/vnd.ms-excel': ['.xls'],
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
|
'application/vnd.ms-powerpoint': ['.ppt'],
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
|
'image/jpeg': ['.jpg', '.jpeg'],
|
|
'image/png': ['.png'],
|
|
'image/gif': ['.gif']
|
|
},
|
|
maxSize: 10 * 1024 * 1024, // 10MB
|
|
multiple: true
|
|
});
|
|
|
|
<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:**
|
|
```typescript
|
|
<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:**
|
|
```typescript
|
|
const [googleDocsLink, setGoogleDocsLink] = useState('');
|
|
const [googleSheetsLink, setGoogleSheetsLink] = useState('');
|
|
|
|
const validateGoogleLink = (url, type) => {
|
|
if (!url) return true;
|
|
|
|
const pattern = type === 'docs'
|
|
? /^https:\/\/docs\.google\.com\/document\//
|
|
: /^https:\/\/docs\.google\.com\/spreadsheets\//;
|
|
|
|
return pattern.test(url);
|
|
};
|
|
|
|
<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:**
|
|
```typescript
|
|
const ReviewStep = () => {
|
|
// Get all data from Redux
|
|
const workflowData = useAppSelector(state => state.workflow.currentDraft);
|
|
const { templateType, title, description, priority } = workflowData.basicInfo;
|
|
const { levels } = workflowData.approvals;
|
|
const { spectators } = workflowData.participants;
|
|
const { files, googleDocsLink, googleSheetsLink } = workflowData.documents;
|
|
|
|
return (
|
|
<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)**
|
|
|
|
```typescript
|
|
const handleSubmitWorkflow = async () => {
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
// Step 1: Create workflow draft
|
|
const workflowResponse = await workflowService.createWorkflow({
|
|
title: workflowData.basicInfo.title,
|
|
description: workflowData.basicInfo.description,
|
|
priority: workflowData.basicInfo.priority,
|
|
total_levels: workflowData.approvals.levels.length,
|
|
total_tat_hours: calculateTotalTAT().totalHours,
|
|
is_draft: false
|
|
});
|
|
|
|
const workflowId = workflowResponse.data.request_id;
|
|
|
|
// Step 2: Upload documents
|
|
if (files.length > 0) {
|
|
for (const file of files) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
await documentService.uploadDocument(workflowId, formData, {
|
|
onUploadProgress: (progressEvent) => {
|
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
|
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Step 3: Create approval levels
|
|
for (const level of workflowData.approvals.levels) {
|
|
await approvalService.createApprovalLevel(workflowId, {
|
|
level_number: level.levelNumber,
|
|
level_name: level.levelName,
|
|
approver_id: level.approverId,
|
|
tat_hours: level.tatUnit === 'days' ? level.tatValue * 24 : level.tatValue
|
|
});
|
|
}
|
|
|
|
// Step 4: Add spectators
|
|
if (spectators.length > 0) {
|
|
for (const spectator of spectators) {
|
|
await participantService.addSpectator(workflowId, {
|
|
user_id: spectator.user_id,
|
|
participant_type: 'SPECTATOR'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Step 5: Submit workflow for approval
|
|
await workflowService.submitWorkflow(workflowId);
|
|
|
|
// Success!
|
|
toast.success('Workflow submitted successfully!');
|
|
|
|
// Clear wizard state
|
|
dispatch(clearWorkflowDraft());
|
|
|
|
// Navigate to request detail
|
|
navigate(`/workflows/${workflowId}`);
|
|
|
|
} catch (error) {
|
|
console.error('Workflow submission failed:', error);
|
|
toast.error(error.response?.data?.message || 'Failed to submit workflow');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
**3. Save as Draft:**
|
|
```typescript
|
|
const handleSaveDraft = async () => {
|
|
setSavingDraft(true);
|
|
|
|
try {
|
|
const response = await workflowService.createWorkflow({
|
|
...workflowData,
|
|
is_draft: true
|
|
});
|
|
|
|
toast.success('Draft saved successfully!');
|
|
navigate('/workflows/my-requests');
|
|
} catch (error) {
|
|
toast.error('Failed to save draft');
|
|
} finally {
|
|
setSavingDraft(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
**Deliverable:** Complete workflow submission with error handling
|
|
|
|
---
|
|
|
|
## Sprint 3: Approval Actions & TAT Tracking (Weeks 5-6)
|
|
|
|
### 🎯 Focus: Enable approvers to approve/reject workflows
|
|
|
|
---
|
|
|
|
### Request Detail Page Structure (FE-303: 2 days)
|
|
|
|
**UI Status:** ✅ Already from Figma
|
|
|
|
**What to Build:**
|
|
|
|
**1. Fetch Workflow Data on Load:**
|
|
```typescript
|
|
const RequestDetail = () => {
|
|
const { id } = useParams();
|
|
const dispatch = useAppDispatch();
|
|
const { currentWorkflow, loading, error } = useAppSelector(state => state.workflow);
|
|
|
|
useEffect(() => {
|
|
// Fetch workflow details
|
|
dispatch(fetchWorkflowById(id));
|
|
|
|
// Fetch approvals
|
|
dispatch(fetchApprovals(id));
|
|
|
|
// Fetch documents
|
|
dispatch(fetchDocuments(id));
|
|
|
|
// Fetch activities
|
|
dispatch(fetchActivities(id));
|
|
|
|
// Fetch TAT status
|
|
dispatch(fetchTATStatus(id));
|
|
}, [id]);
|
|
|
|
if (loading) return <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):**
|
|
```typescript
|
|
const TATProgressBar = ({ workflow }) => {
|
|
// Calculate TAT status
|
|
const tatStatus = useMemo(() => {
|
|
if (!workflow.tat_start_date) return null;
|
|
|
|
const startTime = new Date(workflow.tat_start_date);
|
|
const currentTime = new Date();
|
|
const totalTAT = workflow.total_tat_hours;
|
|
|
|
const elapsedHours = (currentTime - startTime) / (1000 * 60 * 60);
|
|
const percentageUsed = (elapsedHours / totalTAT) * 100;
|
|
const remainingHours = totalTAT - elapsedHours;
|
|
|
|
let status = 'ON_TRACK';
|
|
let color = 'success';
|
|
|
|
if (percentageUsed >= 100) {
|
|
status = 'BREACHED';
|
|
color = 'error';
|
|
} else if (percentageUsed >= 80) {
|
|
status = 'APPROACHING';
|
|
color = 'warning';
|
|
}
|
|
|
|
return {
|
|
elapsedHours: Math.floor(elapsedHours),
|
|
remainingHours: Math.max(0, Math.floor(remainingHours)),
|
|
percentageUsed: Math.min(100, percentageUsed),
|
|
status,
|
|
color
|
|
};
|
|
}, [workflow]);
|
|
|
|
if (!tatStatus) return null;
|
|
|
|
return (
|
|
<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:**
|
|
```typescript
|
|
const ApproveModal = ({ open, onClose, workflow, currentLevel }) => {
|
|
const [comments, setComments] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const dispatch = useAppDispatch();
|
|
|
|
const handleApprove = async () => {
|
|
// Validate comments
|
|
if (!comments || comments.trim() === '') {
|
|
toast.error('Comments are required for approval');
|
|
return;
|
|
}
|
|
|
|
if (comments.length > 500) {
|
|
toast.error('Comments must be less than 500 characters');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
// Call approve API
|
|
await approvalService.approveRequest(workflow.request_id, currentLevel.level_id, {
|
|
comments: comments.trim()
|
|
});
|
|
|
|
// Refresh workflow data
|
|
dispatch(fetchWorkflowById(workflow.request_id));
|
|
|
|
// Success notification
|
|
toast.success('Request approved successfully!');
|
|
|
|
// Close modal
|
|
onClose();
|
|
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.message || 'Failed to approve request');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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):**
|
|
```typescript
|
|
// Same structure as Approve, but:
|
|
// - Red/danger styling
|
|
// - Warning message about closure
|
|
// - "Rejection Reason" instead of "Comments"
|
|
// - Confirmation prompt: "Are you sure?"
|
|
```
|
|
|
|
**Backend Integration (BE-301: 2 days):**
|
|
- PATCH /api/v1/workflows/:id/approvals/:levelId/approve
|
|
- PATCH /api/v1/workflows/:id/approvals/:levelId/reject
|
|
- Complex approval state machine logic
|
|
- TAT tracking updates
|
|
- Notifications to next approver/initiator
|
|
|
|
**Deliverable:** Approval/rejection functionality working
|
|
|
|
---
|
|
|
|
### TAT Monitoring Cron Job (BE-302: 2 days)
|
|
|
|
**Backend Implementation:**
|
|
```typescript
|
|
// src/jobs/tatMonitor.job.ts
|
|
import cron from 'node-cron';
|
|
|
|
// Run every 30 minutes
|
|
cron.schedule('*/30 * * * *', async () => {
|
|
console.log('Running TAT monitoring job...');
|
|
|
|
// Get all active workflows
|
|
const activeWorkflows = await WorkflowRequest.findAll({
|
|
where: {
|
|
status: ['PENDING', 'IN_PROGRESS']
|
|
},
|
|
include: [
|
|
{
|
|
model: ApprovalLevel,
|
|
where: { status: 'IN_PROGRESS' },
|
|
required: true
|
|
}
|
|
]
|
|
});
|
|
|
|
for (const workflow of activeWorkflows) {
|
|
const currentLevel = workflow.approval_levels[0];
|
|
|
|
// Calculate elapsed time
|
|
const elapsedHours = calculateElapsedHours(
|
|
currentLevel.level_start_time,
|
|
new Date(),
|
|
workflow.priority // STANDARD or EXPRESS
|
|
);
|
|
|
|
const percentageUsed = (elapsedHours / currentLevel.tat_hours) * 100;
|
|
|
|
// Determine TAT status
|
|
let tatStatus = 'ON_TRACK';
|
|
if (percentageUsed >= 100) {
|
|
tatStatus = 'BREACHED';
|
|
} else if (percentageUsed >= 80) {
|
|
tatStatus = 'APPROACHING';
|
|
}
|
|
|
|
// Update TAT tracking
|
|
await TATTracking.upsert({
|
|
request_id: workflow.request_id,
|
|
level_id: currentLevel.level_id,
|
|
tat_status: tatStatus,
|
|
percentage_used: percentageUsed,
|
|
remaining_hours: currentLevel.tat_hours - elapsedHours,
|
|
checked_at: new Date()
|
|
});
|
|
|
|
// Send notifications based on status
|
|
if (percentageUsed >= 100 && !currentLevel.breach_alert_sent) {
|
|
// TAT Breached - send urgent notification
|
|
await notificationService.create({
|
|
user_id: currentLevel.approver_id,
|
|
request_id: workflow.request_id,
|
|
notification_type: 'TAT_BREACH',
|
|
title: 'TAT BREACHED!',
|
|
message: `Request ${workflow.request_number} has exceeded TAT deadline`,
|
|
priority: 'URGENT'
|
|
});
|
|
|
|
// Mark alert sent
|
|
await currentLevel.update({ breach_alert_sent: true });
|
|
|
|
} else if (percentageUsed >= 80 && !currentLevel.warning_alert_sent) {
|
|
// TAT Warning - send high priority notification
|
|
await notificationService.create({
|
|
user_id: currentLevel.approver_id,
|
|
request_id: workflow.request_id,
|
|
notification_type: 'TAT_WARNING',
|
|
title: 'TAT Warning: Action Required',
|
|
message: `Request ${workflow.request_number} has used 80% of TAT`,
|
|
priority: 'HIGH'
|
|
});
|
|
|
|
// Mark alert sent
|
|
await currentLevel.update({ warning_alert_sent: true });
|
|
}
|
|
}
|
|
|
|
console.log(`TAT monitoring completed. Checked ${activeWorkflows.length} workflows.`);
|
|
});
|
|
```
|
|
|
|
**Deliverable:** Automated TAT monitoring and alerts
|
|
|
|
---
|
|
|
|
## Sprint 4: Documents & Work Notes (Week 7)
|
|
|
|
### 📎 Focus: Document management and collaboration
|
|
|
|
---
|
|
|
|
### Document Management (BE-401, FE-305: 2 days total)
|
|
|
|
**Backend Document Upload (BE-401: 1.5 days):**
|
|
|
|
```typescript
|
|
// src/services/storage.service.ts
|
|
import { Storage } from '@google-cloud/storage';
|
|
import multer from 'multer';
|
|
|
|
const storage = new Storage({
|
|
projectId: process.env.GCP_PROJECT_ID,
|
|
keyFilename: process.env.GCP_KEY_FILE
|
|
});
|
|
|
|
const bucket = storage.bucket(process.env.GCP_BUCKET_NAME);
|
|
|
|
export const uploadToGCP = async (file) => {
|
|
const fileName = `${Date.now()}-${file.originalname}`;
|
|
const blob = bucket.file(fileName);
|
|
|
|
const blobStream = blob.createWriteStream({
|
|
metadata: {
|
|
contentType: file.mimetype
|
|
}
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
blobStream.on('error', reject);
|
|
blobStream.on('finish', async () => {
|
|
const publicUrl = `https://storage.googleapis.com/${bucket.name}/${blob.name}`;
|
|
resolve({ fileName, publicUrl });
|
|
});
|
|
blobStream.end(file.buffer);
|
|
});
|
|
};
|
|
|
|
// src/controllers/document.controller.ts
|
|
export const documentController = {
|
|
upload: async (req, res) => {
|
|
const { id: requestId } = req.params;
|
|
const file = req.file;
|
|
|
|
// Upload to GCP
|
|
const { fileName, publicUrl } = await uploadToGCP(file);
|
|
|
|
// Save metadata to database
|
|
const document = await Document.create({
|
|
request_id: requestId,
|
|
uploaded_by: req.user.userId,
|
|
file_name: file.originalname,
|
|
file_type: file.mimetype,
|
|
file_size: file.size,
|
|
file_path: fileName,
|
|
storage_url: publicUrl
|
|
});
|
|
|
|
// Create activity log
|
|
await Activity.create({
|
|
request_id: requestId,
|
|
user_id: req.user.userId,
|
|
activity_type: 'DOCUMENT_UPLOADED',
|
|
activity_description: `Uploaded document: ${file.originalname}`
|
|
});
|
|
|
|
res.json({ success: true, data: document });
|
|
}
|
|
};
|
|
```
|
|
|
|
**Frontend Document Display (FE-305: 0.5 days):**
|
|
|
|
```typescript
|
|
const DocumentsTab = ({ workflowId }) => {
|
|
const dispatch = useAppDispatch();
|
|
const { documents, loading } = useAppSelector(state => state.document);
|
|
const [previewModal, setPreviewModal] = useState({ open: false, document: null });
|
|
|
|
useEffect(() => {
|
|
dispatch(fetchDocuments(workflowId));
|
|
}, [workflowId]);
|
|
|
|
const handlePreview = (document) => {
|
|
if (document.file_type.includes('pdf') || document.file_type.includes('image')) {
|
|
setPreviewModal({ open: true, document });
|
|
} else {
|
|
toast.info('Preview not available. Please download to view.');
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (documentId) => {
|
|
try {
|
|
const response = await documentService.getDownloadUrl(documentId);
|
|
window.open(response.data.downloadUrl, '_blank');
|
|
} catch (error) {
|
|
toast.error('Failed to download document');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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):**
|
|
|
|
```typescript
|
|
// src/controllers/workNote.controller.ts
|
|
export const workNoteController = {
|
|
getAll: async (req, res) => {
|
|
const { id: requestId } = req.params;
|
|
|
|
const workNotes = await WorkNote.findAll({
|
|
where: { request_id: requestId },
|
|
include: [{
|
|
model: User,
|
|
attributes: ['user_id', 'display_name', 'profile_picture_url']
|
|
}],
|
|
order: [['created_at', 'ASC']]
|
|
});
|
|
|
|
res.json({ success: true, data: workNotes });
|
|
},
|
|
|
|
create: async (req, res) => {
|
|
const { id: requestId } = req.params;
|
|
const { message } = req.body;
|
|
|
|
// Parse @mentions from message
|
|
const mentionedUserIds = parseMentions(message);
|
|
|
|
// Create work note
|
|
const workNote = await WorkNote.create({
|
|
request_id: requestId,
|
|
user_id: req.user.userId,
|
|
message,
|
|
mentioned_users: mentionedUserIds,
|
|
has_attachment: false
|
|
});
|
|
|
|
// Send notifications to mentioned users
|
|
for (const mentionedUserId of mentionedUserIds) {
|
|
await Notification.create({
|
|
user_id: mentionedUserId,
|
|
request_id: requestId,
|
|
notification_type: 'WORK_NOTE_MENTION',
|
|
title: `${req.user.displayName} mentioned you`,
|
|
message: `"${message.substring(0, 100)}..."`,
|
|
priority: 'NORMAL',
|
|
action_url: `/workflows/${requestId}#work-notes`
|
|
});
|
|
}
|
|
|
|
// Create activity
|
|
await Activity.create({
|
|
request_id: requestId,
|
|
user_id: req.user.userId,
|
|
activity_type: 'WORK_NOTE_ADDED',
|
|
activity_description: `Added a work note`
|
|
});
|
|
|
|
res.json({ success: true, data: workNote });
|
|
},
|
|
|
|
addReaction: async (req, res) => {
|
|
const { noteId } = req.params;
|
|
const { emoji } = req.body;
|
|
|
|
const workNote = await WorkNote.findByPk(noteId);
|
|
|
|
// Update reactions (JSONB column)
|
|
const reactions = workNote.reactions || {};
|
|
if (!reactions[emoji]) {
|
|
reactions[emoji] = [];
|
|
}
|
|
|
|
// Toggle reaction
|
|
const userIdIndex = reactions[emoji].indexOf(req.user.userId);
|
|
if (userIdIndex > -1) {
|
|
reactions[emoji].splice(userIdIndex, 1); // Remove
|
|
} else {
|
|
reactions[emoji].push(req.user.userId); // Add
|
|
}
|
|
|
|
await workNote.update({ reactions });
|
|
|
|
res.json({ success: true, data: workNote });
|
|
}
|
|
};
|
|
|
|
// Helper function to parse @mentions
|
|
function parseMentions(message) {
|
|
const mentionPattern = /@(\w+)|@"([^"]+)"/g;
|
|
const mentions = [];
|
|
let match;
|
|
|
|
while ((match = mentionPattern.exec(message)) !== null) {
|
|
const username = match[1] || match[2];
|
|
// Look up user in database
|
|
const user = await User.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ email: { [Op.like]: `%${username}%` } },
|
|
{ display_name: { [Op.like]: `%${username}%` } }
|
|
]
|
|
}
|
|
});
|
|
|
|
if (user) {
|
|
mentions.push(user.user_id);
|
|
}
|
|
}
|
|
|
|
return [...new Set(mentions)]; // Remove duplicates
|
|
}
|
|
```
|
|
|
|
**Frontend Work Notes Integration (FE-308: 0.5 days):**
|
|
|
|
Since UI and emoji/doc selection already work, just connect to backend:
|
|
|
|
```typescript
|
|
const WorkNotes = ({ workflowId }) => {
|
|
const dispatch = useAppDispatch();
|
|
const { notes, loading } = useAppSelector(state => state.workNote);
|
|
const [message, setMessage] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const messagesEndRef = useRef(null);
|
|
|
|
// Fetch notes on load
|
|
useEffect(() => {
|
|
dispatch(fetchWorkNotes(workflowId));
|
|
|
|
// Poll for new messages every 10 seconds
|
|
const interval = setInterval(() => {
|
|
dispatch(fetchWorkNotes(workflowId));
|
|
}, 10000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [workflowId]);
|
|
|
|
// Auto-scroll to bottom
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [notes]);
|
|
|
|
const handleSendMessage = async () => {
|
|
if (!message.trim()) return;
|
|
|
|
setSending(true);
|
|
|
|
try {
|
|
await dispatch(createWorkNote({
|
|
workflowId,
|
|
message: message.trim()
|
|
}));
|
|
|
|
setMessage(''); // Clear input
|
|
|
|
} catch (error) {
|
|
toast.error('Failed to send message');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const handleAddReaction = async (noteId, emoji) => {
|
|
try {
|
|
await dispatch(addReactionToNote({ noteId, emoji }));
|
|
} catch (error) {
|
|
toast.error('Failed to add reaction');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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):**
|
|
|
|
```typescript
|
|
// 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):**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
// 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):**
|
|
|
|
```typescript
|
|
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
|
|
|