Compare commits
1 Commits
feature/cu
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e56e48f8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -78,7 +78,4 @@ $RECYCLE.BIN/
|
|||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Claude
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Worklenz Spam Protection System Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide documents the spam protection system implemented in Worklenz to prevent abuse of user invitations and registrations.
|
||||||
|
|
||||||
|
## System Components
|
||||||
|
|
||||||
|
### 1. Spam Detection (`/worklenz-backend/src/utils/spam-detector.ts`)
|
||||||
|
|
||||||
|
The core spam detection engine that analyzes text for suspicious patterns:
|
||||||
|
|
||||||
|
- **Flag-First Policy**: Suspicious content is flagged for review, not blocked
|
||||||
|
- **Selective Blocking**: Only extremely obvious spam (score > 80) gets blocked
|
||||||
|
- **URL Detection**: Identifies links, shortened URLs, and suspicious domains
|
||||||
|
- **Spam Phrases**: Detects common spam tactics (urgent, click here, win prizes)
|
||||||
|
- **Cryptocurrency Spam**: Identifies blockchain/crypto compensation scams
|
||||||
|
- **Formatting Issues**: Excessive capitals, special characters, emojis
|
||||||
|
- **Fake Name Detection**: Generic names (test, demo, fake, spam)
|
||||||
|
- **Whitelist Support**: Legitimate business names bypass all checks
|
||||||
|
- **Context-Aware**: Smart detection reduces false positives
|
||||||
|
|
||||||
|
### 2. Rate Limiting (`/worklenz-backend/src/middleware/rate-limiter.ts`)
|
||||||
|
|
||||||
|
Prevents volume-based attacks:
|
||||||
|
|
||||||
|
- **Invite Limits**: 5 invitations per 15 minutes per user
|
||||||
|
- **Organization Creation**: 3 attempts per hour
|
||||||
|
- **In-Memory Store**: Fast rate limit checking without database queries
|
||||||
|
|
||||||
|
### 3. Frontend Validation
|
||||||
|
|
||||||
|
Real-time feedback as users type:
|
||||||
|
|
||||||
|
- `/worklenz-frontend/src/components/account-setup/organization-step.tsx`
|
||||||
|
- `/worklenz-frontend/src/components/admin-center/overview/organization-name/organization-name.tsx`
|
||||||
|
- `/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx`
|
||||||
|
|
||||||
|
### 4. Backend Enforcement
|
||||||
|
|
||||||
|
Blocks spam at API level:
|
||||||
|
|
||||||
|
- **Team Members Controller**: Validates organization/owner names before invites
|
||||||
|
- **Signup Process**: Blocks spam during registration
|
||||||
|
- **Logging**: All blocked attempts sent to Slack via winston logger
|
||||||
|
|
||||||
|
### 5. Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Teams table: Simple status field
|
||||||
|
ALTER TABLE teams ADD COLUMN status VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
|
-- Moderation history tracking
|
||||||
|
CREATE TABLE team_moderation (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
team_id UUID REFERENCES teams(id),
|
||||||
|
status VARCHAR(20), -- 'flagged', 'suspended', 'restored'
|
||||||
|
reason TEXT,
|
||||||
|
moderator_id UUID,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP -- For temporary suspensions
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Spam detection logs
|
||||||
|
CREATE TABLE spam_logs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
team_id UUID,
|
||||||
|
content_type VARCHAR(50),
|
||||||
|
original_content TEXT,
|
||||||
|
spam_score INTEGER,
|
||||||
|
spam_reasons JSONB,
|
||||||
|
action_taken VARCHAR(50)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Tools
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/moderation/flagged-organizations - View flagged teams
|
||||||
|
POST /api/moderation/flag-organization - Manually flag a team
|
||||||
|
POST /api/moderation/suspend-organization - Suspend a team
|
||||||
|
POST /api/moderation/unsuspend-organization - Restore a team
|
||||||
|
GET /api/moderation/scan-spam - Scan for spam in existing data
|
||||||
|
GET /api/moderation/stats - View moderation statistics
|
||||||
|
POST /api/moderation/bulk-scan - Bulk scan and auto-flag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slack Notifications
|
||||||
|
|
||||||
|
The system sends structured alerts to Slack for:
|
||||||
|
|
||||||
|
- 🚨 **Spam Detected** (score > 30)
|
||||||
|
- 🔥 **High Risk Content** (known spam domains)
|
||||||
|
- 🛑 **Blocked Attempts** (invitations/signups)
|
||||||
|
- ⚠️ **Rate Limit Exceeded**
|
||||||
|
|
||||||
|
Example Slack notification:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alert_type": "high_risk_content",
|
||||||
|
"team_name": "CLICK LINK: gclnk.com/spam",
|
||||||
|
"user_email": "spammer@example.com",
|
||||||
|
"spam_score": 95,
|
||||||
|
"reasons": ["Contains suspicious URLs", "Contains monetary references"],
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the System
|
||||||
|
|
||||||
|
### Test Spam Patterns
|
||||||
|
|
||||||
|
These will be **FLAGGED** for review (flag-first approach):
|
||||||
|
|
||||||
|
1. **Suspicious Words**: "Free Software Solutions" (flagged but allowed)
|
||||||
|
2. **URLs**: "Visit our site: bit.ly/win-prize" (flagged but allowed)
|
||||||
|
3. **Cryptocurrency**: "🔔 $50,000 BLOCKCHAIN COMPENSATION" (flagged but allowed)
|
||||||
|
4. **Urgency**: "URGENT! Click here NOW!!!" (flagged but allowed)
|
||||||
|
5. **Generic Names**: "Test Company", "Demo Organization" (flagged but allowed)
|
||||||
|
6. **Excessive Numbers**: "Company12345" (flagged but allowed)
|
||||||
|
7. **Single Emoji**: "Great Company 💰" (flagged but allowed)
|
||||||
|
|
||||||
|
### BLOCKED Patterns (zero-tolerance - score > 80):
|
||||||
|
|
||||||
|
1. **Known Spam Domains**: "CLICK LINK: gclnk.com/spam"
|
||||||
|
2. **Extreme Scam Patterns**: "🔔CHECK $213,953 BLOCKCHAIN COMPENSATION URGENT🔔"
|
||||||
|
3. **Obvious Spam URLs**: Content with bit.ly/scam patterns
|
||||||
|
|
||||||
|
### Whitelisted (Will NOT be flagged):
|
||||||
|
|
||||||
|
1. **Legitimate Business**: "Microsoft Corporation", "Free Software Company"
|
||||||
|
2. **Standard Suffixes**: "ABC Solutions Inc", "XYZ Consulting LLC"
|
||||||
|
3. **Tech Companies**: "DataTech Services", "The Design Studio"
|
||||||
|
4. **Context-Aware**: "Free Range Marketing", "Check Point Systems"
|
||||||
|
5. **Legitimate "Test"**: "TestDrive Automotive" (not generic)
|
||||||
|
|
||||||
|
### Expected Behavior
|
||||||
|
|
||||||
|
1. **Suspicious Signup**: Flagged in logs, user allowed to proceed
|
||||||
|
2. **Obvious Spam Signup**: Blocked with user-friendly message
|
||||||
|
3. **Suspicious Invitations**: Flagged in logs, invitation sent
|
||||||
|
4. **Obvious Spam Invitations**: Blocked with support contact suggestion
|
||||||
|
5. **Frontend**: Shows warning message for suspicious content
|
||||||
|
6. **Logger**: Sends Slack notification for all suspicious activity
|
||||||
|
7. **Database**: Records all activity in spam_logs table
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
Run these SQL scripts in order:
|
||||||
|
|
||||||
|
1. `spam_protection_tables.sql` - Creates new schema
|
||||||
|
2. `fix_spam_protection_constraints.sql` - Fixes notification_settings constraints
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
No additional environment variables required. The system uses existing:
|
||||||
|
- `COOKIE_SECRET` - For session management
|
||||||
|
- Database connection settings
|
||||||
|
|
||||||
|
### Adjusting Thresholds
|
||||||
|
|
||||||
|
In `spam-detector.ts`:
|
||||||
|
```typescript
|
||||||
|
const isSpam = score >= 50; // Adjust threshold here
|
||||||
|
```
|
||||||
|
|
||||||
|
In `rate-limiter.ts`:
|
||||||
|
```typescript
|
||||||
|
inviteRateLimit(5, 15 * 60 * 1000) // 5 requests per 15 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Spam Statistics
|
||||||
|
```sql
|
||||||
|
SELECT * FROM moderation_dashboard;
|
||||||
|
SELECT COUNT(*) FROM spam_logs WHERE created_at > NOW() - INTERVAL '24 hours';
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Rate Limit Events
|
||||||
|
```sql
|
||||||
|
SELECT * FROM rate_limit_log WHERE blocked = true ORDER BY created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Legitimate users blocked
|
||||||
|
|
||||||
|
1. Check spam_logs for their content
|
||||||
|
2. Adjust spam patterns or scoring threshold
|
||||||
|
3. Whitelist specific domains if needed
|
||||||
|
|
||||||
|
### Issue: Notification settings error during signup
|
||||||
|
|
||||||
|
Run the fix script: `fix_spam_protection_constraints.sql`
|
||||||
|
|
||||||
|
### Issue: Slack notifications not received
|
||||||
|
|
||||||
|
1. Check winston logger configuration
|
||||||
|
2. Verify log levels in `logger.ts`
|
||||||
|
3. Ensure Slack webhook is configured
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Machine Learning**: Train on spam_logs data
|
||||||
|
2. **IP Blocking**: Geographic or reputation-based blocking
|
||||||
|
3. **CAPTCHA Integration**: For suspicious signups
|
||||||
|
4. **Email Verification**: Stronger email validation
|
||||||
|
5. **Allowlist Management**: Pre-approved domains
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Logs contain sensitive data - ensure proper access controls
|
||||||
|
- Rate limit data stored in memory - consider Redis for scaling
|
||||||
|
- Spam patterns should be regularly updated
|
||||||
|
- Monitor for false positives and adjust accordingly
|
||||||
41
test_sort_fix.sql
Normal file
41
test_sort_fix.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- Test script to verify the sort order constraint fix
|
||||||
|
|
||||||
|
-- Test the helper function
|
||||||
|
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
|
||||||
|
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
|
||||||
|
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
|
||||||
|
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
|
||||||
|
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
|
||||||
|
|
||||||
|
-- Test bulk update function (example - would need real project_id and task_ids)
|
||||||
|
/*
|
||||||
|
SELECT update_task_sort_orders_bulk(
|
||||||
|
'[
|
||||||
|
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
|
||||||
|
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
|
||||||
|
]'::json,
|
||||||
|
'status'
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Verify that sort_order constraint still exists and works
|
||||||
|
SELECT
|
||||||
|
tc.constraint_name,
|
||||||
|
tc.table_name,
|
||||||
|
kcu.column_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
WHERE tc.constraint_name = 'tasks_sort_order_unique';
|
||||||
|
|
||||||
|
-- Check that new sort order columns don't have unique constraints (which is correct)
|
||||||
|
SELECT
|
||||||
|
tc.constraint_name,
|
||||||
|
tc.table_name,
|
||||||
|
kcu.column_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
WHERE kcu.table_name = 'tasks'
|
||||||
|
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
||||||
|
AND tc.constraint_type = 'UNIQUE';
|
||||||
30
test_sort_orders.sql
Normal file
30
test_sort_orders.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Test script to validate the separate sort order implementation
|
||||||
|
|
||||||
|
-- Check if new columns exist
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tasks'
|
||||||
|
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
|
||||||
|
ORDER BY column_name;
|
||||||
|
|
||||||
|
-- Check if helper function exists
|
||||||
|
SELECT routine_name, routine_type
|
||||||
|
FROM information_schema.routines
|
||||||
|
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
|
||||||
|
|
||||||
|
-- Sample test data to verify different sort orders work
|
||||||
|
-- (This would be run after the migrations)
|
||||||
|
/*
|
||||||
|
-- Test: Tasks should have different orders for different groupings
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
sort_order,
|
||||||
|
status_sort_order,
|
||||||
|
priority_sort_order,
|
||||||
|
phase_sort_order,
|
||||||
|
member_sort_order
|
||||||
|
FROM tasks
|
||||||
|
WHERE project_id = '<test-project-id>'
|
||||||
|
ORDER BY status_sort_order;
|
||||||
|
*/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Fix for notification_settings constraint issue during signup
|
||||||
|
-- This makes the team_id nullable temporarily during user creation
|
||||||
|
|
||||||
|
-- First, drop the existing NOT NULL constraint
|
||||||
|
ALTER TABLE notification_settings
|
||||||
|
ALTER COLUMN team_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Add a constraint that ensures team_id is not null when there's no ongoing signup
|
||||||
|
ALTER TABLE notification_settings
|
||||||
|
ADD CONSTRAINT notification_settings_team_id_check
|
||||||
|
CHECK (team_id IS NOT NULL OR user_id IS NOT NULL);
|
||||||
|
|
||||||
|
-- Update the notification_settings trigger to handle null team_id gracefully
|
||||||
|
CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS
|
||||||
|
$$
|
||||||
|
BEGIN
|
||||||
|
-- Only insert if team_id is not null
|
||||||
|
IF NEW.team_id IS NOT NULL AND
|
||||||
|
(NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND
|
||||||
|
(NEW.active = TRUE)
|
||||||
|
THEN
|
||||||
|
INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id,
|
||||||
|
email_notifications_enabled, team_id, daily_digest_enabled)
|
||||||
|
VALUES (TRUE, TRUE, NEW.user_id, TRUE, NEW.team_id, FALSE);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Also update the teams table to ensure the status column doesn't interfere with signup
|
||||||
|
ALTER TABLE teams
|
||||||
|
DROP CONSTRAINT IF EXISTS teams_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE teams
|
||||||
|
ADD CONSTRAINT teams_status_check
|
||||||
|
CHECK (status IS NULL OR status IN ('active', 'flagged', 'suspended'));
|
||||||
|
|
||||||
|
-- Set default value for status
|
||||||
|
ALTER TABLE teams
|
||||||
|
ALTER COLUMN status SET DEFAULT 'active';
|
||||||
|
|
||||||
|
-- Update existing null values
|
||||||
|
UPDATE teams SET status = 'active' WHERE status IS NULL;
|
||||||
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
-- Add minimal status column to teams table for performance
|
||||||
|
ALTER TABLE teams
|
||||||
|
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'flagged', 'suspended'));
|
||||||
|
|
||||||
|
-- Create separate moderation table for detailed tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS team_moderation (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) NOT NULL CHECK (status IN ('flagged', 'suspended', 'restored')),
|
||||||
|
reason TEXT,
|
||||||
|
moderator_id UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP, -- For temporary suspensions
|
||||||
|
metadata JSONB -- For additional context
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for efficient querying
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_moderation_team_id ON team_moderation(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_moderation_status ON team_moderation(status, created_at);
|
||||||
|
|
||||||
|
-- Create spam_logs table to track spam detection events
|
||||||
|
CREATE TABLE IF NOT EXISTS spam_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
content_type VARCHAR(50) NOT NULL, -- 'organization_name', 'owner_name', 'invitation'
|
||||||
|
original_content TEXT NOT NULL,
|
||||||
|
sanitized_content TEXT,
|
||||||
|
spam_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
spam_reasons JSONB,
|
||||||
|
is_high_risk BOOLEAN DEFAULT FALSE,
|
||||||
|
action_taken VARCHAR(50), -- 'blocked', 'flagged', 'allowed'
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
ip_address INET
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for spam logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spam_logs_team_id ON spam_logs(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spam_logs_created_at ON spam_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spam_logs_content_type ON spam_logs(content_type);
|
||||||
|
|
||||||
|
-- Create rate_limit_log table to track rate limiting events
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
action_type VARCHAR(50) NOT NULL, -- 'invite_attempt', 'org_creation'
|
||||||
|
blocked BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for rate limit logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_user_id ON rate_limit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_created_at ON rate_limit_log(created_at);
|
||||||
|
|
||||||
|
-- Add admin flag to users table if it doesn't exist
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Function to log spam detection
|
||||||
|
CREATE OR REPLACE FUNCTION log_spam_detection(
|
||||||
|
p_team_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_content_type VARCHAR(50),
|
||||||
|
p_original_content TEXT,
|
||||||
|
p_sanitized_content TEXT,
|
||||||
|
p_spam_score INTEGER,
|
||||||
|
p_spam_reasons JSONB,
|
||||||
|
p_is_high_risk BOOLEAN,
|
||||||
|
p_action_taken VARCHAR(50),
|
||||||
|
p_ip_address INET
|
||||||
|
) RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO spam_logs (
|
||||||
|
team_id, user_id, content_type, original_content, sanitized_content,
|
||||||
|
spam_score, spam_reasons, is_high_risk, action_taken, ip_address
|
||||||
|
) VALUES (
|
||||||
|
p_team_id, p_user_id, p_content_type, p_original_content, p_sanitized_content,
|
||||||
|
p_spam_score, p_spam_reasons, p_is_high_risk, p_action_taken, p_ip_address
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to log rate limiting events
|
||||||
|
CREATE OR REPLACE FUNCTION log_rate_limit_event(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_ip_address INET,
|
||||||
|
p_action_type VARCHAR(50),
|
||||||
|
p_blocked BOOLEAN
|
||||||
|
) RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO rate_limit_log (user_id, ip_address, action_type, blocked)
|
||||||
|
VALUES (p_user_id, p_ip_address, p_action_type, p_blocked);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to get spam statistics for a team
|
||||||
|
CREATE OR REPLACE FUNCTION get_team_spam_stats(p_team_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_detections BIGINT,
|
||||||
|
high_risk_detections BIGINT,
|
||||||
|
blocked_actions BIGINT,
|
||||||
|
latest_detection TIMESTAMP
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_detections,
|
||||||
|
COUNT(*) FILTER (WHERE is_high_risk = TRUE) as high_risk_detections,
|
||||||
|
COUNT(*) FILTER (WHERE action_taken = 'blocked') as blocked_actions,
|
||||||
|
MAX(created_at) as latest_detection
|
||||||
|
FROM spam_logs
|
||||||
|
WHERE team_id = p_team_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- View for easy moderation dashboard
|
||||||
|
CREATE OR REPLACE VIEW moderation_dashboard AS
|
||||||
|
SELECT
|
||||||
|
t.id as team_id,
|
||||||
|
t.name as organization_name,
|
||||||
|
u.name as owner_name,
|
||||||
|
u.email as owner_email,
|
||||||
|
t.created_at as team_created_at,
|
||||||
|
t.status as current_status,
|
||||||
|
tm.status as last_moderation_action,
|
||||||
|
tm.reason as last_moderation_reason,
|
||||||
|
tm.created_at as last_moderation_date,
|
||||||
|
tm.expires_at as suspension_expires_at,
|
||||||
|
moderator.name as moderator_name,
|
||||||
|
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count,
|
||||||
|
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id) as spam_detection_count,
|
||||||
|
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id AND is_high_risk = TRUE) as high_risk_count
|
||||||
|
FROM teams t
|
||||||
|
INNER JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN team_moderation tm ON t.id = tm.team_id
|
||||||
|
AND tm.created_at = (SELECT MAX(created_at) FROM team_moderation WHERE team_id = t.id)
|
||||||
|
LEFT JOIN users moderator ON tm.moderator_id = moderator.id
|
||||||
|
WHERE t.status != 'active' OR EXISTS(
|
||||||
|
SELECT 1 FROM spam_logs WHERE team_id = t.id AND created_at > NOW() - INTERVAL '7 days'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Function to update team status and create moderation records
|
||||||
|
CREATE OR REPLACE FUNCTION update_team_status(
|
||||||
|
p_team_id UUID,
|
||||||
|
p_new_status VARCHAR(20),
|
||||||
|
p_reason TEXT,
|
||||||
|
p_moderator_id UUID DEFAULT NULL,
|
||||||
|
p_expires_at TIMESTAMP DEFAULT NULL
|
||||||
|
) RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Update team status
|
||||||
|
UPDATE teams SET status = p_new_status WHERE id = p_team_id;
|
||||||
|
|
||||||
|
-- Insert moderation record
|
||||||
|
INSERT INTO team_moderation (team_id, status, reason, moderator_id, expires_at)
|
||||||
|
VALUES (p_team_id, p_new_status, p_reason, p_moderator_id, p_expires_at);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to automatically flag teams with high spam scores
|
||||||
|
CREATE OR REPLACE FUNCTION auto_flag_spam_teams()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Auto-flag teams if they have high spam scores or multiple violations
|
||||||
|
IF NEW.spam_score > 80 OR NEW.is_high_risk = TRUE THEN
|
||||||
|
PERFORM update_team_status(
|
||||||
|
NEW.team_id,
|
||||||
|
'flagged',
|
||||||
|
'Auto-flagged: High spam score or high-risk content detected',
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to check and restore expired suspensions
|
||||||
|
CREATE OR REPLACE FUNCTION restore_expired_suspensions() RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Find teams with expired suspensions
|
||||||
|
UPDATE teams
|
||||||
|
SET status = 'active'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT DISTINCT tm.team_id
|
||||||
|
FROM team_moderation tm
|
||||||
|
WHERE tm.status = 'suspended'
|
||||||
|
AND tm.expires_at IS NOT NULL
|
||||||
|
AND tm.expires_at < NOW()
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM team_moderation tm2
|
||||||
|
WHERE tm2.team_id = tm.team_id
|
||||||
|
AND tm2.created_at > tm.created_at
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Log restoration records
|
||||||
|
INSERT INTO team_moderation (team_id, status, reason, moderator_id)
|
||||||
|
SELECT DISTINCT tm.team_id, 'restored', 'Auto-restored: suspension expired', NULL
|
||||||
|
FROM team_moderation tm
|
||||||
|
WHERE tm.status = 'suspended'
|
||||||
|
AND tm.expires_at IS NOT NULL
|
||||||
|
AND tm.expires_at < NOW()
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM team_moderation tm2
|
||||||
|
WHERE tm2.team_id = tm.team_id
|
||||||
|
AND tm2.created_at > tm.created_at
|
||||||
|
AND tm2.status = 'restored'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger for auto-flagging
|
||||||
|
DROP TRIGGER IF EXISTS trigger_auto_flag_spam ON spam_logs;
|
||||||
|
CREATE TRIGGER trigger_auto_flag_spam
|
||||||
|
AFTER INSERT ON spam_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION auto_flag_spam_teams();
|
||||||
@@ -94,194 +94,4 @@ export default class GanttController extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const projectId = req.query.project_id;
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
t.start_date,
|
|
||||||
t.end_date,
|
|
||||||
t.done,
|
|
||||||
t.roadmap_sort_order,
|
|
||||||
t.parent_task_id,
|
|
||||||
CASE WHEN t.done THEN 100 ELSE 0 END as progress,
|
|
||||||
ts.name as status_name,
|
|
||||||
tsc.color_code as status_color,
|
|
||||||
tp.name as priority_name,
|
|
||||||
tp.value as priority_value,
|
|
||||||
tp.color_code as priority_color,
|
|
||||||
(
|
|
||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
tm.id as team_member_id,
|
|
||||||
u.name as assignee_name,
|
|
||||||
u.avatar_url
|
|
||||||
FROM tasks_assignees ta
|
|
||||||
JOIN team_members tm ON ta.team_member_id = tm.id
|
|
||||||
JOIN users u ON tm.user_id = u.id
|
|
||||||
WHERE ta.task_id = t.id
|
|
||||||
) assignee_info
|
|
||||||
) as assignees,
|
|
||||||
(
|
|
||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
pp.id as phase_id,
|
|
||||||
pp.name as phase_name,
|
|
||||||
pp.color_code as phase_color
|
|
||||||
FROM task_phase tp
|
|
||||||
JOIN project_phases pp ON tp.phase_id = pp.id
|
|
||||||
WHERE tp.task_id = t.id
|
|
||||||
) phase_info
|
|
||||||
) as phases,
|
|
||||||
(
|
|
||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
td.related_task_id,
|
|
||||||
td.dependency_type,
|
|
||||||
rt.name as related_task_name
|
|
||||||
FROM task_dependencies td
|
|
||||||
JOIN tasks rt ON td.related_task_id = rt.id
|
|
||||||
WHERE td.task_id = t.id
|
|
||||||
) dependency_info
|
|
||||||
) as dependencies
|
|
||||||
FROM tasks t
|
|
||||||
LEFT JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
|
|
||||||
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
|
|
||||||
WHERE t.project_id = $1
|
|
||||||
AND t.archived = FALSE
|
|
||||||
AND t.parent_task_id IS NULL
|
|
||||||
ORDER BY t.roadmap_sort_order, t.created_at DESC;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q, [projectId]);
|
|
||||||
|
|
||||||
// Get subtasks for each parent task
|
|
||||||
for (const task of result.rows) {
|
|
||||||
const subtasksQuery = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
done,
|
|
||||||
roadmap_sort_order,
|
|
||||||
parent_task_id,
|
|
||||||
CASE WHEN done THEN 100 ELSE 0 END as progress
|
|
||||||
FROM tasks
|
|
||||||
WHERE parent_task_id = $1
|
|
||||||
AND archived = FALSE
|
|
||||||
ORDER BY roadmap_sort_order, created_at DESC;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const subtasksResult = await db.query(subtasksQuery, [task.id]);
|
|
||||||
task.subtasks = subtasksResult.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
|
||||||
}
|
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const projectId = req.query.project_id;
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
SELECT
|
|
||||||
pp.id,
|
|
||||||
pp.name,
|
|
||||||
pp.color_code,
|
|
||||||
pp.start_date,
|
|
||||||
pp.end_date,
|
|
||||||
pp.sort_index,
|
|
||||||
-- Calculate task counts by status category for progress
|
|
||||||
COALESCE(
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks t
|
|
||||||
JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
|
||||||
WHERE tp.phase_id = pp.id
|
|
||||||
AND t.archived = FALSE
|
|
||||||
AND stsc.is_todo = TRUE), 0
|
|
||||||
) as todo_count,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks t
|
|
||||||
JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
|
||||||
WHERE tp.phase_id = pp.id
|
|
||||||
AND t.archived = FALSE
|
|
||||||
AND stsc.is_doing = TRUE), 0
|
|
||||||
) as doing_count,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks t
|
|
||||||
JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
|
||||||
WHERE tp.phase_id = pp.id
|
|
||||||
AND t.archived = FALSE
|
|
||||||
AND stsc.is_done = TRUE), 0
|
|
||||||
) as done_count,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks t
|
|
||||||
JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
WHERE tp.phase_id = pp.id
|
|
||||||
AND t.archived = FALSE), 0
|
|
||||||
) as total_count
|
|
||||||
FROM project_phases pp
|
|
||||||
WHERE pp.project_id = $1
|
|
||||||
ORDER BY pp.sort_index, pp.created_at;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q, [projectId]);
|
|
||||||
|
|
||||||
// Calculate progress percentages for each phase
|
|
||||||
const phasesWithProgress = result.rows.map(phase => {
|
|
||||||
const total = parseInt(phase.total_count) || 0;
|
|
||||||
const todoCount = parseInt(phase.todo_count) || 0;
|
|
||||||
const doingCount = parseInt(phase.doing_count) || 0;
|
|
||||||
const doneCount = parseInt(phase.done_count) || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: phase.id,
|
|
||||||
name: phase.name,
|
|
||||||
color_code: phase.color_code,
|
|
||||||
start_date: phase.start_date,
|
|
||||||
end_date: phase.end_date,
|
|
||||||
sort_index: phase.sort_index,
|
|
||||||
// Calculate progress percentages
|
|
||||||
todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0,
|
|
||||||
doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0,
|
|
||||||
done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0,
|
|
||||||
total_tasks: total
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, phasesWithProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const { task_id, start_date, end_date } = req.body;
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
UPDATE tasks
|
|
||||||
SET start_date = $2, end_date = $3, updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, start_date, end_date;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q, [task_id, start_date, end_date]);
|
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
|
import db from "../config/db";
|
||||||
|
import { SpamDetector } from "../utils/spam-detector";
|
||||||
|
import { RateLimiter } from "../middleware/rate-limiter";
|
||||||
|
|
||||||
|
export default class ModerationController extends WorklenzControllerBase {
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getFlaggedOrganizations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT * FROM moderation_dashboard
|
||||||
|
ORDER BY last_moderation_date DESC
|
||||||
|
LIMIT 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q);
|
||||||
|
|
||||||
|
// Add spam analysis to each result
|
||||||
|
const flaggedTeams = result.rows.map(team => {
|
||||||
|
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||||
|
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
org_spam_score: orgSpamCheck.score,
|
||||||
|
org_spam_reasons: orgSpamCheck.reasons,
|
||||||
|
owner_spam_score: ownerSpamCheck.score,
|
||||||
|
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||||
|
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||||
|
SpamDetector.isHighRiskContent(team.owner_name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, flaggedTeams));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async flagOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, reason } = req.body;
|
||||||
|
if (!teamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||||
|
const result = await db.query(q, [teamId, reason || 'Spam/Abuse', req.user.id]);
|
||||||
|
|
||||||
|
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||||
|
const teamResult = await db.query(teamQuery, [teamId]);
|
||||||
|
|
||||||
|
if (teamResult.rows.length === 0) {
|
||||||
|
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization flagged successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async suspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId, reason, expiresAt } = req.body;
|
||||||
|
if (!teamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `SELECT update_team_status($1, 'suspended', $2, $3, $4) as result`;
|
||||||
|
const result = await db.query(q, [teamId, reason || 'Terms of Service Violation', req.user.id, expiresAt || null]);
|
||||||
|
|
||||||
|
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||||
|
const teamResult = await db.query(teamQuery, [teamId]);
|
||||||
|
|
||||||
|
if (teamResult.rows.length === 0) {
|
||||||
|
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization suspended successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async unsuspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamId } = req.body;
|
||||||
|
if (!teamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `SELECT update_team_status($1, 'active', 'Manually restored by admin', $2) as result`;
|
||||||
|
const result = await db.query(q, [teamId, req.user.id]);
|
||||||
|
|
||||||
|
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||||
|
const teamResult = await db.query(teamQuery, [teamId]);
|
||||||
|
|
||||||
|
if (teamResult.rows.length === 0) {
|
||||||
|
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization restored successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async scanForSpam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT t.id, t.name as organization_name, u.name as owner_name, u.email as owner_email,
|
||||||
|
t.created_at
|
||||||
|
FROM teams t
|
||||||
|
INNER JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.status = 'active'
|
||||||
|
AND t.created_at > NOW() - INTERVAL '7 days'
|
||||||
|
ORDER BY t.created_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q);
|
||||||
|
const suspiciousTeams = [];
|
||||||
|
|
||||||
|
for (const team of result.rows) {
|
||||||
|
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||||
|
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||||
|
|
||||||
|
if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam ||
|
||||||
|
SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||||
|
SpamDetector.isHighRiskContent(team.owner_name)) {
|
||||||
|
|
||||||
|
suspiciousTeams.push({
|
||||||
|
...team,
|
||||||
|
org_spam_score: orgSpamCheck.score,
|
||||||
|
org_spam_reasons: orgSpamCheck.reasons,
|
||||||
|
owner_spam_score: ownerSpamCheck.score,
|
||||||
|
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||||
|
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||||
|
SpamDetector.isHighRiskContent(team.owner_name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, {
|
||||||
|
total_scanned: result.rows.length,
|
||||||
|
suspicious_count: suspiciousTeams.length,
|
||||||
|
suspicious_teams: suspiciousTeams
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getModerationStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsQuery = `
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM teams WHERE status = 'flagged') as flagged_count,
|
||||||
|
(SELECT COUNT(*) FROM teams WHERE status = 'suspended') as suspended_count,
|
||||||
|
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '24 hours') as new_teams_24h,
|
||||||
|
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '7 days') as new_teams_7d
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(statsQuery);
|
||||||
|
const stats = result.rows[0];
|
||||||
|
|
||||||
|
// Get rate limiting stats for recent activity
|
||||||
|
const recentInviteActivity = RateLimiter.getStats(req.user?.id || '');
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, {
|
||||||
|
...stats,
|
||||||
|
rate_limit_stats: recentInviteActivity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async bulkScanAndFlag(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
if (!req.user?.is_admin) {
|
||||||
|
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { autoFlag = false } = req.body;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT t.id, t.name as organization_name, u.name as owner_name
|
||||||
|
FROM teams t
|
||||||
|
INNER JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.status = 'active'
|
||||||
|
AND t.created_at > NOW() - INTERVAL '30 days'
|
||||||
|
LIMIT 1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q);
|
||||||
|
const flaggedTeams = [];
|
||||||
|
|
||||||
|
for (const team of result.rows) {
|
||||||
|
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||||
|
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||||
|
const isHighRisk = SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||||
|
SpamDetector.isHighRiskContent(team.owner_name);
|
||||||
|
|
||||||
|
if ((orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 || isHighRisk) && autoFlag) {
|
||||||
|
// Auto-flag high-confidence spam
|
||||||
|
const reasons = [
|
||||||
|
...orgSpamCheck.reasons,
|
||||||
|
...ownerSpamCheck.reasons,
|
||||||
|
...(isHighRisk ? ['High-risk content detected'] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const flagQuery = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||||
|
await db.query(flagQuery, [
|
||||||
|
team.id,
|
||||||
|
`Auto-flagged: ${reasons.join(', ')}`,
|
||||||
|
req.user.id
|
||||||
|
]);
|
||||||
|
|
||||||
|
flaggedTeams.push({
|
||||||
|
...team,
|
||||||
|
action: 'flagged',
|
||||||
|
reasons: reasons
|
||||||
|
});
|
||||||
|
} else if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam || isHighRisk) {
|
||||||
|
flaggedTeams.push({
|
||||||
|
...team,
|
||||||
|
action: 'review_needed',
|
||||||
|
org_spam_score: orgSpamCheck.score,
|
||||||
|
owner_spam_score: ownerSpamCheck.score,
|
||||||
|
reasons: [...orgSpamCheck.reasons, ...ownerSpamCheck.reasons, ...(isHighRisk ? ['High-risk content'] : [])]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, {
|
||||||
|
total_scanned: result.rows.length,
|
||||||
|
auto_flagged: flaggedTeams.filter(t => t.action === 'flagged').length,
|
||||||
|
needs_review: flaggedTeams.filter(t => t.action === 'review_needed').length,
|
||||||
|
teams: flaggedTeams
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } f
|
|||||||
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||||
import { updateUsers } from "../shared/paddle-requests";
|
import { updateUsers } from "../shared/paddle-requests";
|
||||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||||
|
import { SpamDetector } from "../utils/spam-detector";
|
||||||
|
import loggerModule from "../utils/logger";
|
||||||
|
|
||||||
|
const { logger } = loggerModule;
|
||||||
|
|
||||||
export default class TeamMembersController extends WorklenzControllerBase {
|
export default class TeamMembersController extends WorklenzControllerBase {
|
||||||
|
|
||||||
@@ -72,7 +76,8 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions({
|
@HandleExceptions({
|
||||||
raisedExceptions: {
|
raisedExceptions: {
|
||||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
|
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
|
||||||
|
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
@@ -82,6 +87,54 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate organization name for spam - Flag suspicious, block only obvious spam
|
||||||
|
const orgSpamCheck = SpamDetector.detectSpam(req.user?.team_name || '');
|
||||||
|
const ownerSpamCheck = SpamDetector.detectSpam(req.user?.name || '');
|
||||||
|
|
||||||
|
// Only block extremely suspicious content for invitations (higher threshold)
|
||||||
|
const isObviousSpam = orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 ||
|
||||||
|
SpamDetector.isHighRiskContent(req.user?.team_name || '') ||
|
||||||
|
SpamDetector.isHighRiskContent(req.user?.name || '');
|
||||||
|
|
||||||
|
if (isObviousSpam) {
|
||||||
|
logger.error('🛑 INVITATION BLOCKED - OBVIOUS SPAM', {
|
||||||
|
user_id: req.user?.id,
|
||||||
|
user_email: req.user?.email,
|
||||||
|
team_id: req.user?.team_id,
|
||||||
|
team_name: req.user?.team_name,
|
||||||
|
owner_name: req.user?.name,
|
||||||
|
org_spam_score: orgSpamCheck.score,
|
||||||
|
owner_spam_score: ownerSpamCheck.score,
|
||||||
|
org_reasons: orgSpamCheck.reasons,
|
||||||
|
owner_reasons: ownerSpamCheck.reasons,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'obvious_spam_invitation_blocked'
|
||||||
|
});
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Invitations temporarily disabled. Please contact support for assistance.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log suspicious but allow invitations
|
||||||
|
if (orgSpamCheck.score > 0 || ownerSpamCheck.score > 0) {
|
||||||
|
logger.warn('⚠️ SUSPICIOUS INVITATION ATTEMPT', {
|
||||||
|
user_id: req.user?.id,
|
||||||
|
user_email: req.user?.email,
|
||||||
|
team_id: req.user?.team_id,
|
||||||
|
team_name: req.user?.team_name,
|
||||||
|
owner_name: req.user?.name,
|
||||||
|
org_spam_score: orgSpamCheck.score,
|
||||||
|
owner_spam_score: ownerSpamCheck.score,
|
||||||
|
org_reasons: orgSpamCheck.reasons,
|
||||||
|
owner_reasons: ownerSpamCheck.reasons,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'suspicious_invitation_flagged'
|
||||||
|
});
|
||||||
|
// Continue with invitation but flag for review
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-risk content already checked above in isObviousSpam condition
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the subscription status of the team.
|
* Checks the subscription status of the team.
|
||||||
* @type {Object} subscriptionData - Object containing subscription information
|
* @type {Object} subscriptionData - Object containing subscription information
|
||||||
|
|||||||
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { NextFunction } from "express";
|
||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import loggerModule from "../utils/logger";
|
||||||
|
|
||||||
|
const { logger } = loggerModule;
|
||||||
|
|
||||||
|
interface RateLimitStore {
|
||||||
|
[key: string]: {
|
||||||
|
count: number;
|
||||||
|
resetTime: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private static store: RateLimitStore = {};
|
||||||
|
private static cleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
static {
|
||||||
|
// Clean up expired entries every 5 minutes
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
Object.keys(this.store).forEach(key => {
|
||||||
|
if (this.store[key].resetTime < now) {
|
||||||
|
delete this.store[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static inviteRateLimit(
|
||||||
|
maxRequests = 5,
|
||||||
|
windowMs: number = 15 * 60 * 1000 // 15 minutes
|
||||||
|
) {
|
||||||
|
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||||
|
const identifier = req.user?.id || req.ip;
|
||||||
|
const key = `invite_${identifier}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||||
|
this.store[key] = {
|
||||||
|
count: 1,
|
||||||
|
resetTime: now + windowMs
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.store[key].count >= maxRequests) {
|
||||||
|
const remainingTime = Math.ceil((this.store[key].resetTime - now) / 1000);
|
||||||
|
|
||||||
|
// Log rate limit exceeded for Slack notifications
|
||||||
|
logger.warn("⚠️ RATE LIMIT EXCEEDED - INVITE ATTEMPTS", {
|
||||||
|
user_id: req.user?.id,
|
||||||
|
user_email: req.user?.email,
|
||||||
|
ip_address: req.ip,
|
||||||
|
attempts: this.store[key].count,
|
||||||
|
max_attempts: maxRequests,
|
||||||
|
remaining_time: remainingTime,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: "rate_limit_exceeded"
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(429).send(
|
||||||
|
new ServerResponse(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
`Too many invitation attempts. Please try again in ${remainingTime} seconds.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store[key].count++;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static organizationCreationRateLimit(
|
||||||
|
maxRequests = 3,
|
||||||
|
windowMs: number = 60 * 60 * 1000 // 1 hour
|
||||||
|
) {
|
||||||
|
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||||
|
const identifier = req.user?.id || req.ip;
|
||||||
|
const key = `org_creation_${identifier}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||||
|
this.store[key] = {
|
||||||
|
count: 1,
|
||||||
|
resetTime: now + windowMs
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.store[key].count >= maxRequests) {
|
||||||
|
const remainingTime = Math.ceil((this.store[key].resetTime - now) / (1000 * 60));
|
||||||
|
|
||||||
|
// Log organization creation rate limit exceeded
|
||||||
|
logger.warn("⚠️ RATE LIMIT EXCEEDED - ORG CREATION", {
|
||||||
|
user_id: req.user?.id,
|
||||||
|
user_email: req.user?.email,
|
||||||
|
ip_address: req.ip,
|
||||||
|
attempts: this.store[key].count,
|
||||||
|
max_attempts: maxRequests,
|
||||||
|
remaining_time_minutes: remainingTime,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: "org_creation_rate_limit"
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(429).send(
|
||||||
|
new ServerResponse(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
`Too many organization creation attempts. Please try again in ${remainingTime} minutes.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store[key].count++;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getStats(identifier: string): { invites: number; orgCreations: number } {
|
||||||
|
const inviteKey = `invite_${identifier}`;
|
||||||
|
const orgKey = `org_creation_${identifier}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
invites: this.store[inviteKey]?.count || 0,
|
||||||
|
orgCreations: this.store[orgKey]?.count || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clearStats(identifier: string): void {
|
||||||
|
const inviteKey = `invite_${identifier}`;
|
||||||
|
const orgKey = `org_creation_${identifier}`;
|
||||||
|
|
||||||
|
delete this.store[inviteKey];
|
||||||
|
delete this.store[orgKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import {log_error} from "../../shared/utils";
|
|||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import {Request} from "express";
|
import {Request} from "express";
|
||||||
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
|
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
|
||||||
|
import { SpamDetector } from "../../utils/spam-detector";
|
||||||
|
import loggerModule from "../../utils/logger";
|
||||||
|
|
||||||
|
const { logger } = loggerModule;
|
||||||
|
|
||||||
async function isGoogleAccountFound(email: string) {
|
async function isGoogleAccountFound(email: string) {
|
||||||
const q = `
|
const q = `
|
||||||
@@ -49,12 +53,111 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
|||||||
|
|
||||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||||
|
|
||||||
|
// Check for spam in team name - Flag suspicious but allow signup
|
||||||
|
const teamNameSpamCheck = SpamDetector.detectSpam(team_name);
|
||||||
|
if (teamNameSpamCheck.score > 0 || teamNameSpamCheck.reasons.length > 0) {
|
||||||
|
logger.warn('⚠️ SUSPICIOUS SIGNUP - TEAM NAME', {
|
||||||
|
email,
|
||||||
|
team_name,
|
||||||
|
user_name: name,
|
||||||
|
spam_score: teamNameSpamCheck.score,
|
||||||
|
reasons: teamNameSpamCheck.reasons,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'suspicious_signup_flagged'
|
||||||
|
});
|
||||||
|
// Continue with signup but flag for review
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for spam in user name - Flag suspicious but allow signup
|
||||||
|
const userNameSpamCheck = SpamDetector.detectSpam(name);
|
||||||
|
if (userNameSpamCheck.score > 0 || userNameSpamCheck.reasons.length > 0) {
|
||||||
|
logger.warn('⚠️ SUSPICIOUS SIGNUP - USER NAME', {
|
||||||
|
email,
|
||||||
|
team_name,
|
||||||
|
user_name: name,
|
||||||
|
spam_score: userNameSpamCheck.score,
|
||||||
|
reasons: userNameSpamCheck.reasons,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'suspicious_signup_flagged'
|
||||||
|
});
|
||||||
|
// Continue with signup but flag for review
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only block EXTREMELY high-risk content (known spam domains, obvious scams)
|
||||||
|
if (SpamDetector.isHighRiskContent(team_name) || SpamDetector.isHighRiskContent(name)) {
|
||||||
|
// Check if it's REALLY obvious spam (very high scores)
|
||||||
|
const isObviousSpam = teamNameSpamCheck.score > 80 || userNameSpamCheck.score > 80 ||
|
||||||
|
/gclnk\.com|bit\.ly\/scam|win.*\$\d+.*crypto/i.test(team_name + ' ' + name);
|
||||||
|
|
||||||
|
if (isObviousSpam) {
|
||||||
|
logger.error('🛑 SIGNUP BLOCKED - OBVIOUS SPAM', {
|
||||||
|
email,
|
||||||
|
team_name,
|
||||||
|
user_name: name,
|
||||||
|
team_spam_score: teamNameSpamCheck.score,
|
||||||
|
user_spam_score: userNameSpamCheck.score,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'obvious_spam_blocked'
|
||||||
|
});
|
||||||
|
return done(null, null, req.flash(ERROR_KEY, "Registration temporarily unavailable. Please contact support if you need immediate access."));
|
||||||
|
} else {
|
||||||
|
// High-risk but not obviously spam - flag and allow
|
||||||
|
logger.error('🔥 HIGH RISK SIGNUP - FLAGGED', {
|
||||||
|
email,
|
||||||
|
team_name,
|
||||||
|
user_name: name,
|
||||||
|
team_spam_score: teamNameSpamCheck.score,
|
||||||
|
user_spam_score: userNameSpamCheck.score,
|
||||||
|
ip_address: req.ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: 'high_risk_signup_flagged'
|
||||||
|
});
|
||||||
|
// Continue with signup but flag for immediate review
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const googleAccountFound = await isGoogleAccountFound(email);
|
const googleAccountFound = await isGoogleAccountFound(email);
|
||||||
if (googleAccountFound)
|
if (googleAccountFound)
|
||||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||||
|
|
||||||
|
// If signup was suspicious, flag the team for review after creation
|
||||||
|
const totalSuspicionScore = (teamNameSpamCheck.score || 0) + (userNameSpamCheck.score || 0);
|
||||||
|
if (totalSuspicionScore > 0) {
|
||||||
|
// Flag team for admin review (but don't block user)
|
||||||
|
const flagQuery = `
|
||||||
|
INSERT INTO spam_logs (team_id, user_id, content_type, original_content, spam_score, spam_reasons, action_taken, ip_address)
|
||||||
|
VALUES (
|
||||||
|
(SELECT team_id FROM users WHERE id = $1),
|
||||||
|
$1,
|
||||||
|
'signup_review',
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
'flagged_for_review',
|
||||||
|
$5
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.query(flagQuery, [
|
||||||
|
user.id,
|
||||||
|
`Team: ${team_name} | User: ${name}`,
|
||||||
|
totalSuspicionScore,
|
||||||
|
JSON.stringify([...teamNameSpamCheck.reasons, ...userNameSpamCheck.reasons]),
|
||||||
|
req.ip
|
||||||
|
]);
|
||||||
|
} catch (flagError) {
|
||||||
|
// Don't fail signup if flagging fails
|
||||||
|
logger.warn('Failed to flag suspicious signup for review', { error: flagError, user_id: user.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendWelcomeEmail(email, name);
|
sendWelcomeEmail(email, name);
|
||||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -12,9 +12,4 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
|
|||||||
|
|
||||||
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
||||||
|
|
||||||
// New roadmap Gantt APIs
|
|
||||||
ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks));
|
|
||||||
ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases));
|
|
||||||
ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates));
|
|
||||||
|
|
||||||
export default ganttApiRouter;
|
export default ganttApiRouter;
|
||||||
@@ -60,6 +60,7 @@ import taskRecurringApiRouter from "./task-recurring-api-router";
|
|||||||
|
|
||||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||||
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
||||||
|
import moderationApiRouter from "./moderation-api-router";
|
||||||
|
|
||||||
const api = express.Router();
|
const api = express.Router();
|
||||||
|
|
||||||
@@ -122,4 +123,5 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
|||||||
api.use("/custom-columns", customColumnsApiRouter);
|
api.use("/custom-columns", customColumnsApiRouter);
|
||||||
|
|
||||||
api.use("/logs", userActivityLogsApiRouter);
|
api.use("/logs", userActivityLogsApiRouter);
|
||||||
|
api.use("/moderation", moderationApiRouter);
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import express from "express";
|
||||||
|
import ModerationController from "../../controllers/moderation-controller";
|
||||||
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
|
||||||
|
const moderationApiRouter = express.Router();
|
||||||
|
|
||||||
|
// Admin-only routes for spam/abuse moderation
|
||||||
|
moderationApiRouter.get("/flagged-organizations", safeControllerFunction(ModerationController.getFlaggedOrganizations));
|
||||||
|
moderationApiRouter.post("/flag-organization", safeControllerFunction(ModerationController.flagOrganization));
|
||||||
|
moderationApiRouter.post("/suspend-organization", safeControllerFunction(ModerationController.suspendOrganization));
|
||||||
|
moderationApiRouter.post("/unsuspend-organization", safeControllerFunction(ModerationController.unsuspendOrganization));
|
||||||
|
moderationApiRouter.get("/scan-spam", safeControllerFunction(ModerationController.scanForSpam));
|
||||||
|
moderationApiRouter.get("/stats", safeControllerFunction(ModerationController.getModerationStats));
|
||||||
|
moderationApiRouter.post("/bulk-scan", safeControllerFunction(ModerationController.bulkScanAndFlag));
|
||||||
|
|
||||||
|
export default moderationApiRouter;
|
||||||
@@ -6,6 +6,7 @@ import idParamValidator from "../../middlewares/validators/id-param-validator";
|
|||||||
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
||||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
import { RateLimiter } from "../../middleware/rate-limiter";
|
||||||
|
|
||||||
const teamMembersApiRouter = express.Router();
|
const teamMembersApiRouter = express.Router();
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ const teamMembersApiRouter = express.Router();
|
|||||||
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
||||||
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
||||||
|
|
||||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||||
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
|
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
|
||||||
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
||||||
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
||||||
@@ -30,6 +31,6 @@ teamMembersApiRouter.put("/:id", teamOwnerOrAdminValidator, idParamValidator, sa
|
|||||||
teamMembersApiRouter.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
|
teamMembersApiRouter.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
|
||||||
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
|
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
|
||||||
|
|
||||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||||
|
|
||||||
export default teamMembersApiRouter;
|
export default teamMembersApiRouter;
|
||||||
|
|||||||
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import loggerModule from "./logger";
|
||||||
|
|
||||||
|
const { logger } = loggerModule;
|
||||||
|
|
||||||
|
export interface SpamDetectionResult {
|
||||||
|
isSpam: boolean;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpamDetector {
|
||||||
|
// Whitelist for legitimate organizations that might trigger false positives
|
||||||
|
private static readonly WHITELIST_PATTERNS = [
|
||||||
|
/^(microsoft|google|apple|amazon|facebook|meta|twitter|linkedin|github|stackoverflow)$/i,
|
||||||
|
/^.*(inc|llc|ltd|corp|corporation|company|co|group|enterprises|solutions|services|consulting|tech|technologies|agency|studio|lab|labs|systems|software|development|designs?)$/i,
|
||||||
|
// Allow "free" when it's clearly about software/business
|
||||||
|
/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i,
|
||||||
|
/(open|free).*(software|source)/i,
|
||||||
|
// Common legitimate business patterns
|
||||||
|
/^[a-z]+\s+(software|solutions|services|consulting|tech|technologies|systems|development|designs?|agency|studio|labs?|group|company)$/i,
|
||||||
|
/^(the\s+)?[a-z]+\s+(company|group|studio|agency|lab|labs)$/i
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly SPAM_PATTERNS = [
|
||||||
|
// URLs and links
|
||||||
|
/https?:\/\//i,
|
||||||
|
/www\./i,
|
||||||
|
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf|cc|to|us|biz|info|xyz)\b/i,
|
||||||
|
|
||||||
|
// Common spam phrases
|
||||||
|
/click\s*(here|link|now)/i,
|
||||||
|
/urgent|emergency|immediate|limited.time/i,
|
||||||
|
/win|won|winner|prize|reward|congratulations/i,
|
||||||
|
/free|bonus|gift|offer|special.offer/i,
|
||||||
|
/check\s*(out|this|pay)|verify|claim/i,
|
||||||
|
/blockchain|crypto|bitcoin|compensation|investment/i,
|
||||||
|
/cash|money|dollars?|\$\d+|earn.*money/i,
|
||||||
|
|
||||||
|
// Excessive special characters
|
||||||
|
/[!]{2,}/,
|
||||||
|
/[🔔⬅👆💰$💎🎁🎉⚡]{1,}/,
|
||||||
|
/\b[A-Z]{4,}\b/,
|
||||||
|
|
||||||
|
// Suspicious formatting
|
||||||
|
/\s{3,}/,
|
||||||
|
/[.]{3,}/,
|
||||||
|
|
||||||
|
// Additional suspicious patterns
|
||||||
|
/act.now|don.t.miss|guaranteed|limited.spots/i,
|
||||||
|
/download|install|app|software/i,
|
||||||
|
/survey|questionnaire|feedback/i,
|
||||||
|
/\d+%.*off|save.*\$|discount/i
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly SUSPICIOUS_WORDS = [
|
||||||
|
"urgent", "emergency", "click", "link", "win", "winner", "prize",
|
||||||
|
"free", "bonus", "cash", "money", "blockchain", "crypto", "compensation",
|
||||||
|
"check", "pay", "reward", "offer", "gift", "congratulations", "claim",
|
||||||
|
"verify", "earn", "investment", "guaranteed", "limited", "exclusive",
|
||||||
|
"download", "install", "survey", "feedback", "discount", "save"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static detectSpam(text: string): SpamDetectionResult {
|
||||||
|
if (!text || typeof text !== "string") {
|
||||||
|
return { isSpam: false, score: 0, reasons: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = text.toLowerCase().trim();
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Check for obviously fake organization names FIRST (before whitelist)
|
||||||
|
if (/^(test|example|demo|fake|spam|abuse|temp)\s*(company|org|corp|inc|llc)?$/i.test(text.trim()) ||
|
||||||
|
/(test|demo|fake|spam|abuse|temp)\s*(123|abc|xyz|\d+)/i.test(text)) {
|
||||||
|
score += 30;
|
||||||
|
reasons.push("Contains generic/test name patterns");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whitelist - bypass remaining checks for whitelisted organizations
|
||||||
|
if (score === 0) { // Only check whitelist if no generic patterns found
|
||||||
|
for (const pattern of this.WHITELIST_PATTERNS) {
|
||||||
|
if (pattern.test(normalizedText)) {
|
||||||
|
return { isSpam: false, score: 0, reasons: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for URL patterns
|
||||||
|
for (const pattern of this.SPAM_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
score += 25; // Lowered from 30 to catch more suspicious content
|
||||||
|
if (pattern.toString().includes("https?") || pattern.toString().includes("www")) {
|
||||||
|
reasons.push("Contains suspicious URLs or links");
|
||||||
|
} else if (pattern.toString().includes("urgent|emergency")) {
|
||||||
|
reasons.push("Contains urgent/emergency language");
|
||||||
|
} else if (pattern.toString().includes("win|won|winner")) {
|
||||||
|
reasons.push("Contains prize/winning language");
|
||||||
|
} else if (pattern.toString().includes("cash|money")) {
|
||||||
|
reasons.push("Contains monetary references");
|
||||||
|
} else if (pattern.toString().includes("blockchain|crypto")) {
|
||||||
|
reasons.push("Contains cryptocurrency references");
|
||||||
|
} else if (pattern.toString().includes("[!]{3,}")) {
|
||||||
|
reasons.push("Excessive use of exclamation marks");
|
||||||
|
} else if (pattern.toString().includes("[🔔⬅👆💰$]")) {
|
||||||
|
reasons.push("Contains suspicious emojis or symbols");
|
||||||
|
} else if (pattern.toString().includes("[A-Z]{5,}")) {
|
||||||
|
reasons.push("Contains excessive capital letters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for excessive suspicious words - Now with context awareness
|
||||||
|
const suspiciousWords = this.SUSPICIOUS_WORDS.filter(word => {
|
||||||
|
if (!normalizedText.includes(word)) return false;
|
||||||
|
|
||||||
|
// Context-aware filtering for common false positives
|
||||||
|
if (word === 'free') {
|
||||||
|
// Allow "free" in legitimate software/business contexts
|
||||||
|
return !/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word === 'check') {
|
||||||
|
// Allow "check" in legitimate business contexts
|
||||||
|
return !/check.*(list|mark|point|out|up|in|book|ing|ed)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word === 'save') {
|
||||||
|
// Allow "save" in legitimate business contexts
|
||||||
|
return !/save.*(data|file|document|time|energy|environment|earth)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Other words are still suspicious
|
||||||
|
});
|
||||||
|
|
||||||
|
if (suspiciousWords.length >= 1) {
|
||||||
|
score += suspiciousWords.length * 20;
|
||||||
|
reasons.push(`Contains ${suspiciousWords.length} suspicious word${suspiciousWords.length > 1 ? 's' : ''}: ${suspiciousWords.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check text length - very short or very long names are suspicious
|
||||||
|
if (text.length < 2) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push("Text too short");
|
||||||
|
} else if (text.length > 100) {
|
||||||
|
score += 25;
|
||||||
|
reasons.push("Text unusually long");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for repeated characters
|
||||||
|
if (/(.)\1{4,}/.test(text)) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push("Contains repeated characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mixed scripts (potential homograph attack)
|
||||||
|
const hasLatin = /[a-zA-Z]/.test(text);
|
||||||
|
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||||
|
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||||
|
|
||||||
|
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||||
|
score += 40;
|
||||||
|
reasons.push("Contains mixed character scripts");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic name check already done above - skip duplicate check
|
||||||
|
|
||||||
|
// Check for excessive numbers in organization names (often spam)
|
||||||
|
if (/\d{4,}/.test(text)) {
|
||||||
|
score += 25;
|
||||||
|
reasons.push("Contains excessive numbers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSpam = score >= 50;
|
||||||
|
|
||||||
|
// Log suspicious activity for Slack notifications
|
||||||
|
if (isSpam || score > 30) {
|
||||||
|
logger.warn("🚨 SPAM DETECTED", {
|
||||||
|
text: text.substring(0, 100),
|
||||||
|
score,
|
||||||
|
reasons: [...new Set(reasons)],
|
||||||
|
isSpam,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: "spam_detection"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSpam,
|
||||||
|
score,
|
||||||
|
reasons: [...new Set(reasons)] // Remove duplicates
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isHighRiskContent(text: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/gclnk\.com/i,
|
||||||
|
/bit\.ly\/scam/i, // More specific bit.ly patterns
|
||||||
|
/tinyurl\.com\/scam/i,
|
||||||
|
/\$\d{3,}.*crypto/i, // Money + crypto combination
|
||||||
|
/blockchain.*compensation.*urgent/i,
|
||||||
|
/win.*\$\d+.*urgent/i, // Win money urgent pattern
|
||||||
|
/click.*here.*\$\d+/i // Click here money pattern
|
||||||
|
];
|
||||||
|
|
||||||
|
const isHighRisk = patterns.some(pattern => pattern.test(text));
|
||||||
|
|
||||||
|
// Log high-risk content immediately
|
||||||
|
if (isHighRisk) {
|
||||||
|
logger.error("🔥 HIGH RISK CONTENT DETECTED", {
|
||||||
|
text: text.substring(0, 100),
|
||||||
|
matched_patterns: patterns.filter(pattern => pattern.test(text)).map(p => p.toString()),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
alert_type: "high_risk_content"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return isHighRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static shouldBlockContent(text: string): boolean {
|
||||||
|
const result = this.detectSpam(text);
|
||||||
|
// Only block if extremely high score or high-risk patterns
|
||||||
|
return result.score > 80 || this.isHighRiskContent(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static shouldFlagContent(text: string): boolean {
|
||||||
|
const result = this.detectSpam(text);
|
||||||
|
// Flag anything suspicious (score > 0) but not necessarily blocked
|
||||||
|
return result.score > 0 || result.reasons.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static sanitizeText(text: string): string {
|
||||||
|
if (!text || typeof text !== "string") return "";
|
||||||
|
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.replace(/https?:\/\/[^\s]+/gi, "[URL_REMOVED]")
|
||||||
|
.replace(/www\.[^\s]+/gi, "[URL_REMOVED]")
|
||||||
|
.replace(/[🔔⬅👆💰$]{2,}/g, "")
|
||||||
|
.replace(/[!]{3,}/g, "!")
|
||||||
|
.replace(/\s{3,}/g, " ")
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
worklenz-frontend/.gitignore
vendored
1
worklenz-frontend/.gitignore
vendored
@@ -11,6 +11,7 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/public/tinymce
|
/public/tinymce
|
||||||
|
/docs
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class HubSpotManager {
|
|||||||
* Load HubSpot script with dark mode support
|
* Load HubSpot script with dark mode support
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
// if (!this.isProduction) return;
|
if (!this.isProduction) return;
|
||||||
|
|
||||||
const loadHubSpot = () => {
|
const loadHubSpot = () => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
@@ -52,7 +52,6 @@ class HubSpotManager {
|
|||||||
existingStyle.remove();
|
existingStyle.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply dark mode CSS if dark theme is active
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
this.injectDarkModeCSS();
|
this.injectDarkModeCSS();
|
||||||
}
|
}
|
||||||
@@ -122,11 +121,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Make available globally for potential cleanup
|
// Make available globally for potential cleanup
|
||||||
window.HubSpotManager = hubspot;
|
window.HubSpotManager = hubspot;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add this style to ensure the chat widget uses the light color scheme
|
|
||||||
(function() {
|
|
||||||
var style = document.createElement('style');
|
|
||||||
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
})();
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||||
"connection-restored": "U lidhët me serverin me sukses",
|
"connection-restored": "U lidhët me serverin me sukses",
|
||||||
"cancel": "Anulo",
|
"cancel": "Anulo"
|
||||||
"update-available": "Worklenz u përditesua!",
|
|
||||||
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
|
|
||||||
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
|
|
||||||
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
|
|
||||||
"update-now": "Përditeso tani",
|
|
||||||
"update-later": "Më vonë",
|
|
||||||
"updating": "Duke u përditesuar..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "Vom Server getrennt.",
|
"reconnecting": "Vom Server getrennt.",
|
||||||
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||||
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen"
|
||||||
"update-available": "Worklenz aktualisiert!",
|
|
||||||
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
|
|
||||||
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
|
|
||||||
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
|
|
||||||
"update-now": "Jetzt aktualisieren",
|
|
||||||
"update-later": "Später",
|
|
||||||
"updating": "Wird aktualisiert..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "Disconnected from server.",
|
"reconnecting": "Disconnected from server.",
|
||||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||||
"connection-restored": "Connected to server successfully",
|
"connection-restored": "Connected to server successfully",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel"
|
||||||
"update-available": "Worklenz Updated!",
|
|
||||||
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
|
|
||||||
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
|
|
||||||
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
|
|
||||||
"update-now": "Update Now",
|
|
||||||
"update-later": "Later",
|
|
||||||
"updating": "Updating..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase",
|
|
||||||
"priority": "Priority",
|
|
||||||
"assignees": "Assignees",
|
|
||||||
"dueDate": "Due Date",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"noAssignees": "Unassigned",
|
|
||||||
"noDueDate": "No due date"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "Reconectando al servidor...",
|
"reconnecting": "Reconectando al servidor...",
|
||||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||||
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar"
|
||||||
"update-available": "¡Worklenz actualizado!",
|
|
||||||
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
|
|
||||||
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
|
|
||||||
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
|
|
||||||
"update-now": "Actualizar ahora",
|
|
||||||
"update-later": "Más tarde",
|
|
||||||
"updating": "Actualizando..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "Reconectando ao servidor...",
|
"reconnecting": "Reconectando ao servidor...",
|
||||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar"
|
||||||
"update-available": "Worklenz atualizado!",
|
|
||||||
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
|
|
||||||
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
|
|
||||||
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
|
|
||||||
"update-now": "Atualizar agora",
|
|
||||||
"update-later": "Mais tarde",
|
|
||||||
"updating": "Atualizando..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,5 @@
|
|||||||
"reconnecting": "与服务器断开连接。",
|
"reconnecting": "与服务器断开连接。",
|
||||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||||
"connection-restored": "成功连接到服务器",
|
"connection-restored": "成功连接到服务器",
|
||||||
"cancel": "取消",
|
"cancel": "取消"
|
||||||
"update-available": "Worklenz 已更新!",
|
|
||||||
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
|
|
||||||
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
|
|
||||||
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
|
|
||||||
"update-now": "立即更新",
|
|
||||||
"update-later": "稍后",
|
|
||||||
"updating": "正在更新..."
|
|
||||||
}
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Phase Details",
|
|
||||||
"overview": {
|
|
||||||
"title": "Overview",
|
|
||||||
"totalTasks": "Total Tasks",
|
|
||||||
"completion": "Completion",
|
|
||||||
"progress": "Progress"
|
|
||||||
},
|
|
||||||
"timeline": {
|
|
||||||
"title": "Timeline",
|
|
||||||
"startDate": "Start Date",
|
|
||||||
"endDate": "End Date",
|
|
||||||
"status": "Status",
|
|
||||||
"notSet": "Not set",
|
|
||||||
"statusLabels": {
|
|
||||||
"upcoming": "Upcoming",
|
|
||||||
"active": "In Progress",
|
|
||||||
"overdue": "Overdue",
|
|
||||||
"notScheduled": "Not Scheduled"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"taskBreakdown": {
|
|
||||||
"title": "Task Breakdown",
|
|
||||||
"completed": "Completed",
|
|
||||||
"pending": "Pending",
|
|
||||||
"overdue": "Overdue"
|
|
||||||
},
|
|
||||||
"phaseColor": {
|
|
||||||
"title": "Phase Color",
|
|
||||||
"description": "Phase identifier color"
|
|
||||||
},
|
|
||||||
"tasksInPhase": {
|
|
||||||
"title": "Tasks in this Phase",
|
|
||||||
"noTasks": "No tasks in this phase"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -325,12 +325,6 @@ self.addEventListener('message', event => {
|
|||||||
event.ports[0].postMessage({ version: CACHE_VERSION });
|
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'CHECK_FOR_UPDATES':
|
|
||||||
checkForUpdates().then((hasUpdates) => {
|
|
||||||
event.ports[0].postMessage({ hasUpdates });
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'CLEAR_CACHE':
|
case 'CLEAR_CACHE':
|
||||||
clearAllCaches().then(() => {
|
clearAllCaches().then(() => {
|
||||||
event.ports[0].postMessage({ success: true });
|
event.ports[0].postMessage({ success: true });
|
||||||
@@ -355,44 +349,6 @@ async function clearAllCaches() {
|
|||||||
console.log('Service Worker: All caches cleared');
|
console.log('Service Worker: All caches cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
|
||||||
try {
|
|
||||||
// Check if there's a new service worker available
|
|
||||||
const registration = await self.registration.update();
|
|
||||||
const hasNewWorker = registration.installing || registration.waiting;
|
|
||||||
|
|
||||||
if (hasNewWorker) {
|
|
||||||
console.log('Service Worker: New version detected');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check if the main app files have been updated by trying to fetch index.html
|
|
||||||
// and comparing it with the cached version
|
|
||||||
try {
|
|
||||||
const cache = await caches.open(CACHE_NAMES.STATIC);
|
|
||||||
const cachedResponse = await cache.match('/');
|
|
||||||
const networkResponse = await fetch('/', { cache: 'no-cache' });
|
|
||||||
|
|
||||||
if (cachedResponse && networkResponse.ok) {
|
|
||||||
const cachedContent = await cachedResponse.text();
|
|
||||||
const networkContent = await networkResponse.text();
|
|
||||||
|
|
||||||
if (cachedContent !== networkContent) {
|
|
||||||
console.log('Service Worker: App content has changed');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Service Worker: Could not check for content updates', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Service Worker: Error checking for updates', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
// Clear all caches
|
// Clear all caches
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import i18next from 'i18next';
|
|||||||
// Components
|
// Components
|
||||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||||
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
|
||||||
import { UpdateNotificationProvider } from './components/update-notification';
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import router from './app/routes';
|
import router from './app/routes';
|
||||||
@@ -203,16 +202,14 @@ const App: React.FC = memo(() => {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ThemeWrapper>
|
<ThemeWrapper>
|
||||||
<UpdateNotificationProvider>
|
<ModuleErrorBoundary>
|
||||||
<ModuleErrorBoundary>
|
<RouterProvider
|
||||||
<RouterProvider
|
router={router}
|
||||||
router={router}
|
future={{
|
||||||
future={{
|
v7_startTransition: true,
|
||||||
v7_startTransition: true,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</ModuleErrorBoundary>
|
||||||
</ModuleErrorBoundary>
|
|
||||||
</UpdateNotificationProvider>
|
|
||||||
</ThemeWrapper>
|
</ThemeWrapper>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ import selectionReducer from '@/features/task-management/selection.slice';
|
|||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
||||||
import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service';
|
|
||||||
|
|
||||||
import projectViewReducer from '@features/project/project-view-slice';
|
import projectViewReducer from '@features/project/project-view-slice';
|
||||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||||
@@ -93,7 +92,7 @@ export const store = configureStore({
|
|||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false,
|
serializableCheck: false,
|
||||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
|
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
// Auth & User
|
// Auth & User
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@@ -106,7 +105,6 @@ export const store = configureStore({
|
|||||||
homePageReducer: homePageReducer,
|
homePageReducer: homePageReducer,
|
||||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||||
[ganttApi.reducerPath]: ganttApi.reducer,
|
|
||||||
userActivityReducer: userActivityReducer,
|
userActivityReducer: userActivityReducer,
|
||||||
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
|
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||||
|
import { SpamDetector } from '@/utils/spamDetector';
|
||||||
|
|
||||||
const { Title, Paragraph, Text } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const OrganizationStep: React.FC<Props> = ({
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||||
const inputRef = useRef<InputRef>(null);
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||||
|
|
||||||
// Autofill organization name if not already set
|
// Autofill organization name if not already set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,7 +46,19 @@ export const OrganizationStep: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const sanitizedValue = sanitizeInput(e.target.value);
|
const rawValue = e.target.value;
|
||||||
|
const sanitizedValue = sanitizeInput(rawValue);
|
||||||
|
|
||||||
|
// Check for spam patterns
|
||||||
|
const spamCheck = SpamDetector.detectSpam(rawValue);
|
||||||
|
if (spamCheck.isSpam) {
|
||||||
|
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||||
|
} else if (SpamDetector.isHighRiskContent(rawValue)) {
|
||||||
|
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||||
|
} else {
|
||||||
|
setSpamWarning('');
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(setOrganizationName(sanitizedValue));
|
dispatch(setOrganizationName(sanitizedValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,12 +74,25 @@ export const OrganizationStep: React.FC<Props> = ({
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spam Warning */}
|
||||||
|
{spamWarning && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<Alert
|
||||||
|
message={spamWarning}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setSpamWarning('')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Form Card */}
|
{/* Main Form Card */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Card
|
<Card
|
||||||
className="border-2 hover:shadow-md transition-all duration-200"
|
className="border-2 hover:shadow-md transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
borderColor: token?.colorPrimary,
|
borderColor: spamWarning ? token?.colorWarning : token?.colorPrimary,
|
||||||
backgroundColor: token?.colorBgContainer
|
backgroundColor: token?.colorBgContainer
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||||
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
|
import { Card, Button, Tooltip, Typography, Alert } from '@/shared/antd-imports';
|
||||||
import TextArea from 'antd/es/input/TextArea';
|
import TextArea from 'antd/es/input/TextArea';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { SpamDetector } from '@/utils/spamDetector';
|
||||||
|
|
||||||
interface OrganizationNameProps {
|
interface OrganizationNameProps {
|
||||||
themeMode: string;
|
themeMode: string;
|
||||||
@@ -16,6 +17,7 @@ interface OrganizationNameProps {
|
|||||||
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
|
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
|
||||||
const [isEditable, setIsEditable] = useState(false);
|
const [isEditable, setIsEditable] = useState(false);
|
||||||
const [newName, setNewName] = useState(name);
|
const [newName, setNewName] = useState(name);
|
||||||
|
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNewName(name);
|
setNewName(name);
|
||||||
@@ -34,7 +36,18 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setNewName(e.target.value);
|
const value = e.target.value;
|
||||||
|
setNewName(value);
|
||||||
|
|
||||||
|
// Check for spam patterns
|
||||||
|
const spamCheck = SpamDetector.detectSpam(value);
|
||||||
|
if (spamCheck.isSpam) {
|
||||||
|
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||||
|
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||||
|
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||||
|
} else {
|
||||||
|
setSpamWarning('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateOrganizationName = async () => {
|
const updateOrganizationName = async () => {
|
||||||
@@ -62,6 +75,16 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
|||||||
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
|
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
|
||||||
{t('name')}
|
{t('name')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
{spamWarning && (
|
||||||
|
<Alert
|
||||||
|
message={spamWarning}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setSpamWarning('')}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div style={{ paddingTop: '8px' }}>
|
<div style={{ paddingTop: '8px' }}>
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: '8px' }}>
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
|
|||||||
@@ -10,6 +10,3 @@ export { default as LabelsSelector } from './LabelsSelector';
|
|||||||
export { default as Progress } from './Progress';
|
export { default as Progress } from './Progress';
|
||||||
export { default as Tag } from './Tag';
|
export { default as Tag } from './Tag';
|
||||||
export { default as Tooltip } from './Tooltip';
|
export { default as Tooltip } from './Tooltip';
|
||||||
|
|
||||||
// Update Notification Components
|
|
||||||
export * from './update-notification';
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Divider, Form, Input, message, Modal, Typography } from '@/shared/antd-imports';
|
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
|
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
|
||||||
import { ITeamGetResponse } from '@/types/teams/team.type';
|
import { ITeamGetResponse } from '@/types/teams/team.type';
|
||||||
|
import { SpamDetector } from '@/utils/spamDetector';
|
||||||
|
|
||||||
interface EditTeamNameModalProps {
|
interface EditTeamNameModalProps {
|
||||||
team: ITeamGetResponse | null;
|
team: ITeamGetResponse | null;
|
||||||
@@ -16,6 +17,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (team) {
|
if (team) {
|
||||||
@@ -67,6 +69,16 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
|||||||
destroyOnClose={true}
|
destroyOnClose={true}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||||
|
{spamWarning && (
|
||||||
|
<Alert
|
||||||
|
message={spamWarning}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setSpamWarning('')}
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label={t('name')}
|
label={t('name')}
|
||||||
@@ -77,7 +89,20 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder={t('namePlaceholder')} />
|
<Input
|
||||||
|
placeholder={t('namePlaceholder')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const spamCheck = SpamDetector.detectSpam(value);
|
||||||
|
if (spamCheck.isSpam) {
|
||||||
|
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||||
|
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||||
|
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||||
|
} else {
|
||||||
|
setSpamWarning('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
// Update Notification Component
|
|
||||||
// Shows a notification when new build is available and provides update options
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
|
|
||||||
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
|
||||||
|
|
||||||
interface UpdateNotificationProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onUpdate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onUpdate
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('common');
|
|
||||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
|
||||||
const { hardReload } = useServiceWorker();
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
setIsUpdating(true);
|
|
||||||
try {
|
|
||||||
if (hardReload) {
|
|
||||||
await hardReload();
|
|
||||||
} else {
|
|
||||||
// Fallback to regular reload
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
onUpdate();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during update:', error);
|
|
||||||
// Fallback to regular reload
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLater = () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<DownloadOutlined style={{ color: '#1890ff' }} />
|
|
||||||
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
|
|
||||||
{t('update-available')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={visible}
|
|
||||||
onCancel={handleLater}
|
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
closable={false}
|
|
||||||
maskClosable={false}
|
|
||||||
width={460}
|
|
||||||
styles={{
|
|
||||||
body: { padding: '20px 24px' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
|
|
||||||
{t('update-description')}
|
|
||||||
</Text>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
|
||||||
{t('update-instruction')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
background: '#f6ffed',
|
|
||||||
border: '1px solid #b7eb8f',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '12px',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}>
|
|
||||||
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
|
|
||||||
{t('update-whats-new', {
|
|
||||||
interpolation: { escapeValue: false }
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Space
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'flex-end'
|
|
||||||
}}
|
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={handleLater}
|
|
||||||
disabled={isUpdating}
|
|
||||||
>
|
|
||||||
{t('update-later')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
loading={isUpdating}
|
|
||||||
onClick={handleUpdate}
|
|
||||||
>
|
|
||||||
{isUpdating ? t('updating') : t('update-now')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UpdateNotification;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
// Update Notification Provider
|
|
||||||
// Provides global update notification management
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
|
|
||||||
import UpdateNotification from './UpdateNotification';
|
|
||||||
|
|
||||||
interface UpdateNotificationProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
checkInterval?: number;
|
|
||||||
enableAutoCheck?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
|
|
||||||
children,
|
|
||||||
checkInterval = 5 * 60 * 1000, // 5 minutes
|
|
||||||
enableAutoCheck = true
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
showUpdateNotification,
|
|
||||||
setShowUpdateNotification,
|
|
||||||
dismissUpdate
|
|
||||||
} = useUpdateChecker({
|
|
||||||
checkInterval,
|
|
||||||
enableAutoCheck,
|
|
||||||
showNotificationOnUpdate: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
dismissUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
// The hardReload function in UpdateNotification will handle the actual update
|
|
||||||
setShowUpdateNotification(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
<UpdateNotification
|
|
||||||
visible={showUpdateNotification}
|
|
||||||
onClose={handleClose}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UpdateNotificationProvider;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as UpdateNotification } from './UpdateNotification';
|
|
||||||
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// Update Checker Hook
|
|
||||||
// Periodically checks for app updates and manages update notifications
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
|
|
||||||
|
|
||||||
interface UseUpdateCheckerOptions {
|
|
||||||
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
|
|
||||||
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
|
|
||||||
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseUpdateCheckerReturn {
|
|
||||||
hasUpdate: boolean;
|
|
||||||
isChecking: boolean;
|
|
||||||
lastChecked: Date | null;
|
|
||||||
checkForUpdates: () => Promise<void>;
|
|
||||||
dismissUpdate: () => void;
|
|
||||||
showUpdateNotification: boolean;
|
|
||||||
setShowUpdateNotification: (show: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
|
|
||||||
const {
|
|
||||||
checkInterval = 5 * 60 * 1000, // 5 minutes
|
|
||||||
enableAutoCheck = true,
|
|
||||||
showNotificationOnUpdate = true
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
|
|
||||||
|
|
||||||
const [hasUpdate, setHasUpdate] = React.useState(false);
|
|
||||||
const [isChecking, setIsChecking] = React.useState(false);
|
|
||||||
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
|
|
||||||
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
|
|
||||||
const [updateDismissed, setUpdateDismissed] = React.useState(false);
|
|
||||||
|
|
||||||
// Check for updates function
|
|
||||||
const checkForUpdates = React.useCallback(async () => {
|
|
||||||
if (!serviceWorkerCheckUpdates || isChecking) return;
|
|
||||||
|
|
||||||
setIsChecking(true);
|
|
||||||
try {
|
|
||||||
const hasUpdates = await serviceWorkerCheckUpdates();
|
|
||||||
setHasUpdate(hasUpdates);
|
|
||||||
setLastChecked(new Date());
|
|
||||||
|
|
||||||
// Show notification if update found and user hasn't dismissed it
|
|
||||||
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
|
|
||||||
setShowUpdateNotification(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Update check completed:', { hasUpdates });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for updates:', error);
|
|
||||||
} finally {
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
|
|
||||||
|
|
||||||
// Dismiss update notification
|
|
||||||
const dismissUpdate = React.useCallback(() => {
|
|
||||||
setUpdateDismissed(true);
|
|
||||||
setShowUpdateNotification(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set up automatic checking interval
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enableAutoCheck || !swManager) return;
|
|
||||||
|
|
||||||
// Initial check after a short delay
|
|
||||||
const initialTimeout = setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, 10000); // 10 seconds after component mount
|
|
||||||
|
|
||||||
// Set up interval for periodic checks
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, checkInterval);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(initialTimeout);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
|
|
||||||
|
|
||||||
// Listen for visibility change to check for updates when user returns to tab
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enableAutoCheck) return;
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (!document.hidden && swManager) {
|
|
||||||
// Check for updates when user returns to the tab
|
|
||||||
setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, 2000); // 2 second delay
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [enableAutoCheck, swManager, checkForUpdates]);
|
|
||||||
|
|
||||||
// Listen for focus events to check for updates
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!enableAutoCheck) return;
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (swManager && !isChecking) {
|
|
||||||
// Check for updates when window regains focus
|
|
||||||
setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, 1000); // 1 second delay
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('focus', handleFocus);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('focus', handleFocus);
|
|
||||||
};
|
|
||||||
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
|
|
||||||
|
|
||||||
// Reset dismissed state when new update is found
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (hasUpdate && updateDismissed) {
|
|
||||||
setUpdateDismissed(false);
|
|
||||||
}
|
|
||||||
}, [hasUpdate, updateDismissed]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasUpdate,
|
|
||||||
isChecking,
|
|
||||||
lastChecked,
|
|
||||||
checkForUpdates,
|
|
||||||
dismissUpdate,
|
|
||||||
showUpdateNotification,
|
|
||||||
setShowUpdateNotification
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -19,9 +19,6 @@ const ProjectViewMembers = React.lazy(
|
|||||||
const ProjectViewUpdates = React.lazy(
|
const ProjectViewUpdates = React.lazy(
|
||||||
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
||||||
);
|
);
|
||||||
const ProjectViewGantt = React.lazy(
|
|
||||||
() => import('@/pages/projects/projectView/gantt/ProjectViewGantt')
|
|
||||||
);
|
|
||||||
|
|
||||||
// type of a tab items
|
// type of a tab items
|
||||||
type TabItems = {
|
type TabItems = {
|
||||||
@@ -46,7 +43,6 @@ const getTabLabel = (key: string): string => {
|
|||||||
files: 'Files',
|
files: 'Files',
|
||||||
members: 'Members',
|
members: 'Members',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
gantt: 'Gantt Chart',
|
|
||||||
};
|
};
|
||||||
return fallbacks[key] || key;
|
return fallbacks[key] || key;
|
||||||
}
|
}
|
||||||
@@ -121,16 +117,6 @@ export const tabItems: TabItems[] = [
|
|||||||
React.createElement(ProjectViewUpdates)
|
React.createElement(ProjectViewUpdates)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
index: 6,
|
|
||||||
key: 'gantt',
|
|
||||||
label: getTabLabel('gantt'),
|
|
||||||
element: React.createElement(
|
|
||||||
Suspense,
|
|
||||||
{ fallback: React.createElement(InlineSuspenseFallback) },
|
|
||||||
React.createElement(ProjectViewGantt)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Function to update tab labels when language changes
|
// Function to update tab labels when language changes
|
||||||
@@ -156,9 +142,6 @@ export const updateTabLabels = () => {
|
|||||||
case 'updates':
|
case 'updates':
|
||||||
item.label = getTabLabel('updates');
|
item.label = getTabLabel('updates');
|
||||||
break;
|
break;
|
||||||
case 'gantt':
|
|
||||||
item.label = getTabLabel('gantt');
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
|
||||||
import { Spin, message } from '@/shared/antd-imports';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import GanttTimeline from './components/gantt-timeline/GanttTimeline';
|
|
||||||
import GanttTaskList from './components/gantt-task-list/GanttTaskList';
|
|
||||||
import GanttChart from './components/gantt-chart/GanttChart';
|
|
||||||
import GanttToolbar from './components/gantt-toolbar/GanttToolbar';
|
|
||||||
import ManagePhaseModal from '@components/task-management/ManagePhaseModal';
|
|
||||||
import PhaseDetailsModal from './components/phase-details-modal/PhaseDetailsModal';
|
|
||||||
import { GanttProvider } from './context/gantt-context';
|
|
||||||
import { GanttViewMode } from './types/gantt-types';
|
|
||||||
import {
|
|
||||||
useGetRoadmapTasksQuery,
|
|
||||||
useGetProjectPhasesQuery,
|
|
||||||
transformToGanttTasks,
|
|
||||||
transformToGanttPhases,
|
|
||||||
} from './services/gantt-api.service';
|
|
||||||
import { TimelineUtils } from './utils/timeline-calculator';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import {
|
|
||||||
setShowTaskDrawer,
|
|
||||||
setSelectedTaskId,
|
|
||||||
setTaskFormViewModel,
|
|
||||||
fetchTask,
|
|
||||||
} from '@features/task-drawer/task-drawer.slice';
|
|
||||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
|
||||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
|
||||||
import './gantt-styles.css';
|
|
||||||
|
|
||||||
const ProjectViewGantt: React.FC = React.memo(() => {
|
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [viewMode, setViewMode] = useState<GanttViewMode>('month');
|
|
||||||
const [showPhaseModal, setShowPhaseModal] = useState(false);
|
|
||||||
const [showPhaseDetailsModal, setShowPhaseDetailsModal] = useState(false);
|
|
||||||
const [selectedPhase, setSelectedPhase] = useState<any>(null);
|
|
||||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
|
||||||
const [animatingTasks, setAnimatingTasks] = useState<Set<string>>(new Set());
|
|
||||||
const [prevExpandedTasks, setPrevExpandedTasks] = useState<Set<string>>(new Set());
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
|
||||||
const taskListRef = useRef<HTMLDivElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// RTK Query hooks
|
|
||||||
const {
|
|
||||||
data: tasksResponse,
|
|
||||||
error: tasksError,
|
|
||||||
isLoading: tasksLoading,
|
|
||||||
refetch: refetchTasks,
|
|
||||||
} = useGetRoadmapTasksQuery({ projectId: projectId || '' }, { skip: !projectId });
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: phasesResponse,
|
|
||||||
error: phasesError,
|
|
||||||
isLoading: phasesLoading,
|
|
||||||
refetch: refetchPhases,
|
|
||||||
} = useGetProjectPhasesQuery({ projectId: projectId || '' }, { skip: !projectId });
|
|
||||||
|
|
||||||
// Transform API data to component format
|
|
||||||
const tasks = useMemo(() => {
|
|
||||||
if (tasksResponse?.body && phasesResponse?.body) {
|
|
||||||
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
|
|
||||||
const result: any[] = [];
|
|
||||||
|
|
||||||
transformedTasks.forEach(task => {
|
|
||||||
// Always show phase milestones
|
|
||||||
if (task.type === 'milestone' || task.is_milestone) {
|
|
||||||
result.push(task);
|
|
||||||
|
|
||||||
// If this phase is expanded, show its children tasks
|
|
||||||
const phaseId =
|
|
||||||
task.id === 'phase-unmapped'
|
|
||||||
? 'unmapped'
|
|
||||||
: task.phase_id || task.id.replace('phase-', '');
|
|
||||||
const isExpanded = expandedTasks.has(phaseId);
|
|
||||||
|
|
||||||
if (isExpanded && task.children) {
|
|
||||||
task.children.forEach((child: any) => {
|
|
||||||
result.push({
|
|
||||||
...child,
|
|
||||||
phase_id: task.phase_id, // Ensure child has correct phase_id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [tasksResponse, phasesResponse, expandedTasks]);
|
|
||||||
|
|
||||||
const phases = useMemo(() => {
|
|
||||||
if (phasesResponse?.body) {
|
|
||||||
return transformToGanttPhases(phasesResponse.body);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [phasesResponse]);
|
|
||||||
|
|
||||||
// Calculate date range based on tasks
|
|
||||||
const dateRange = useMemo(() => {
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
return TimelineUtils.getSmartDateRange(tasks, viewMode);
|
|
||||||
}
|
|
||||||
return { start: new Date(), end: new Date() };
|
|
||||||
}, [tasks, viewMode]);
|
|
||||||
|
|
||||||
const loading = tasksLoading || phasesLoading;
|
|
||||||
|
|
||||||
// Load priorities for task drawer functionality
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchPriorities());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
// Track expansion changes for animations
|
|
||||||
useEffect(() => {
|
|
||||||
const currentExpanded = expandedTasks;
|
|
||||||
const previousExpanded = prevExpandedTasks;
|
|
||||||
|
|
||||||
// Find newly expanded or collapsed phases
|
|
||||||
const newlyExpanded = new Set([...currentExpanded].filter(id => !previousExpanded.has(id)));
|
|
||||||
const newlyCollapsed = new Set([...previousExpanded].filter(id => !currentExpanded.has(id)));
|
|
||||||
|
|
||||||
if (newlyExpanded.size > 0 || newlyCollapsed.size > 0) {
|
|
||||||
// Set animation state for newly changed phases
|
|
||||||
setAnimatingTasks(new Set([...newlyExpanded, ...newlyCollapsed]));
|
|
||||||
|
|
||||||
// Clear animation state after animation completes
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setAnimatingTasks(new Set());
|
|
||||||
}, 400); // Match CSS animation duration
|
|
||||||
|
|
||||||
setPrevExpandedTasks(new Set(currentExpanded));
|
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}, [expandedTasks, prevExpandedTasks]);
|
|
||||||
|
|
||||||
const handleViewModeChange = useCallback((mode: GanttViewMode) => {
|
|
||||||
setViewMode(mode);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
const target = e.target as HTMLDivElement;
|
|
||||||
|
|
||||||
// Sync horizontal scroll with timeline
|
|
||||||
if (timelineRef.current) {
|
|
||||||
timelineRef.current.scrollLeft = target.scrollLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync vertical scroll with task list
|
|
||||||
if (taskListRef.current) {
|
|
||||||
taskListRef.current.scrollTop = target.scrollTop;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
const target = e.target as HTMLDivElement;
|
|
||||||
|
|
||||||
// Sync vertical scroll with chart
|
|
||||||
if (chartRef.current) {
|
|
||||||
chartRef.current.scrollTop = target.scrollTop;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
refetchTasks();
|
|
||||||
refetchPhases();
|
|
||||||
}, [refetchTasks, refetchPhases]);
|
|
||||||
|
|
||||||
const handleCreatePhase = useCallback(() => {
|
|
||||||
setShowPhaseModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateTask = useCallback(
|
|
||||||
(phaseId?: string) => {
|
|
||||||
// Create a new task using the task drawer
|
|
||||||
const newTaskViewModel = {
|
|
||||||
id: null,
|
|
||||||
name: DEFAULT_TASK_NAME,
|
|
||||||
project_id: projectId,
|
|
||||||
phase_id: phaseId || null,
|
|
||||||
// Add other default properties as needed
|
|
||||||
};
|
|
||||||
|
|
||||||
dispatch(setSelectedTaskId(null));
|
|
||||||
dispatch(setTaskFormViewModel(newTaskViewModel));
|
|
||||||
dispatch(setShowTaskDrawer(true));
|
|
||||||
},
|
|
||||||
[dispatch, projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback(
|
|
||||||
(taskId: string) => {
|
|
||||||
// Open existing task in the task drawer
|
|
||||||
dispatch(setSelectedTaskId(taskId));
|
|
||||||
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
|
|
||||||
dispatch(setShowTaskDrawer(true));
|
|
||||||
|
|
||||||
// Fetch the complete task data including priorities
|
|
||||||
if (projectId) {
|
|
||||||
dispatch(fetchTask({ taskId, projectId }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClosePhaseModal = useCallback(() => {
|
|
||||||
setShowPhaseModal(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePhaseClick = useCallback((phase: any) => {
|
|
||||||
setSelectedPhase(phase);
|
|
||||||
setShowPhaseDetailsModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClosePhaseDetailsModal = useCallback(() => {
|
|
||||||
setShowPhaseDetailsModal(false);
|
|
||||||
setSelectedPhase(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePhaseUpdate = useCallback(
|
|
||||||
(updatedPhase: any) => {
|
|
||||||
// Refresh the data after phase update
|
|
||||||
refetchTasks();
|
|
||||||
refetchPhases();
|
|
||||||
},
|
|
||||||
[refetchTasks, refetchPhases]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePhaseReorder = useCallback((oldIndex: number, newIndex: number) => {
|
|
||||||
// TODO: Implement phase reordering API call
|
|
||||||
console.log('Reorder phases:', { oldIndex, newIndex });
|
|
||||||
message.info('Phase reordering will be implemented with the backend API');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateQuickTask = useCallback(
|
|
||||||
(taskName: string, phaseId?: string, startDate?: Date) => {
|
|
||||||
// For now, just refresh the Gantt data after task creation
|
|
||||||
// The actual task creation will happen through existing mechanisms
|
|
||||||
// and the refresh will show the new task
|
|
||||||
console.log('Task created:', { taskName, phaseId, startDate });
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
message.success(`Task "${taskName}" created successfully`);
|
|
||||||
|
|
||||||
// Refresh the Gantt data to show the new task
|
|
||||||
refetchTasks();
|
|
||||||
refetchPhases();
|
|
||||||
},
|
|
||||||
[refetchTasks, refetchPhases]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
if (tasksError || phasesError) {
|
|
||||||
message.error('Failed to load Gantt chart data');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-full w-full">
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GanttProvider
|
|
||||||
value={{
|
|
||||||
tasks,
|
|
||||||
phases,
|
|
||||||
viewMode,
|
|
||||||
projectId: projectId || '',
|
|
||||||
dateRange,
|
|
||||||
onRefresh: handleRefresh,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900"
|
|
||||||
style={{ height: 'calc(100vh - 64px)' }}
|
|
||||||
>
|
|
||||||
<GanttToolbar
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={handleViewModeChange}
|
|
||||||
dateRange={dateRange}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 overflow-hidden border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800">
|
|
||||||
<div className="relative flex w-full h-full">
|
|
||||||
{/* Fixed Task List - positioned absolutely to avoid scrollbar interference */}
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 z-20 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
|
||||||
<GanttTaskList
|
|
||||||
tasks={tasks}
|
|
||||||
projectId={projectId || ''}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onTaskClick={handleTaskClick}
|
|
||||||
onPhaseClick={handlePhaseClick}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onCreateQuickTask={handleCreateQuickTask}
|
|
||||||
onCreatePhase={handleCreatePhase}
|
|
||||||
onPhaseReorder={handlePhaseReorder}
|
|
||||||
ref={taskListRef}
|
|
||||||
onScroll={handleTaskListScroll}
|
|
||||||
expandedTasks={expandedTasks}
|
|
||||||
onExpandedTasksChange={setExpandedTasks}
|
|
||||||
animatingTasks={animatingTasks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable Timeline and Chart - with left margin for task list */}
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col overflow-hidden gantt-timeline-container"
|
|
||||||
style={{ marginLeft: '444px' }}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<GanttTimeline
|
|
||||||
viewMode={viewMode}
|
|
||||||
ref={timelineRef}
|
|
||||||
containerRef={containerRef}
|
|
||||||
dateRange={dateRange}
|
|
||||||
/>
|
|
||||||
<GanttChart
|
|
||||||
tasks={tasks}
|
|
||||||
viewMode={viewMode}
|
|
||||||
ref={chartRef}
|
|
||||||
onScroll={handleChartScroll}
|
|
||||||
onPhaseClick={handlePhaseClick}
|
|
||||||
containerRef={containerRef}
|
|
||||||
dateRange={dateRange}
|
|
||||||
phases={phases}
|
|
||||||
expandedTasks={expandedTasks}
|
|
||||||
animatingTasks={animatingTasks}
|
|
||||||
onCreateQuickTask={handleCreateQuickTask}
|
|
||||||
projectId={projectId || ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phase Management Modal */}
|
|
||||||
<ManagePhaseModal
|
|
||||||
open={showPhaseModal}
|
|
||||||
onClose={handleClosePhaseModal}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Phase Details Modal */}
|
|
||||||
<PhaseDetailsModal
|
|
||||||
open={showPhaseDetailsModal}
|
|
||||||
onClose={handleClosePhaseDetailsModal}
|
|
||||||
phase={selectedPhase}
|
|
||||||
onPhaseUpdate={handlePhaseUpdate}
|
|
||||||
/>
|
|
||||||
</GanttProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProjectViewGantt.displayName = 'ProjectViewGantt';
|
|
||||||
|
|
||||||
export default ProjectViewGantt;
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
import React, { memo, useMemo, forwardRef, RefObject, useState, useCallback } from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { Input } from 'antd';
|
|
||||||
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
|
|
||||||
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
|
||||||
|
|
||||||
// Utility function to add alpha channel to hex color
|
|
||||||
const addAlphaToHex = (hex: string, alpha: number): string => {
|
|
||||||
// Remove # if present
|
|
||||||
const cleanHex = hex.replace('#', '');
|
|
||||||
|
|
||||||
// Convert hex to RGB
|
|
||||||
const r = parseInt(cleanHex.substring(0, 2), 16);
|
|
||||||
const g = parseInt(cleanHex.substring(2, 4), 16);
|
|
||||||
const b = parseInt(cleanHex.substring(4, 6), 16);
|
|
||||||
|
|
||||||
// Return rgba string
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GanttChartProps {
|
|
||||||
tasks: GanttTask[];
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
|
||||||
onPhaseClick?: (phase: GanttTask) => void;
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
dateRange?: { start: Date; end: Date };
|
|
||||||
phases?: GanttPhase[];
|
|
||||||
expandedTasks?: Set<string>;
|
|
||||||
animatingTasks?: Set<string>;
|
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string, startDate?: Date) => void;
|
|
||||||
projectId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GridColumnProps {
|
|
||||||
index: number;
|
|
||||||
columnWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GridColumn: React.FC<GridColumnProps> = memo(({ index, columnWidth }) => (
|
|
||||||
<div
|
|
||||||
className={`border-r border-gray-100 dark:border-gray-700 flex-shrink-0 h-full ${
|
|
||||||
index % 2 === 1 ? 'bg-gray-50 dark:bg-gray-850' : ''
|
|
||||||
}`}
|
|
||||||
style={{ width: `${columnWidth}px` }}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
GridColumn.displayName = 'GridColumn';
|
|
||||||
|
|
||||||
interface TaskBarRowProps {
|
|
||||||
task: GanttTask;
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
columnWidth: number;
|
|
||||||
columnsCount: number;
|
|
||||||
dateRange?: { start: Date; end: Date };
|
|
||||||
animationClass?: string;
|
|
||||||
onPhaseClick?: (phase: GanttTask) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskBarRow: React.FC<TaskBarRowProps> = memo(
|
|
||||||
({ task, viewMode, columnWidth, columnsCount, dateRange, animationClass = '', onPhaseClick }) => {
|
|
||||||
const renderMilestone = () => {
|
|
||||||
if (!task.start_date || !dateRange) return null;
|
|
||||||
|
|
||||||
// Calculate position for milestone diamond based on view mode
|
|
||||||
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
|
|
||||||
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
|
|
||||||
const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm"
|
|
||||||
style={{
|
|
||||||
left: `${left}px`,
|
|
||||||
backgroundColor: task.color || '#3b82f6',
|
|
||||||
}}
|
|
||||||
title={`${task.name} - ${task.start_date.toLocaleDateString()}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTaskBar = () => {
|
|
||||||
if (!task.start_date || !task.end_date || !dateRange) return null;
|
|
||||||
|
|
||||||
// Calculate position and width for task bar based on time ratios
|
|
||||||
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
|
|
||||||
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
|
|
||||||
const taskDuration = task.end_date.getTime() - task.start_date.getTime();
|
|
||||||
|
|
||||||
const totalWidth = columnsCount * columnWidth;
|
|
||||||
const left = Math.max(0, (timeFromStart / totalTimeSpan) * totalWidth);
|
|
||||||
const width = Math.max(10, (taskDuration / totalTimeSpan) * totalWidth);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 transform -translate-y-1/2 h-6 rounded flex items-center px-2 text-xs text-white font-medium shadow-sm"
|
|
||||||
style={{
|
|
||||||
left: `${left}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
backgroundColor: task.color || '#6b7280',
|
|
||||||
}}
|
|
||||||
title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`}
|
|
||||||
>
|
|
||||||
<div className="truncate">{task.name}</div>
|
|
||||||
{task.progress > 0 && (
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full bg-black bg-opacity-20 rounded"
|
|
||||||
style={{ width: `${task.progress}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (isPhase && onPhaseClick) {
|
|
||||||
onPhaseClick(task);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
|
||||||
!isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : onPhaseClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750' : ''
|
|
||||||
} ${animationClass}`}
|
|
||||||
onClick={isPhase && onPhaseClick ? handleClick : undefined}
|
|
||||||
style={{
|
|
||||||
...(isPhase && task.color ? { backgroundColor: addAlphaToHex(task.color, 0.15) } : {}),
|
|
||||||
// Set lower z-index when no phase click handler so parent can receive clicks
|
|
||||||
...(isPhase && !onPhaseClick ? { position: 'relative', zIndex: 1 } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPhase ? renderMilestone() : renderTaskBar()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
TaskBarRow.displayName = 'TaskBarRow';
|
|
||||||
|
|
||||||
// Task Creation Popover Component
|
|
||||||
const TaskCreationPopover: React.FC<{
|
|
||||||
taskPopover: {
|
|
||||||
taskName: string;
|
|
||||||
date: Date;
|
|
||||||
phaseId: string | null;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
visible: boolean;
|
|
||||||
};
|
|
||||||
onTaskNameChange: (name: string) => void;
|
|
||||||
onCreateTask: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}> = ({ taskPopover, onTaskNameChange, onCreateTask, onCancel }) => {
|
|
||||||
if (!taskPopover.visible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<>
|
|
||||||
{/* Click outside overlay to close popover */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[9999] bg-black/5"
|
|
||||||
onClick={onCancel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Popover */}
|
|
||||||
<div
|
|
||||||
className="fixed z-[10000]"
|
|
||||||
style={{
|
|
||||||
left: `${taskPopover.position.x - 100}px`,
|
|
||||||
top: `${taskPopover.position.y - 30}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg p-3 min-w-[250px]">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
|
||||||
Add task for {taskPopover.date.toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={taskPopover.taskName}
|
|
||||||
onChange={(e) => onTaskNameChange(e.target.value)}
|
|
||||||
onPressEnter={onCreateTask}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Enter task name..."
|
|
||||||
autoFocus
|
|
||||||
size="small"
|
|
||||||
className="mb-2"
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
Press Enter to create • Esc to cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(
|
|
||||||
({ tasks, viewMode, onScroll, onPhaseClick, containerRef, dateRange, phases, expandedTasks, animatingTasks, onCreateQuickTask, projectId }, ref) => {
|
|
||||||
|
|
||||||
// State for popover task creation
|
|
||||||
const [taskPopover, setTaskPopover] = useState<{
|
|
||||||
taskName: string;
|
|
||||||
date: Date;
|
|
||||||
phaseId: string | null;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
visible: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const columnsCount = useMemo(() => {
|
|
||||||
if (!dateRange) {
|
|
||||||
// Default counts if no date range
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
return 30;
|
|
||||||
case 'week':
|
|
||||||
return 12;
|
|
||||||
case 'month':
|
|
||||||
return 12;
|
|
||||||
case 'quarter':
|
|
||||||
return 8;
|
|
||||||
case 'year':
|
|
||||||
return 5;
|
|
||||||
default:
|
|
||||||
return 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, end } = dateRange;
|
|
||||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
let baseColumnsCount = 0;
|
|
||||||
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
baseColumnsCount = diffDays;
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
baseColumnsCount = Math.ceil(diffDays / 7);
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
const startYear = start.getFullYear();
|
|
||||||
const startMonth = start.getMonth();
|
|
||||||
const endYear = end.getFullYear();
|
|
||||||
const endMonth = end.getMonth();
|
|
||||||
baseColumnsCount = (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
|
|
||||||
break;
|
|
||||||
case 'quarter':
|
|
||||||
const qStartYear = start.getFullYear();
|
|
||||||
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
|
||||||
const qEndYear = end.getFullYear();
|
|
||||||
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
|
||||||
baseColumnsCount = (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
baseColumnsCount = end.getFullYear() - start.getFullYear() + 1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
baseColumnsCount = 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumnsCount;
|
|
||||||
}, [viewMode, dateRange]);
|
|
||||||
|
|
||||||
// Calculate exact date from mouse position within timeline columns
|
|
||||||
const calculateDateFromPosition = useCallback((x: number, columnWidth: number): Date => {
|
|
||||||
if (!dateRange) return new Date();
|
|
||||||
|
|
||||||
// Calculate which column was clicked and position within that column
|
|
||||||
const columnIndex = Math.floor(x / columnWidth);
|
|
||||||
const positionWithinColumn = (x % columnWidth) / columnWidth; // 0 to 1
|
|
||||||
|
|
||||||
const { start, end } = dateRange;
|
|
||||||
let targetDate = new Date(start);
|
|
||||||
|
|
||||||
// Handle virtual columns beyond the actual date range
|
|
||||||
const actualColumnsInRange = columnsCount;
|
|
||||||
const isVirtualColumn = columnIndex >= actualColumnsInRange;
|
|
||||||
|
|
||||||
// If it's a virtual column, extend the date by calculating based on the end date
|
|
||||||
if (isVirtualColumn) {
|
|
||||||
const virtualColumnIndex = columnIndex - actualColumnsInRange;
|
|
||||||
targetDate = new Date(end);
|
|
||||||
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
|
|
||||||
targetDate.setHours(Math.min(Math.floor(positionWithinColumn * 24), 23), 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
targetDate.setDate(targetDate.getDate() + (virtualColumnIndex + 1) * 7);
|
|
||||||
const dayWithinVirtualWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
|
|
||||||
targetDate.setDate(targetDate.getDate() + dayWithinVirtualWeek);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
targetDate.setMonth(targetDate.getMonth() + virtualColumnIndex + 1);
|
|
||||||
const daysInVirtualMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
|
||||||
const dayWithinVirtualMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInVirtualMonth), daysInVirtualMonth));
|
|
||||||
targetDate.setDate(dayWithinVirtualMonth);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'quarter':
|
|
||||||
const quartersToAdd = virtualColumnIndex + 1;
|
|
||||||
targetDate.setMonth(targetDate.getMonth() + (quartersToAdd * 3));
|
|
||||||
const quarterStartMonth = Math.floor(targetDate.getMonth() / 3) * 3;
|
|
||||||
targetDate.setMonth(quarterStartMonth, 1);
|
|
||||||
const quarterEndDate = new Date(targetDate.getFullYear(), quarterStartMonth + 3, 0);
|
|
||||||
const daysInVirtualQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
||||||
const dayWithinVirtualQuarter = Math.min(Math.floor(positionWithinColumn * daysInVirtualQuarter), daysInVirtualQuarter - 1);
|
|
||||||
targetDate.setDate(targetDate.getDate() + dayWithinVirtualQuarter);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
targetDate.setFullYear(targetDate.getFullYear() + virtualColumnIndex + 1);
|
|
||||||
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
|
|
||||||
const daysInVirtualYear = isLeapYear ? 366 : 365;
|
|
||||||
const dayWithinVirtualYear = Math.min(Math.floor(positionWithinColumn * daysInVirtualYear), daysInVirtualYear - 1);
|
|
||||||
targetDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinVirtualYear);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
// Timeline shows individual days - each column is one day
|
|
||||||
const dayStart = new Date(start);
|
|
||||||
const dayDates: Date[] = [];
|
|
||||||
const tempDayDate = new Date(dayStart);
|
|
||||||
while (tempDayDate <= end && dayDates.length <= columnIndex) {
|
|
||||||
dayDates.push(new Date(tempDayDate));
|
|
||||||
tempDayDate.setDate(tempDayDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayDates[columnIndex]) {
|
|
||||||
targetDate = new Date(dayDates[columnIndex]);
|
|
||||||
// For day view, add hours based on position within column (0-23 hours)
|
|
||||||
const hour = Math.min(Math.floor(positionWithinColumn * 24), 23);
|
|
||||||
targetDate.setHours(hour, 0, 0, 0);
|
|
||||||
} else if (dayDates.length > 0) {
|
|
||||||
// Fallback to last available day if index is out of bounds
|
|
||||||
targetDate = new Date(dayDates[dayDates.length - 1]);
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'week':
|
|
||||||
// Timeline shows weeks - calculate specific day within the week
|
|
||||||
const weekStart = new Date(start);
|
|
||||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Start of week (Sunday)
|
|
||||||
|
|
||||||
const weekDates: Date[] = [];
|
|
||||||
const tempWeekDate = new Date(weekStart);
|
|
||||||
while (tempWeekDate <= end && weekDates.length <= columnIndex) {
|
|
||||||
weekDates.push(new Date(tempWeekDate));
|
|
||||||
tempWeekDate.setDate(tempWeekDate.getDate() + 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (weekDates[columnIndex]) {
|
|
||||||
targetDate = new Date(weekDates[columnIndex]);
|
|
||||||
// Add days within the week (0-6 days from Sunday)
|
|
||||||
const dayWithinWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
|
|
||||||
targetDate.setDate(targetDate.getDate() + dayWithinWeek);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
} else if (weekDates.length > 0) {
|
|
||||||
// Fallback to last available week if index is out of bounds
|
|
||||||
targetDate = new Date(weekDates[weekDates.length - 1]);
|
|
||||||
targetDate.setDate(targetDate.getDate() + 6); // End of week
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'month':
|
|
||||||
// Timeline shows months - calculate specific day within the month
|
|
||||||
const startYear = start.getFullYear();
|
|
||||||
const startMonth = start.getMonth();
|
|
||||||
const endYear = end.getFullYear();
|
|
||||||
const endMonth = end.getMonth();
|
|
||||||
|
|
||||||
const monthDates: Date[] = [];
|
|
||||||
let currentYear = startYear;
|
|
||||||
let currentMonth = startMonth;
|
|
||||||
|
|
||||||
while ((currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth))
|
|
||||||
&& monthDates.length <= columnIndex) {
|
|
||||||
monthDates.push(new Date(currentYear, currentMonth, 1));
|
|
||||||
currentMonth++;
|
|
||||||
if (currentMonth > 11) {
|
|
||||||
currentMonth = 0;
|
|
||||||
currentYear++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (monthDates[columnIndex]) {
|
|
||||||
targetDate = new Date(monthDates[columnIndex]);
|
|
||||||
// Calculate days in this month
|
|
||||||
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
|
||||||
// Add days within the month (1-daysInMonth)
|
|
||||||
const dayWithinMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInMonth), daysInMonth));
|
|
||||||
targetDate.setDate(dayWithinMonth);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
} else if (monthDates.length > 0) {
|
|
||||||
// Fallback to last available month if index is out of bounds
|
|
||||||
targetDate = new Date(monthDates[monthDates.length - 1]);
|
|
||||||
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
|
||||||
targetDate.setDate(daysInMonth);
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'quarter':
|
|
||||||
// Timeline shows quarters - calculate specific month and day within quarter
|
|
||||||
const qStartYear = start.getFullYear();
|
|
||||||
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
|
||||||
const qEndYear = end.getFullYear();
|
|
||||||
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
|
||||||
|
|
||||||
const quarterDates: Date[] = [];
|
|
||||||
let qYear = qStartYear;
|
|
||||||
let qQuarter = qStartQuarter;
|
|
||||||
|
|
||||||
while ((qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter))
|
|
||||||
&& quarterDates.length <= columnIndex) {
|
|
||||||
const quarterStartMonth = (qQuarter - 1) * 3;
|
|
||||||
quarterDates.push(new Date(qYear, quarterStartMonth, 1));
|
|
||||||
|
|
||||||
qQuarter++;
|
|
||||||
if (qQuarter > 4) {
|
|
||||||
qQuarter = 1;
|
|
||||||
qYear++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quarterDates[columnIndex]) {
|
|
||||||
targetDate = new Date(quarterDates[columnIndex]);
|
|
||||||
// Calculate exact days in this quarter
|
|
||||||
const quarterStartMonth = targetDate.getMonth();
|
|
||||||
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
|
|
||||||
const quarterEndDate = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0);
|
|
||||||
const daysInQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
||||||
|
|
||||||
const dayWithinQuarter = Math.min(Math.floor(positionWithinColumn * daysInQuarter), daysInQuarter - 1);
|
|
||||||
targetDate.setDate(targetDate.getDate() + dayWithinQuarter);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
} else if (quarterDates.length > 0) {
|
|
||||||
// Fallback to last available quarter if index is out of bounds
|
|
||||||
targetDate = new Date(quarterDates[quarterDates.length - 1]);
|
|
||||||
const quarterStartMonth = targetDate.getMonth();
|
|
||||||
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
|
|
||||||
targetDate.setMonth(quarterEndMonth);
|
|
||||||
const daysInMonth = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0).getDate();
|
|
||||||
targetDate.setDate(daysInMonth);
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'year':
|
|
||||||
// Timeline shows years - calculate specific month and day within year
|
|
||||||
const yearStart = start.getFullYear();
|
|
||||||
const yearEnd = end.getFullYear();
|
|
||||||
|
|
||||||
const yearDates: Date[] = [];
|
|
||||||
for (let year = yearStart; year <= yearEnd && yearDates.length <= columnIndex; year++) {
|
|
||||||
yearDates.push(new Date(year, 0, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (yearDates[columnIndex]) {
|
|
||||||
targetDate = new Date(yearDates[columnIndex]);
|
|
||||||
// Calculate exact days in this year
|
|
||||||
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
|
|
||||||
const daysInYear = isLeapYear ? 366 : 365;
|
|
||||||
const dayWithinYear = Math.min(Math.floor(positionWithinColumn * daysInYear), daysInYear - 1);
|
|
||||||
|
|
||||||
// Add days carefully to avoid month overflow
|
|
||||||
const tempDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinYear);
|
|
||||||
targetDate = tempDate;
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
} else if (yearDates.length > 0) {
|
|
||||||
// Fallback to last available year if index is out of bounds
|
|
||||||
targetDate = new Date(yearDates[yearDates.length - 1]);
|
|
||||||
targetDate.setMonth(11, 31); // December 31st
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Default to day precision
|
|
||||||
targetDate = new Date(start);
|
|
||||||
targetDate.setDate(start.getDate() + columnIndex);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final safety check - ensure we have a valid date
|
|
||||||
if (isNaN(targetDate.getTime())) {
|
|
||||||
console.warn('Invalid date calculated, falling back to start date');
|
|
||||||
targetDate = new Date(start);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure date is within the dateRange bounds
|
|
||||||
if (targetDate < start) {
|
|
||||||
targetDate = new Date(start);
|
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
} else if (targetDate > end) {
|
|
||||||
targetDate = new Date(end);
|
|
||||||
targetDate.setHours(23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetDate;
|
|
||||||
}, [dateRange, viewMode, columnsCount]);
|
|
||||||
|
|
||||||
// First get basic dimensions to access containerWidth
|
|
||||||
const basicDimensions = useGanttDimensions(
|
|
||||||
viewMode,
|
|
||||||
containerRef,
|
|
||||||
columnsCount
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate effective columns count that ensures container coverage
|
|
||||||
const effectiveColumnsCount = useMemo(() => {
|
|
||||||
if (!basicDimensions.containerWidth || basicDimensions.containerWidth === 0) {
|
|
||||||
return columnsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the column width calculation
|
|
||||||
const getBaseColumnWidth = (mode: GanttViewMode): number => {
|
|
||||||
switch (mode) {
|
|
||||||
case 'day':
|
|
||||||
return 40;
|
|
||||||
case 'week':
|
|
||||||
return 60;
|
|
||||||
case 'month':
|
|
||||||
return 80;
|
|
||||||
case 'quarter':
|
|
||||||
return 120;
|
|
||||||
case 'year':
|
|
||||||
return 160;
|
|
||||||
default:
|
|
||||||
return 80;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseColumnWidth = getBaseColumnWidth(viewMode);
|
|
||||||
const minColumnsNeeded = Math.ceil(basicDimensions.containerWidth / baseColumnWidth);
|
|
||||||
|
|
||||||
// For views that should stretch (month, quarter, year), ensure we have enough columns
|
|
||||||
// but don't add too many extra columns for day/week views
|
|
||||||
const shouldEnsureMinimum = viewMode !== 'day' && viewMode !== 'week';
|
|
||||||
|
|
||||||
if (shouldEnsureMinimum) {
|
|
||||||
return Math.max(columnsCount, minColumnsNeeded);
|
|
||||||
} else {
|
|
||||||
// For day/week views, we want scrolling, so just use calculated columns
|
|
||||||
// But ensure we have at least enough to fill a reasonable portion
|
|
||||||
return Math.max(columnsCount, Math.min(minColumnsNeeded, columnsCount * 2));
|
|
||||||
}
|
|
||||||
}, [columnsCount, basicDimensions.containerWidth, viewMode]);
|
|
||||||
|
|
||||||
// Get final dimensions with effective column count
|
|
||||||
const { actualColumnWidth, totalWidth, shouldScroll, containerWidth } = useGanttDimensions(
|
|
||||||
viewMode,
|
|
||||||
containerRef,
|
|
||||||
effectiveColumnsCount
|
|
||||||
);
|
|
||||||
|
|
||||||
const gridColumns = useMemo(
|
|
||||||
() => Array.from({ length: effectiveColumnsCount }).map((_, index) => index),
|
|
||||||
[effectiveColumnsCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Flatten tasks to match the same hierarchy as task list
|
|
||||||
// This should be synchronized with the task list component's expand/collapse state
|
|
||||||
const flattenedTasks = useMemo(() => {
|
|
||||||
const result: Array<GanttTask | { id: string; isEmptyRow: boolean; isAddPhaseRow?: boolean }> = [];
|
|
||||||
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
|
|
||||||
|
|
||||||
const processTask = (task: GanttTask, level: number = 0) => {
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
const phaseId = isPhase
|
|
||||||
? task.id === 'phase-unmapped'
|
|
||||||
? 'unmapped'
|
|
||||||
: task.phase_id || task.id.replace('phase-', '')
|
|
||||||
: task.id;
|
|
||||||
const isExpanded = expandedTasks ? expandedTasks.has(phaseId) : task.expanded !== false;
|
|
||||||
|
|
||||||
// Avoid processing the same task multiple times
|
|
||||||
if (processedIds.has(task.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processedIds.add(task.id);
|
|
||||||
|
|
||||||
// Set the correct level for nested tasks
|
|
||||||
const taskWithLevel = { ...task, level };
|
|
||||||
result.push(taskWithLevel);
|
|
||||||
|
|
||||||
if (isPhase && isExpanded) {
|
|
||||||
// Add children if they exist
|
|
||||||
if (task.children && task.children.length > 0) {
|
|
||||||
task.children.forEach(child => processTask(child, level + 1));
|
|
||||||
}
|
|
||||||
// Add an empty row for the "Add Task" button at the end (only if not already processed)
|
|
||||||
const addTaskId = `add-task-${task.id}-timeline`;
|
|
||||||
if (!processedIds.has(addTaskId)) {
|
|
||||||
processedIds.add(addTaskId);
|
|
||||||
result.push({ id: addTaskId, isEmptyRow: true });
|
|
||||||
}
|
|
||||||
} else if (!isPhase && task.children && expandedTasks && expandedTasks.has(task.id)) {
|
|
||||||
task.children.forEach(child => processTask(child, level + 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tasks.forEach(task => processTask(task, 0));
|
|
||||||
|
|
||||||
// Add the "Add Phase" row at the end
|
|
||||||
result.push({ id: 'add-phase-timeline', isEmptyRow: true, isAddPhaseRow: true });
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [tasks, expandedTasks]);
|
|
||||||
|
|
||||||
// Use flattenedTasks directly since we're using popover instead of inline rows
|
|
||||||
const finalTasks = flattenedTasks;
|
|
||||||
|
|
||||||
// Handle timeline click - defined after flattenedTasks
|
|
||||||
const handleTimelineClick = useCallback((e: React.MouseEvent, rowIndex: number) => {
|
|
||||||
if (!dateRange || !onCreateQuickTask) return;
|
|
||||||
|
|
||||||
// Get the click position relative to the timeline
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
|
|
||||||
// Calculate which date was clicked based on column position
|
|
||||||
const clickedDate = calculateDateFromPosition(x, actualColumnWidth);
|
|
||||||
|
|
||||||
// Find which phase this row belongs to
|
|
||||||
const task = flattenedTasks[rowIndex];
|
|
||||||
let phaseId: string | null = null;
|
|
||||||
|
|
||||||
if (task && 'phase_id' in task) {
|
|
||||||
phaseId = task.phase_id || null;
|
|
||||||
} else {
|
|
||||||
// Find the nearest phase above this row
|
|
||||||
for (let i = rowIndex - 1; i >= 0; i--) {
|
|
||||||
const prevTask = flattenedTasks[i];
|
|
||||||
if (prevTask && 'is_milestone' in prevTask && prevTask.is_milestone) {
|
|
||||||
phaseId = prevTask.phase_id || prevTask.id.replace('phase-', '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the click position relative to the viewport for popover positioning
|
|
||||||
const clickX = e.clientX;
|
|
||||||
const clickY = e.clientY;
|
|
||||||
|
|
||||||
const newPopoverState = {
|
|
||||||
taskName: '',
|
|
||||||
date: clickedDate,
|
|
||||||
phaseId,
|
|
||||||
position: { x: clickX, y: clickY },
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
setTaskPopover(newPopoverState);
|
|
||||||
}, [dateRange, onCreateQuickTask, flattenedTasks, calculateDateFromPosition, actualColumnWidth]);
|
|
||||||
|
|
||||||
// Handle task creation
|
|
||||||
const handleCreateTask = useCallback(() => {
|
|
||||||
if (taskPopover && onCreateQuickTask && taskPopover.taskName.trim()) {
|
|
||||||
onCreateQuickTask(taskPopover.taskName.trim(), taskPopover.phaseId || undefined, taskPopover.date);
|
|
||||||
setTaskPopover(null);
|
|
||||||
}
|
|
||||||
}, [taskPopover, onCreateQuickTask]);
|
|
||||||
|
|
||||||
// Handle cancel
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
setTaskPopover(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`flex-1 relative bg-white dark:bg-gray-800 overflow-y-auto ${
|
|
||||||
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
|
|
||||||
} gantt-chart-scroll`}
|
|
||||||
onScroll={onScroll}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
width: `${totalWidth}px`,
|
|
||||||
minHeight: '100%',
|
|
||||||
minWidth: shouldScroll ? 'auto' : '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 bottom-0 flex pointer-events-none"
|
|
||||||
style={{ width: `${totalWidth}px` }}
|
|
||||||
>
|
|
||||||
{/* Grid columns for timeline */}
|
|
||||||
{gridColumns.map(index => (
|
|
||||||
<GridColumn key={`grid-col-${index}`} index={index} columnWidth={actualColumnWidth} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
{finalTasks.map((item, index) => {
|
|
||||||
if ('isEmptyRow' in item && item.isEmptyRow) {
|
|
||||||
// Check if this is the Add Phase row
|
|
||||||
if ('isAddPhaseRow' in item && item.isAddPhaseRow) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular add-task row - determine animation classes
|
|
||||||
const addTaskPhaseId = item.id.replace('add-task-', '').replace('-timeline', '');
|
|
||||||
const shouldAnimate = animatingTasks ? animatingTasks.has(addTaskPhaseId) : false;
|
|
||||||
const staggerIndex = Math.min((index - 1) % 5, 4);
|
|
||||||
const animationClass = shouldAnimate
|
|
||||||
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Render empty row for add-task
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ${animationClass}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = item as GanttTask;
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
|
|
||||||
// Determine if this task should have animation classes
|
|
||||||
let parentPhaseId = '';
|
|
||||||
if (isPhase) {
|
|
||||||
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
|
|
||||||
} else {
|
|
||||||
parentPhaseId = task.phase_id || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldAnimate = !isPhase && animatingTasks ? animatingTasks.has(parentPhaseId) : false;
|
|
||||||
const staggerIndex = Math.min((index - 1) % 5, 4);
|
|
||||||
const animationClass = shouldAnimate
|
|
||||||
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`relative cursor-pointer hover:bg-blue-50/30 dark:hover:bg-blue-900/10 transition-colors ${animationClass}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
handleTimelineClick(e, index);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: isPhase ? '4.5rem' : '2.25rem',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
|
||||||
<TaskBarRow
|
|
||||||
task={task}
|
|
||||||
viewMode={viewMode}
|
|
||||||
columnWidth={actualColumnWidth}
|
|
||||||
columnsCount={columnsCount}
|
|
||||||
dateRange={dateRange}
|
|
||||||
animationClass=""
|
|
||||||
onPhaseClick={undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{finalTasks.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-64 text-gray-400 dark:text-gray-500">
|
|
||||||
No tasks to display
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Task Creation Popover */}
|
|
||||||
{taskPopover && taskPopover.visible && (
|
|
||||||
<TaskCreationPopover
|
|
||||||
taskPopover={taskPopover}
|
|
||||||
onTaskNameChange={(name) => setTaskPopover(prev => prev ? { ...prev, taskName: name } : null)}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GanttChart.displayName = 'GanttChart';
|
|
||||||
|
|
||||||
export default memo(GanttChart);
|
|
||||||
@@ -1,953 +0,0 @@
|
|||||||
import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
RightOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
HolderOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd';
|
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { GanttTask, GanttViewMode } from '../../types/gantt-types';
|
|
||||||
import { useSocket } from '../../../../../../socket/socketContext';
|
|
||||||
import { SocketEvents } from '../../../../../../shared/socket-events';
|
|
||||||
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
|
||||||
import { addTask } from '../../../../../../features/task-management/task-management.slice';
|
|
||||||
import { useAuthService } from '../../../../../../hooks/useAuth';
|
|
||||||
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
|
|
||||||
|
|
||||||
// Utility function to add alpha channel to hex color
|
|
||||||
const addAlphaToHex = (hex: string, alpha: number): string => {
|
|
||||||
// Remove # if present
|
|
||||||
const cleanHex = hex.replace('#', '');
|
|
||||||
|
|
||||||
// Convert hex to RGB
|
|
||||||
const r = parseInt(cleanHex.substring(0, 2), 16);
|
|
||||||
const g = parseInt(cleanHex.substring(2, 4), 16);
|
|
||||||
const b = parseInt(cleanHex.substring(4, 6), 16);
|
|
||||||
|
|
||||||
// Return rgba string
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GanttTaskListProps {
|
|
||||||
tasks: GanttTask[];
|
|
||||||
projectId: string;
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
onTaskToggle?: (taskId: string) => void;
|
|
||||||
onTaskClick?: (taskId: string) => void;
|
|
||||||
onPhaseClick?: (phase: GanttTask) => void;
|
|
||||||
onCreateTask?: (phaseId?: string) => void;
|
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
|
||||||
onCreatePhase?: () => void;
|
|
||||||
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
|
|
||||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
|
||||||
expandedTasks?: Set<string>;
|
|
||||||
onExpandedTasksChange?: (expanded: Set<string>) => void;
|
|
||||||
animatingTasks?: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskRowProps {
|
|
||||||
task: GanttTask;
|
|
||||||
index: number;
|
|
||||||
projectId: string;
|
|
||||||
onToggle?: (taskId: string) => void;
|
|
||||||
onTaskClick?: (taskId: string) => void;
|
|
||||||
onPhaseClick?: (phase: GanttTask) => void;
|
|
||||||
expandedTasks: Set<string>;
|
|
||||||
onCreateTask?: (phaseId?: string) => void;
|
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
|
||||||
isDraggable?: boolean;
|
|
||||||
activeId?: string | null;
|
|
||||||
overId?: string | null;
|
|
||||||
animationClass?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortableTaskRowProps extends TaskRowProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable wrapper for phase milestones
|
|
||||||
const SortableTaskRow: React.FC<SortableTaskRowProps> = memo(props => {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: props.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.7 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style}>
|
|
||||||
<TaskRow
|
|
||||||
{...props}
|
|
||||||
isDraggable={true}
|
|
||||||
dragAttributes={attributes}
|
|
||||||
dragListeners={listeners}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
SortableTaskRow.displayName = 'SortableTaskRow';
|
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: any }> = memo(
|
|
||||||
({
|
|
||||||
task,
|
|
||||||
projectId,
|
|
||||||
onToggle,
|
|
||||||
onTaskClick,
|
|
||||||
onPhaseClick,
|
|
||||||
expandedTasks,
|
|
||||||
onCreateTask,
|
|
||||||
onCreateQuickTask,
|
|
||||||
isDraggable = false,
|
|
||||||
activeId,
|
|
||||||
overId,
|
|
||||||
dragAttributes,
|
|
||||||
dragListeners,
|
|
||||||
animationClass = '',
|
|
||||||
}) => {
|
|
||||||
const [showInlineInput, setShowInlineInput] = useState(false);
|
|
||||||
const [taskName, setTaskName] = useState('');
|
|
||||||
const [showDatePickers, setShowDatePickers] = useState(false);
|
|
||||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { socket, connected } = useSocket();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [updatePhase] = useUpdatePhaseMutation();
|
|
||||||
const formatDateRange = useCallback(() => {
|
|
||||||
if (!task.start_date || !task.end_date) {
|
|
||||||
return <span className="text-gray-400 dark:text-gray-500">Not scheduled</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(task.start_date).toLocaleDateString();
|
|
||||||
const end = new Date(task.end_date).toLocaleDateString();
|
|
||||||
return `${start} - ${end}`;
|
|
||||||
}, [task.start_date, task.end_date]);
|
|
||||||
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
const hasChildren = task.children && task.children.length > 0;
|
|
||||||
// For phases, use phase_id for expansion state, for tasks use task.id
|
|
||||||
const phaseId = isPhase
|
|
||||||
? task.id === 'phase-unmapped'
|
|
||||||
? 'unmapped'
|
|
||||||
: task.phase_id || task.id.replace('phase-', '')
|
|
||||||
: task.id;
|
|
||||||
const isExpanded = expandedTasks.has(phaseId);
|
|
||||||
const indentLevel = (task.level || 0) * 20;
|
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
|
||||||
// For phases, always allow toggle (regardless of having children)
|
|
||||||
// Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList
|
|
||||||
if (isPhase && onToggle) {
|
|
||||||
onToggle(phaseId);
|
|
||||||
} else if (hasChildren && onToggle) {
|
|
||||||
onToggle(task.id);
|
|
||||||
}
|
|
||||||
}, [isPhase, hasChildren, onToggle, task.id, phaseId]);
|
|
||||||
|
|
||||||
const getTaskIcon = () => {
|
|
||||||
// No icon for phases
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExpandIcon = () => {
|
|
||||||
// All phases should be expandable (with or without children)
|
|
||||||
if (isPhase) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleToggle}
|
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded gantt-expand-icon ${
|
|
||||||
isExpanded ? 'expanded' : ''
|
|
||||||
} hover:bg-black/10`}
|
|
||||||
style={task.color ? { color: task.color } : {}}
|
|
||||||
>
|
|
||||||
<RightOutlined className="text-xs transition-transform duration-200" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="w-4 h-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateTask = () => {
|
|
||||||
if (onCreateTask) {
|
|
||||||
// For phase milestones, pass the phase ID
|
|
||||||
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
|
|
||||||
onCreateTask(phaseId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle inline task creation
|
|
||||||
const handleQuickTaskCreation = useCallback(
|
|
||||||
(taskName: string) => {
|
|
||||||
if (!connected || !socket || !projectId) return;
|
|
||||||
|
|
||||||
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
|
||||||
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
project_id: projectId,
|
|
||||||
name: taskName.trim(),
|
|
||||||
reporter_id: currentSession.id,
|
|
||||||
team_id: currentSession.team_id,
|
|
||||||
phase_id: phaseId,
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
|
||||||
|
|
||||||
// Handle the response and update UI
|
|
||||||
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
|
||||||
if (response) {
|
|
||||||
// The task will be automatically added to the task management slice
|
|
||||||
// via global socket handlers, but we need to refresh the Gantt data
|
|
||||||
onCreateQuickTask?.(taskName, phaseId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset input state
|
|
||||||
setTaskName('');
|
|
||||||
setShowInlineInput(false);
|
|
||||||
},
|
|
||||||
[connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyPress = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && taskName.trim()) {
|
|
||||||
handleQuickTaskCreation(taskName);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setTaskName('');
|
|
||||||
setShowInlineInput(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[taskName, handleQuickTaskCreation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowInlineInput = useCallback(() => {
|
|
||||||
setShowInlineInput(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePhaseDateUpdate = useCallback(
|
|
||||||
async (startDate: Date, endDate: Date) => {
|
|
||||||
if (!projectId || !task.phase_id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updatePhase({
|
|
||||||
project_id: projectId,
|
|
||||||
phase_id: task.phase_id,
|
|
||||||
start_date: startDate.toISOString(),
|
|
||||||
end_date: endDate.toISOString(),
|
|
||||||
}).unwrap();
|
|
||||||
|
|
||||||
message.success('Phase dates updated successfully');
|
|
||||||
setShowDatePickers(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update phase dates:', error);
|
|
||||||
message.error('Failed to update phase dates');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId, task.phase_id, updatePhase]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
|
||||||
|
|
||||||
// Calculate phase completion percentage
|
|
||||||
const phaseCompletion = useMemo(() => {
|
|
||||||
if (!isPhase || !task.children || task.children.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const totalTasks = task.children.length;
|
|
||||||
const completedTasks = task.children.filter(child => child.progress === 100).length;
|
|
||||||
return Math.round((completedTasks / totalTasks) * 100);
|
|
||||||
}, [isPhase, task.children]);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback(() => {
|
|
||||||
if (!isPhase && onTaskClick) {
|
|
||||||
onTaskClick(task.id);
|
|
||||||
}
|
|
||||||
}, [isPhase, onTaskClick, task.id]);
|
|
||||||
|
|
||||||
const handlePhaseClick = useCallback(() => {
|
|
||||||
if (isPhase && onPhaseClick) {
|
|
||||||
onPhaseClick(task);
|
|
||||||
}
|
|
||||||
}, [isPhase, onPhaseClick, task]);
|
|
||||||
|
|
||||||
// Handle click outside to close date picker
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
|
||||||
setShowDatePickers(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showDatePickers) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [showDatePickers]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`group flex ${isPhase ? 'min-h-[4.5rem] gantt-phase-row' : 'h-9 gantt-task-row'} border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
|
||||||
!isPhase
|
|
||||||
? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 cursor-pointer'
|
|
||||||
: ''
|
|
||||||
} ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${
|
|
||||||
activeId === task.id ? 'opacity-50' : ''
|
|
||||||
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''} ${animationClass}`}
|
|
||||||
style={
|
|
||||||
isPhase && task.color
|
|
||||||
? {
|
|
||||||
backgroundColor: addAlphaToHex(task.color, 0.15),
|
|
||||||
color: task.color,
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
onClick={!isPhase ? handleTaskClick : undefined}
|
|
||||||
{...(!isPhase && isDraggable ? dragAttributes : {})}
|
|
||||||
{...(!isPhase && isDraggable ? dragListeners : {})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`}
|
|
||||||
style={{
|
|
||||||
paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`,
|
|
||||||
color: isPhase && task.color ? task.color : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 truncate flex-1">
|
|
||||||
{/* Drag handle for phases */}
|
|
||||||
{isPhase && isDraggable && (
|
|
||||||
<button
|
|
||||||
{...dragAttributes}
|
|
||||||
{...dragListeners}
|
|
||||||
className="opacity-50 hover:opacity-100 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-black/10"
|
|
||||||
style={{ color: task.color }}
|
|
||||||
title="Drag to reorder phase"
|
|
||||||
>
|
|
||||||
<HolderOutlined className="text-xs" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{getExpandIcon()}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-1 truncate flex-1">
|
|
||||||
{getTaskIcon()}
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<span
|
|
||||||
className={`truncate ${task.type === 'milestone' ? 'font-semibold cursor-pointer hover:opacity-80' : ''}`}
|
|
||||||
onClick={isPhase ? handlePhaseClick : undefined}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</span>
|
|
||||||
{isPhase && (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-xs" style={{ color: task.color, opacity: 0.8 }}>
|
|
||||||
{task.children?.length || 0} tasks
|
|
||||||
</span>
|
|
||||||
{!showDatePickers && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDatePickers(true)}
|
|
||||||
className="text-xs flex items-center gap-1 transition-colors"
|
|
||||||
style={{ color: task.color, opacity: 0.7 }}
|
|
||||||
>
|
|
||||||
<CalendarOutlined className="text-[10px]" />
|
|
||||||
{task.start_date && task.end_date ? (
|
|
||||||
<>
|
|
||||||
{dayjs(task.start_date).format('MMM D')} -{' '}
|
|
||||||
{dayjs(task.end_date).format('MMM D, YYYY')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Set dates'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showDatePickers && isPhase && (
|
|
||||||
<div ref={datePickerRef} className="flex items-center gap-1 mt-2 -ml-1">
|
|
||||||
<DatePicker.RangePicker
|
|
||||||
size="small"
|
|
||||||
value={[
|
|
||||||
task.start_date ? dayjs(task.start_date) : null,
|
|
||||||
task.end_date ? dayjs(task.end_date) : null,
|
|
||||||
]}
|
|
||||||
onChange={dates => {
|
|
||||||
if (dates && dates[0] && dates[1]) {
|
|
||||||
handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOpenChange={open => {
|
|
||||||
if (!open) {
|
|
||||||
setShowDatePickers(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
style={{ width: 180 }}
|
|
||||||
format="MMM D, YYYY"
|
|
||||||
placeholder={['Start date', 'End date']}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phase completion percentage on the right side */}
|
|
||||||
{isPhase && task.children && task.children.length > 0 && (
|
|
||||||
<div className="flex-shrink-0 mr-2">
|
|
||||||
<span className="text-xs font-medium" style={{ color: task.color, opacity: 0.9 }}>
|
|
||||||
{phaseCompletion}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
TaskRow.displayName = 'TaskRow';
|
|
||||||
|
|
||||||
// Add Task Row Component
|
|
||||||
interface AddTaskRowProps {
|
|
||||||
task: GanttTask;
|
|
||||||
projectId: string;
|
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({ task, projectId, onCreateQuickTask }) => {
|
|
||||||
const [showInlineInput, setShowInlineInput] = useState(false);
|
|
||||||
const [taskName, setTaskName] = useState('');
|
|
||||||
const { socket, connected } = useSocket();
|
|
||||||
const authService = useAuthService();
|
|
||||||
|
|
||||||
// Handle inline task creation
|
|
||||||
const handleQuickTaskCreation = useCallback(
|
|
||||||
(taskName: string) => {
|
|
||||||
if (!connected || !socket || !projectId) return;
|
|
||||||
|
|
||||||
const currentSession = authService.getCurrentSession();
|
|
||||||
if (!currentSession) {
|
|
||||||
console.error('No current session found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the correct phase ID
|
|
||||||
let phaseId: string | null | undefined = task.parent_phase_id;
|
|
||||||
if (phaseId === 'unmapped') {
|
|
||||||
phaseId = null; // Unmapped tasks have no phase
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
project_id: projectId,
|
|
||||||
name: taskName.trim(),
|
|
||||||
reporter_id: currentSession.id,
|
|
||||||
team_id: currentSession.team_id,
|
|
||||||
phase_id: phaseId,
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
|
||||||
|
|
||||||
// Handle the response and update UI
|
|
||||||
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
|
||||||
if (response) {
|
|
||||||
// Immediately refresh the Gantt data to show the new task
|
|
||||||
onCreateQuickTask?.(taskName, phaseId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset input state
|
|
||||||
setTaskName('');
|
|
||||||
setShowInlineInput(false);
|
|
||||||
},
|
|
||||||
[connected, socket, projectId, task.parent_phase_id, onCreateQuickTask, authService]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyPress = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && taskName.trim()) {
|
|
||||||
handleQuickTaskCreation(taskName);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setTaskName('');
|
|
||||||
setShowInlineInput(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[taskName, handleQuickTaskCreation]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowInlineInput = useCallback(() => {
|
|
||||||
setShowInlineInput(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="gantt-add-task-inline flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
||||||
<div
|
|
||||||
className="w-full px-2 py-2 text-sm flex items-center"
|
|
||||||
style={{ paddingLeft: `${8 + 40}px` }} // Extra indent for child
|
|
||||||
>
|
|
||||||
{showInlineInput ? (
|
|
||||||
<Input
|
|
||||||
size="small"
|
|
||||||
placeholder="Enter task name..."
|
|
||||||
value={taskName}
|
|
||||||
onChange={e => setTaskName(e.target.value)}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
onBlur={() => {
|
|
||||||
if (!taskName.trim()) {
|
|
||||||
setShowInlineInput(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
className="text-xs dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleShowInlineInput}
|
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 gantt-add-task-btn"
|
|
||||||
>
|
|
||||||
Add Task
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
AddTaskRow.displayName = 'AddTaskRow';
|
|
||||||
|
|
||||||
// Add Phase Row Component
|
|
||||||
interface AddPhaseRowProps {
|
|
||||||
projectId: string;
|
|
||||||
onCreatePhase?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddPhaseRow: React.FC<AddPhaseRowProps> = memo(({ projectId, onCreatePhase }) => {
|
|
||||||
return (
|
|
||||||
<div className="gantt-add-phase-row flex min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
|
||||||
<div
|
|
||||||
className="w-full px-2 py-2 text-sm flex items-center"
|
|
||||||
style={{ paddingLeft: `8px` }}
|
|
||||||
onClick={onCreatePhase}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-4 h-4 flex items-center justify-center rounded bg-blue-500 text-white">
|
|
||||||
<PlusOutlined className="text-xs" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
|
||||||
Add New Phase
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-blue-500 dark:text-blue-300 opacity-80">
|
|
||||||
Click to create a new project phase
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
AddPhaseRow.displayName = 'AddPhaseRow';
|
|
||||||
|
|
||||||
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
tasks,
|
|
||||||
projectId,
|
|
||||||
viewMode,
|
|
||||||
onTaskToggle,
|
|
||||||
onTaskClick,
|
|
||||||
onPhaseClick,
|
|
||||||
onCreateTask,
|
|
||||||
onCreateQuickTask,
|
|
||||||
onCreatePhase,
|
|
||||||
onPhaseReorder,
|
|
||||||
onScroll,
|
|
||||||
expandedTasks: expandedTasksProp,
|
|
||||||
onExpandedTasksChange,
|
|
||||||
animatingTasks: animatingTasksProp,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>(
|
|
||||||
() => new Set(tasks.filter(t => t.expanded).map(t => t.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const expandedTasks = expandedTasksProp || localExpandedTasks;
|
|
||||||
const animatingTasks = animatingTasksProp || new Set();
|
|
||||||
|
|
||||||
// Drag and drop state
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Socket and auth
|
|
||||||
const { socket, connected } = useSocket();
|
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
|
||||||
|
|
||||||
// DnD sensors
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const handleTaskToggle = useCallback(
|
|
||||||
(taskId: string) => {
|
|
||||||
const updateExpanded = (prev: Set<string>) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(taskId)) {
|
|
||||||
newSet.delete(taskId);
|
|
||||||
} else {
|
|
||||||
newSet.add(taskId);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (onExpandedTasksChange) {
|
|
||||||
onExpandedTasksChange(updateExpanded(expandedTasks));
|
|
||||||
} else {
|
|
||||||
setLocalExpandedTasks(updateExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
onTaskToggle?.(taskId);
|
|
||||||
},
|
|
||||||
[expandedTasks, onExpandedTasksChange, onTaskToggle]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Flatten tasks based on expand/collapse state
|
|
||||||
const flattenTasks = useCallback(
|
|
||||||
(taskList: GanttTask[]): GanttTask[] => {
|
|
||||||
const result: GanttTask[] = [];
|
|
||||||
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
|
|
||||||
|
|
||||||
const processTask = (task: GanttTask, level: number = 0) => {
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
const phaseId = isPhase
|
|
||||||
? task.id === 'phase-unmapped'
|
|
||||||
? 'unmapped'
|
|
||||||
: task.phase_id || task.id.replace('phase-', '')
|
|
||||||
: task.id;
|
|
||||||
const isExpanded = expandedTasks.has(phaseId);
|
|
||||||
|
|
||||||
// Avoid processing the same task multiple times
|
|
||||||
if (processedIds.has(task.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processedIds.add(task.id);
|
|
||||||
|
|
||||||
// Set the correct level for nested tasks
|
|
||||||
const taskWithLevel = { ...task, level };
|
|
||||||
result.push(taskWithLevel);
|
|
||||||
|
|
||||||
if (isPhase && isExpanded) {
|
|
||||||
// Add children if they exist
|
|
||||||
if (task.children && task.children.length > 0) {
|
|
||||||
task.children.forEach(child => processTask(child, level + 1));
|
|
||||||
}
|
|
||||||
// Add a special "add task" row at the end (only if not already processed)
|
|
||||||
const addTaskId = `add-task-${task.id}`;
|
|
||||||
if (!processedIds.has(addTaskId)) {
|
|
||||||
processedIds.add(addTaskId);
|
|
||||||
result.push({
|
|
||||||
id: addTaskId,
|
|
||||||
name: 'Add Task',
|
|
||||||
type: 'add-task-button' as any,
|
|
||||||
phase_id: task.phase_id,
|
|
||||||
parent_phase_id: phaseId,
|
|
||||||
level: level + 1,
|
|
||||||
start_date: null,
|
|
||||||
end_date: null,
|
|
||||||
progress: 0,
|
|
||||||
} as GanttTask);
|
|
||||||
}
|
|
||||||
} else if (!isPhase && task.children && expandedTasks.has(task.id)) {
|
|
||||||
task.children.forEach(child => processTask(child, level + 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
taskList.forEach(task => processTask(task, 0));
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[expandedTasks]
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleTasks = flattenTasks(tasks);
|
|
||||||
|
|
||||||
// Emit task sort change via socket for moving tasks between phases
|
|
||||||
const emitTaskPhaseChange = useCallback(
|
|
||||||
(taskId: string, fromPhaseId: string | null, toPhaseId: string | null, sortOrder: number) => {
|
|
||||||
if (!socket || !connected || !projectId) return;
|
|
||||||
|
|
||||||
const task = visibleTasks.find(t => t.id === taskId);
|
|
||||||
if (!task || task.type === 'milestone' || task.is_milestone) return;
|
|
||||||
|
|
||||||
const teamId = currentSession?.team_id || '';
|
|
||||||
|
|
||||||
const socketData = {
|
|
||||||
project_id: projectId,
|
|
||||||
group_by: 'phase',
|
|
||||||
task_updates: [
|
|
||||||
{
|
|
||||||
task_id: taskId,
|
|
||||||
sort_order: sortOrder,
|
|
||||||
phase_id: toPhaseId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
from_group: fromPhaseId || 'unmapped',
|
|
||||||
to_group: toPhaseId || 'unmapped',
|
|
||||||
task: {
|
|
||||||
id: task.id,
|
|
||||||
project_id: projectId,
|
|
||||||
status: '',
|
|
||||||
priority: '',
|
|
||||||
},
|
|
||||||
team_id: teamId,
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
|
||||||
},
|
|
||||||
[socket, connected, projectId, visibleTasks, currentSession]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: any) => {
|
|
||||||
setActiveId(event.active.id as string);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!over) {
|
|
||||||
setOverId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOverId(over.id as string);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveId(null);
|
|
||||||
setOverId(null);
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const activeTask = visibleTasks.find(t => t.id === active.id);
|
|
||||||
const overTask = visibleTasks.find(t => t.id === over.id);
|
|
||||||
|
|
||||||
// Handle phase reordering (existing functionality)
|
|
||||||
if (
|
|
||||||
activeTask &&
|
|
||||||
(activeTask.type === 'milestone' || activeTask.is_milestone) &&
|
|
||||||
onPhaseReorder
|
|
||||||
) {
|
|
||||||
const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone);
|
|
||||||
const oldIndex = phases.findIndex(phase => phase.id === active.id);
|
|
||||||
const newIndex = phases.findIndex(phase => phase.id === over.id);
|
|
||||||
|
|
||||||
if (oldIndex !== -1 && newIndex !== -1) {
|
|
||||||
onPhaseReorder(oldIndex, newIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle task moving between phases
|
|
||||||
if (activeTask && !(activeTask.type === 'milestone' || activeTask.is_milestone)) {
|
|
||||||
let targetPhaseId: string | null = null;
|
|
||||||
|
|
||||||
// If dropped on a phase, move to that phase
|
|
||||||
if (overTask && (overTask.type === 'milestone' || overTask.is_milestone)) {
|
|
||||||
targetPhaseId = overTask.phase_id || overTask.id.replace('phase-', '');
|
|
||||||
if (overTask.id === 'phase-unmapped') {
|
|
||||||
targetPhaseId = null;
|
|
||||||
}
|
|
||||||
} else if (overTask) {
|
|
||||||
// If dropped on another task, move to that task's phase
|
|
||||||
targetPhaseId = overTask.phase_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find current phase
|
|
||||||
const currentPhaseId = activeTask.phase_id;
|
|
||||||
|
|
||||||
// Only emit if phase actually changed
|
|
||||||
if (currentPhaseId !== targetPhaseId) {
|
|
||||||
emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate phases and tasks for drag and drop (exclude unmapped phase)
|
|
||||||
const phases = visibleTasks.filter(
|
|
||||||
task => (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped'
|
|
||||||
);
|
|
||||||
const regularTasks = visibleTasks.filter(
|
|
||||||
task => !(task.type === 'milestone' || task.is_milestone)
|
|
||||||
);
|
|
||||||
|
|
||||||
// All draggable items (phases + tasks)
|
|
||||||
const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)];
|
|
||||||
const phasesSet = new Set(phases.map(p => p.id));
|
|
||||||
|
|
||||||
// Determine if the timeline has dual headers
|
|
||||||
const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode);
|
|
||||||
const headerHeight = hasDualHeaders ? 'h-20' : 'h-10';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[444px] min-w-[444px] max-w-[444px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container">
|
|
||||||
<div
|
|
||||||
className={`flex ${headerHeight} border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0 items-center`}
|
|
||||||
>
|
|
||||||
<div className="w-full px-4 text-gray-700 dark:text-gray-300">Task Name</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
|
|
||||||
{visibleTasks.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
No tasks available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext items={allDraggableItems} strategy={verticalListSortingStrategy}>
|
|
||||||
{visibleTasks.map((task, index) => {
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
|
||||||
const isUnmappedPhase = task.id === 'phase-unmapped';
|
|
||||||
const isAddTaskButton = task.type === 'add-task-button';
|
|
||||||
|
|
||||||
// Determine if this task should have animation classes
|
|
||||||
let parentPhaseId = '';
|
|
||||||
if (isPhase) {
|
|
||||||
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
|
|
||||||
} else if (isAddTaskButton) {
|
|
||||||
parentPhaseId = task.parent_phase_id || '';
|
|
||||||
} else {
|
|
||||||
parentPhaseId = task.phase_id || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldAnimate = !isPhase && animatingTasks.has(parentPhaseId);
|
|
||||||
const staggerIndex = Math.min((index - 1) % 5, 4); // Subtract 1 to account for phase row, limit stagger to 5 levels
|
|
||||||
|
|
||||||
if (isAddTaskButton) {
|
|
||||||
const animationClass = shouldAnimate
|
|
||||||
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={task.id} className={animationClass}>
|
|
||||||
<AddTaskRow
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (isPhase && !isUnmappedPhase) {
|
|
||||||
return (
|
|
||||||
<SortableTaskRow
|
|
||||||
key={task.id}
|
|
||||||
id={task.id}
|
|
||||||
task={task}
|
|
||||||
index={index}
|
|
||||||
projectId={projectId}
|
|
||||||
onToggle={handleTaskToggle}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
onPhaseClick={onPhaseClick}
|
|
||||||
expandedTasks={expandedTasks}
|
|
||||||
onCreateTask={onCreateTask}
|
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
|
||||||
activeId={activeId}
|
|
||||||
overId={overId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (isUnmappedPhase) {
|
|
||||||
return (
|
|
||||||
<TaskRow
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
index={index}
|
|
||||||
projectId={projectId}
|
|
||||||
onToggle={handleTaskToggle}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
onPhaseClick={onPhaseClick}
|
|
||||||
expandedTasks={expandedTasks}
|
|
||||||
onCreateTask={onCreateTask}
|
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
|
||||||
isDraggable={false}
|
|
||||||
activeId={activeId}
|
|
||||||
overId={overId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Regular tasks - make them draggable too with animation
|
|
||||||
const animationClass = shouldAnimate
|
|
||||||
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableTaskRow
|
|
||||||
key={task.id}
|
|
||||||
id={task.id}
|
|
||||||
task={task}
|
|
||||||
index={index}
|
|
||||||
projectId={projectId}
|
|
||||||
onToggle={handleTaskToggle}
|
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
onPhaseClick={onPhaseClick}
|
|
||||||
expandedTasks={expandedTasks}
|
|
||||||
onCreateTask={onCreateTask}
|
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
|
||||||
activeId={activeId}
|
|
||||||
overId={overId}
|
|
||||||
animationClass={animationClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{/* Add Phase Row - always at the bottom */}
|
|
||||||
<AddPhaseRow
|
|
||||||
projectId={projectId}
|
|
||||||
onCreatePhase={onCreatePhase}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GanttTaskList.displayName = 'GanttTaskList';
|
|
||||||
|
|
||||||
export default memo(GanttTaskList);
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import React, { memo, useMemo, forwardRef, RefObject } from 'react';
|
|
||||||
import { GanttViewMode } from '../../types/gantt-types';
|
|
||||||
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
|
||||||
import { TimelineUtils } from '../../utils/timeline-calculator';
|
|
||||||
|
|
||||||
interface GanttTimelineProps {
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
dateRange?: { start: Date; end: Date };
|
|
||||||
}
|
|
||||||
|
|
||||||
const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(
|
|
||||||
({ viewMode, containerRef, dateRange }, ref) => {
|
|
||||||
const { topHeaders, bottomHeaders } = useMemo(() => {
|
|
||||||
if (!dateRange) {
|
|
||||||
return { topHeaders: [], bottomHeaders: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, end } = dateRange;
|
|
||||||
const topHeaders: Array<{ label: string; key: string; span: number }> = [];
|
|
||||||
const bottomHeaders: Array<{ label: string; key: string }> = [];
|
|
||||||
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'month':
|
|
||||||
// Top: Years, Bottom: Months
|
|
||||||
const startYear = start.getFullYear();
|
|
||||||
const startMonth = start.getMonth();
|
|
||||||
const endYear = end.getFullYear();
|
|
||||||
const endMonth = end.getMonth();
|
|
||||||
|
|
||||||
// Generate bottom headers (months)
|
|
||||||
let currentYear = startYear;
|
|
||||||
let currentMonth = startMonth;
|
|
||||||
|
|
||||||
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
|
|
||||||
const date = new Date(currentYear, currentMonth, 1);
|
|
||||||
bottomHeaders.push({
|
|
||||||
label: date.toLocaleDateString('en-US', { month: 'short' }),
|
|
||||||
key: `month-${currentYear}-${currentMonth}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
currentMonth++;
|
|
||||||
if (currentMonth > 11) {
|
|
||||||
currentMonth = 0;
|
|
||||||
currentYear++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate top headers (years)
|
|
||||||
for (let year = startYear; year <= endYear; year++) {
|
|
||||||
const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length;
|
|
||||||
if (monthsInYear > 0) {
|
|
||||||
topHeaders.push({
|
|
||||||
label: `${year}`,
|
|
||||||
key: `year-${year}`,
|
|
||||||
span: monthsInYear,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'week':
|
|
||||||
// Top: Months, Bottom: Weeks
|
|
||||||
const weekStart = new Date(start);
|
|
||||||
const weekEnd = new Date(end);
|
|
||||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
|
||||||
|
|
||||||
const weekDates: Date[] = [];
|
|
||||||
const tempDate = new Date(weekStart);
|
|
||||||
while (tempDate <= weekEnd) {
|
|
||||||
weekDates.push(new Date(tempDate));
|
|
||||||
tempDate.setDate(tempDate.getDate() + 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate bottom headers (weeks)
|
|
||||||
weekDates.forEach(date => {
|
|
||||||
const weekNum = TimelineUtils.getWeekNumber(date);
|
|
||||||
bottomHeaders.push({
|
|
||||||
label: `W${weekNum}`,
|
|
||||||
key: `week-${date.getFullYear()}-${weekNum}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate top headers (months)
|
|
||||||
const monthGroups = new Map<string, number>();
|
|
||||||
weekDates.forEach(date => {
|
|
||||||
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
|
||||||
monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
monthGroups.forEach((count, monthKey) => {
|
|
||||||
const [year, month] = monthKey.split('-').map(Number);
|
|
||||||
const date = new Date(year, month, 1);
|
|
||||||
topHeaders.push({
|
|
||||||
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
|
||||||
key: `month-${monthKey}`,
|
|
||||||
span: count,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'day':
|
|
||||||
// Top: Months, Bottom: Days
|
|
||||||
const dayStart = new Date(start);
|
|
||||||
const dayEnd = new Date(end);
|
|
||||||
|
|
||||||
const dayDates: Date[] = [];
|
|
||||||
const tempDayDate = new Date(dayStart);
|
|
||||||
while (tempDayDate <= dayEnd) {
|
|
||||||
dayDates.push(new Date(tempDayDate));
|
|
||||||
tempDayDate.setDate(tempDayDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate bottom headers (days)
|
|
||||||
dayDates.forEach(date => {
|
|
||||||
bottomHeaders.push({
|
|
||||||
label: date.getDate().toString(),
|
|
||||||
key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate top headers (months)
|
|
||||||
const dayMonthGroups = new Map<string, number>();
|
|
||||||
dayDates.forEach(date => {
|
|
||||||
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
|
||||||
dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
dayMonthGroups.forEach((count, monthKey) => {
|
|
||||||
const [year, month] = monthKey.split('-').map(Number);
|
|
||||||
const date = new Date(year, month, 1);
|
|
||||||
topHeaders.push({
|
|
||||||
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
|
||||||
key: `month-${monthKey}`,
|
|
||||||
span: count,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback to single row for other view modes
|
|
||||||
const result = [];
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'quarter':
|
|
||||||
const qStartYear = start.getFullYear();
|
|
||||||
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
|
||||||
const qEndYear = end.getFullYear();
|
|
||||||
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
|
||||||
|
|
||||||
let qYear = qStartYear;
|
|
||||||
let qQuarter = qStartQuarter;
|
|
||||||
|
|
||||||
while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) {
|
|
||||||
result.push({
|
|
||||||
label: `Q${qQuarter} ${qYear}`,
|
|
||||||
key: `quarter-${qYear}-${qQuarter}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
qQuarter++;
|
|
||||||
if (qQuarter > 4) {
|
|
||||||
qQuarter = 1;
|
|
||||||
qYear++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
const yearStart = start.getFullYear();
|
|
||||||
const yearEnd = end.getFullYear();
|
|
||||||
|
|
||||||
for (let year = yearStart; year <= yearEnd; year++) {
|
|
||||||
result.push({
|
|
||||||
label: `${year}`,
|
|
||||||
key: `year-${year}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.forEach(item => {
|
|
||||||
bottomHeaders.push(item);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { topHeaders, bottomHeaders };
|
|
||||||
}, [viewMode, dateRange]);
|
|
||||||
|
|
||||||
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
|
|
||||||
viewMode,
|
|
||||||
containerRef,
|
|
||||||
bottomHeaders.length
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasTopHeaders = topHeaders.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={`${hasTopHeaders ? 'h-20' : 'h-10'} flex-shrink-0 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-y-hidden ${
|
|
||||||
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
|
|
||||||
} scrollbar-hide flex flex-col`}
|
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
||||||
>
|
|
||||||
{hasTopHeaders && (
|
|
||||||
<div
|
|
||||||
className="flex h-10 border-b border-gray-200 dark:border-gray-700"
|
|
||||||
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
|
|
||||||
>
|
|
||||||
{topHeaders.map(header => (
|
|
||||||
<div
|
|
||||||
key={header.key}
|
|
||||||
className="py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-800 dark:text-gray-200 flex-shrink-0 px-2 whitespace-nowrap bg-gray-50 dark:bg-gray-750"
|
|
||||||
style={{ width: `${actualColumnWidth * header.span}px` }}
|
|
||||||
title={header.label}
|
|
||||||
>
|
|
||||||
{header.label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="flex h-10"
|
|
||||||
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
|
|
||||||
>
|
|
||||||
{bottomHeaders.map(header => (
|
|
||||||
<div
|
|
||||||
key={header.key}
|
|
||||||
className={`py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 flex-shrink-0 ${
|
|
||||||
viewMode === 'day' ? 'px-1 text-xs' : 'px-2'
|
|
||||||
} ${
|
|
||||||
viewMode === 'day' && actualColumnWidth < 50
|
|
||||||
? 'whitespace-nowrap overflow-hidden text-ellipsis'
|
|
||||||
: 'whitespace-nowrap'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${actualColumnWidth}px` }}
|
|
||||||
title={header.label}
|
|
||||||
>
|
|
||||||
{header.label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GanttTimeline.displayName = 'GanttTimeline';
|
|
||||||
|
|
||||||
export default memo(GanttTimeline);
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React, { memo } from 'react';
|
|
||||||
import { Select, Button, Space } from 'antd';
|
|
||||||
import {
|
|
||||||
ZoomInOutlined,
|
|
||||||
ZoomOutOutlined,
|
|
||||||
FullscreenOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { GanttViewMode } from '../../types/gantt-types';
|
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
interface GanttToolbarProps {
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
onViewModeChange: (mode: GanttViewMode) => void;
|
|
||||||
dateRange?: { start: Date; end: Date };
|
|
||||||
}
|
|
||||||
|
|
||||||
const GanttToolbar: React.FC<GanttToolbarProps> = memo(
|
|
||||||
({ viewMode, onViewModeChange, dateRange }) => {
|
|
||||||
// Define zoom levels in order from most detailed to least detailed
|
|
||||||
const zoomLevels: GanttViewMode[] = ['day', 'week', 'month', 'quarter', 'year'];
|
|
||||||
const currentZoomIndex = zoomLevels.indexOf(viewMode);
|
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
|
||||||
// Zoom in means more detail (lower index)
|
|
||||||
if (currentZoomIndex > 0) {
|
|
||||||
onViewModeChange(zoomLevels[currentZoomIndex - 1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
|
||||||
// Zoom out means less detail (higher index)
|
|
||||||
if (currentZoomIndex < zoomLevels.length - 1) {
|
|
||||||
onViewModeChange(zoomLevels[currentZoomIndex + 1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFullscreen = () => {
|
|
||||||
// Toggle fullscreen mode
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
document.documentElement.requestFullscreen().catch(err => {
|
|
||||||
console.warn('Failed to enter fullscreen:', err);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.exitFullscreen().catch(err => {
|
|
||||||
console.warn('Failed to exit fullscreen:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
|
||||||
<Space>
|
|
||||||
<Select value={viewMode} onChange={onViewModeChange} className="w-32">
|
|
||||||
<Option value="day">Day</Option>
|
|
||||||
<Option value="week">Week</Option>
|
|
||||||
<Option value="month">Month</Option>
|
|
||||||
<Option value="quarter">Quarter</Option>
|
|
||||||
<Option value="year">Year</Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={<ZoomInOutlined />}
|
|
||||||
title="Zoom In"
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
disabled={currentZoomIndex === 0}
|
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={<ZoomOutOutlined />}
|
|
||||||
title="Zoom Out"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
disabled={currentZoomIndex === zoomLevels.length - 1}
|
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={<FullscreenOutlined />}
|
|
||||||
title="Toggle Fullscreen"
|
|
||||||
onClick={handleFullscreen}
|
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GanttToolbar.displayName = 'GanttToolbar';
|
|
||||||
|
|
||||||
export default GanttToolbar;
|
|
||||||
@@ -1,630 +0,0 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { Modal, Typography, Divider, Progress, Tag, Row, Col, Card, Statistic, theme, Tooltip, Input, DatePicker, ColorPicker, message } from 'antd';
|
|
||||||
import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, BgColorsOutlined, MinusOutlined, PauseOutlined, DoubleRightOutlined, UserOutlined } from '@ant-design/icons';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import AvatarGroup from '@/components/AvatarGroup';
|
|
||||||
import { GanttTask } from '../../types/gantt-types';
|
|
||||||
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
interface PhaseDetailsModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
phase: GanttTask | null;
|
|
||||||
onPhaseUpdate?: (phase: Partial<GanttTask>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PhaseDetailsModal: React.FC<PhaseDetailsModalProps> = ({ open, onClose, phase, onPhaseUpdate }) => {
|
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
|
||||||
const { t } = useTranslation('gantt/phase-details-modal');
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
// API mutation hook
|
|
||||||
const [updatePhase, { isLoading: isUpdating }] = useUpdatePhaseMutation();
|
|
||||||
|
|
||||||
// Inline editing state
|
|
||||||
const [editingField, setEditingField] = useState<string | null>(null);
|
|
||||||
const [editedValues, setEditedValues] = useState<Partial<GanttTask>>({});
|
|
||||||
|
|
||||||
// Calculate phase statistics
|
|
||||||
const phaseStats = useMemo(() => {
|
|
||||||
if (!phase || !phase.children) {
|
|
||||||
return {
|
|
||||||
totalTasks: 0,
|
|
||||||
completedTasks: 0,
|
|
||||||
pendingTasks: 0,
|
|
||||||
overdueTasks: 0,
|
|
||||||
completionPercentage: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTasks = phase.children.length;
|
|
||||||
const completedTasks = phase.children.filter(task => task.progress === 100).length;
|
|
||||||
const pendingTasks = totalTasks - completedTasks;
|
|
||||||
|
|
||||||
// Calculate overdue tasks (tasks with end_date in the past and progress < 100)
|
|
||||||
const now = new Date();
|
|
||||||
const overdueTasks = phase.children.filter(task =>
|
|
||||||
task.end_date &&
|
|
||||||
new Date(task.end_date) < now &&
|
|
||||||
task.progress < 100
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalTasks,
|
|
||||||
completedTasks,
|
|
||||||
pendingTasks,
|
|
||||||
overdueTasks,
|
|
||||||
completionPercentage,
|
|
||||||
};
|
|
||||||
}, [phase]);
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null) => {
|
|
||||||
if (!date) return t('timeline.notSet');
|
|
||||||
return dayjs(date).format('MMM DD, YYYY');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDateStatus = () => {
|
|
||||||
if (!phase?.start_date || !phase?.end_date) return 'not-set';
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const startDate = new Date(phase.start_date);
|
|
||||||
const endDate = new Date(phase.end_date);
|
|
||||||
|
|
||||||
if (now < startDate) return 'upcoming';
|
|
||||||
if (now > endDate) return 'overdue';
|
|
||||||
return 'active';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDateStatusColor = () => {
|
|
||||||
const status = getDateStatus();
|
|
||||||
switch (status) {
|
|
||||||
case 'upcoming': return '#1890ff';
|
|
||||||
case 'active': return '#52c41a';
|
|
||||||
case 'overdue': return '#ff4d4f';
|
|
||||||
default: return '#8c8c8c';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDateStatusText = () => {
|
|
||||||
const status = getDateStatus();
|
|
||||||
switch (status) {
|
|
||||||
case 'upcoming': return t('timeline.statusLabels.upcoming');
|
|
||||||
case 'active': return t('timeline.statusLabels.active');
|
|
||||||
case 'overdue': return t('timeline.statusLabels.overdue');
|
|
||||||
default: return t('timeline.statusLabels.notScheduled');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTaskStatus = (task: GanttTask) => {
|
|
||||||
if (task.progress === 100) return 'completed';
|
|
||||||
if (task.end_date && new Date(task.end_date) < new Date() && task.progress < 100) return 'overdue';
|
|
||||||
if (task.start_date && new Date(task.start_date) > new Date()) return 'upcoming';
|
|
||||||
return 'in-progress';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTaskStatusText = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'Completed';
|
|
||||||
case 'overdue': return 'Overdue';
|
|
||||||
case 'upcoming': return 'Upcoming';
|
|
||||||
case 'in-progress': return 'In Progress';
|
|
||||||
default: return 'Not Started';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTaskStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return token.colorSuccess;
|
|
||||||
case 'overdue': return token.colorError;
|
|
||||||
case 'upcoming': return token.colorPrimary;
|
|
||||||
case 'in-progress': return token.colorWarning;
|
|
||||||
default: return token.colorTextTertiary;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string) => {
|
|
||||||
const priorityLower = priority?.toLowerCase();
|
|
||||||
switch (priorityLower) {
|
|
||||||
case 'low':
|
|
||||||
return <MinusOutlined className="w-3 h-3" />;
|
|
||||||
case 'medium':
|
|
||||||
return <PauseOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
|
|
||||||
case 'high':
|
|
||||||
return <DoubleRightOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
|
|
||||||
default:
|
|
||||||
return <MinusOutlined className="w-3 h-3" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
const priorityLower = priority?.toLowerCase();
|
|
||||||
switch (priorityLower) {
|
|
||||||
case 'low': return '#52c41a';
|
|
||||||
case 'medium': return '#faad14';
|
|
||||||
case 'high': return '#ff4d4f';
|
|
||||||
default: return token.colorTextTertiary;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertAssigneesToMembers = (assignees: string[] | undefined) => {
|
|
||||||
if (!assignees || assignees.length === 0) return [];
|
|
||||||
|
|
||||||
return assignees.map((assignee, index) => ({
|
|
||||||
id: `assignee-${index}`,
|
|
||||||
name: assignee,
|
|
||||||
color_code: token.colorPrimary,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFieldSave = async (field: string, value: any) => {
|
|
||||||
if (!phase || !projectId) {
|
|
||||||
message.error('Phase or project information is missing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the actual phase_id from the phase object
|
|
||||||
const phaseId = phase.phase_id || (phase.id.startsWith('phase-') ? phase.id.replace('phase-', '') : phase.id);
|
|
||||||
|
|
||||||
if (!phaseId || phaseId === 'unmapped') {
|
|
||||||
message.error('Cannot edit unmapped phase');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prepare API request based on field
|
|
||||||
const updateData: any = {
|
|
||||||
phase_id: phaseId,
|
|
||||||
project_id: projectId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map the field to API format
|
|
||||||
if (field === 'name') {
|
|
||||||
updateData.name = value;
|
|
||||||
} else if (field === 'color') {
|
|
||||||
updateData.color_code = value;
|
|
||||||
} else if (field === 'start_date') {
|
|
||||||
updateData.start_date = value ? new Date(value).toISOString() : null;
|
|
||||||
} else if (field === 'end_date') {
|
|
||||||
updateData.end_date = value ? new Date(value).toISOString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the API
|
|
||||||
await updatePhase(updateData).unwrap();
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
message.success(`Phase ${field.replace('_', ' ')} updated successfully`);
|
|
||||||
|
|
||||||
// Call the parent handler to refresh data
|
|
||||||
if (onPhaseUpdate) {
|
|
||||||
onPhaseUpdate({
|
|
||||||
id: phase.id,
|
|
||||||
[field]: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear editing state
|
|
||||||
setEditingField(null);
|
|
||||||
setEditedValues({});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to update phase:', error);
|
|
||||||
message.error(error?.data?.message || `Failed to update phase ${field.replace('_', ' ')}`);
|
|
||||||
|
|
||||||
// Don't clear editing state on error so user can try again
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFieldCancel = () => {
|
|
||||||
setEditingField(null);
|
|
||||||
setEditedValues({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEditing = (field: string, currentValue: any) => {
|
|
||||||
setEditingField(field);
|
|
||||||
setEditedValues({ [field]: currentValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!phase) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ColorPicker
|
|
||||||
value={phase.color || token.colorPrimary}
|
|
||||||
onChange={(color) => handleFieldSave('color', color.toHexString())}
|
|
||||||
size="small"
|
|
||||||
showText={false}
|
|
||||||
trigger="click"
|
|
||||||
/>
|
|
||||||
{editingField === 'name' ? (
|
|
||||||
<Input
|
|
||||||
value={editedValues.name || phase.name}
|
|
||||||
onChange={(e) => setEditedValues(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
onPressEnter={() => handleFieldSave('name', editedValues.name)}
|
|
||||||
onBlur={() => handleFieldSave('name', editedValues.name)}
|
|
||||||
onKeyDown={(e) => e.key === 'Escape' && handleFieldCancel()}
|
|
||||||
className="font-semibold text-lg"
|
|
||||||
style={{ border: 'none', padding: 0, background: 'transparent' }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Title
|
|
||||||
level={4}
|
|
||||||
className="!mb-0 cursor-pointer hover:opacity-70"
|
|
||||||
style={{ color: token.colorText }}
|
|
||||||
onClick={() => startEditing('name', phase.name)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
{phase.name}
|
|
||||||
</Title>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={open}
|
|
||||||
onCancel={onClose}
|
|
||||||
footer={null}
|
|
||||||
width={1000}
|
|
||||||
centered
|
|
||||||
className="phase-details-modal"
|
|
||||||
confirmLoading={isUpdating}
|
|
||||||
>
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{/* Left Side - Phase Overview and Stats */}
|
|
||||||
<div className="flex-1 space-y-6">
|
|
||||||
{/* Phase Overview */}
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="shadow-sm"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('overview.totalTasks')}
|
|
||||||
value={phaseStats.totalTasks}
|
|
||||||
prefix={<ClockCircleOutlined style={{ color: token.colorPrimary }} />}
|
|
||||||
valueStyle={{ color: token.colorText }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic
|
|
||||||
title={t('overview.completion')}
|
|
||||||
value={phaseStats.completionPercentage}
|
|
||||||
suffix="%"
|
|
||||||
prefix={<CheckCircleOutlined style={{ color: token.colorSuccess }} />}
|
|
||||||
valueStyle={{ color: token.colorText }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Divider className="my-4" style={{ borderColor: token.colorBorder }} />
|
|
||||||
<Progress
|
|
||||||
percent={phaseStats.completionPercentage}
|
|
||||||
strokeColor={{
|
|
||||||
'0%': phase.color || token.colorPrimary,
|
|
||||||
'100%': phase.color || token.colorPrimary,
|
|
||||||
}}
|
|
||||||
trailColor={token.colorBgLayout}
|
|
||||||
className="mb-2"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Date Information */}
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong style={{ color: token.colorText }}>{t('timeline.title')}</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className="shadow-sm"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Text type="secondary">{t('timeline.startDate')}</Text>
|
|
||||||
<br />
|
|
||||||
{editingField === 'start_date' ? (
|
|
||||||
<DatePicker
|
|
||||||
value={editedValues.start_date ? dayjs(editedValues.start_date) : (phase.start_date ? dayjs(phase.start_date) : null)}
|
|
||||||
onChange={(date) => {
|
|
||||||
const newDate = date?.toDate() || null;
|
|
||||||
setEditedValues(prev => ({ ...prev, start_date: newDate }));
|
|
||||||
handleFieldSave('start_date', newDate);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Select start date"
|
|
||||||
autoFocus
|
|
||||||
open={true}
|
|
||||||
onOpenChange={(open) => !open && handleFieldCancel()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
className="cursor-pointer hover:opacity-70"
|
|
||||||
style={{ color: token.colorText }}
|
|
||||||
onClick={() => startEditing('start_date', phase.start_date)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
{formatDate(phase.start_date)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Text type="secondary">{t('timeline.endDate')}</Text>
|
|
||||||
<br />
|
|
||||||
{editingField === 'end_date' ? (
|
|
||||||
<DatePicker
|
|
||||||
value={editedValues.end_date ? dayjs(editedValues.end_date) : (phase.end_date ? dayjs(phase.end_date) : null)}
|
|
||||||
onChange={(date) => {
|
|
||||||
const newDate = date?.toDate() || null;
|
|
||||||
setEditedValues(prev => ({ ...prev, end_date: newDate }));
|
|
||||||
handleFieldSave('end_date', newDate);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Select end date"
|
|
||||||
autoFocus
|
|
||||||
open={true}
|
|
||||||
onOpenChange={(open) => !open && handleFieldCancel()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
className="cursor-pointer hover:opacity-70"
|
|
||||||
style={{ color: token.colorText }}
|
|
||||||
onClick={() => startEditing('end_date', phase.end_date)}
|
|
||||||
title="Click to edit"
|
|
||||||
>
|
|
||||||
{formatDate(phase.end_date)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Text type="secondary">{t('timeline.status')}</Text>
|
|
||||||
<br />
|
|
||||||
<Tag color={getDateStatusColor()}>{getDateStatusText()}</Tag>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Task Breakdown */}
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
|
||||||
<Text strong style={{ color: token.colorText }}>{t('taskBreakdown.title')}</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className="shadow-sm"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-500 dark:text-green-400">
|
|
||||||
{phaseStats.completedTasks}
|
|
||||||
</div>
|
|
||||||
<Text type="secondary">{t('taskBreakdown.completed')}</Text>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-yellow-500 dark:text-yellow-400">
|
|
||||||
{phaseStats.pendingTasks}
|
|
||||||
</div>
|
|
||||||
<Text type="secondary">{t('taskBreakdown.pending')}</Text>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-red-500 dark:text-red-400">
|
|
||||||
{phaseStats.overdueTasks}
|
|
||||||
</div>
|
|
||||||
<Text type="secondary">{t('taskBreakdown.overdue')}</Text>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Color Information */}
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BgColorsOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong style={{ color: token.colorText }}>{t('phaseColor.title')}</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className="shadow-sm"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: phase.color || token.colorPrimary,
|
|
||||||
borderColor: token.colorBorder,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Text strong style={{ color: token.colorText }}>{phase.color || token.colorPrimary}</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">{t('phaseColor.description')}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Task List */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{phase.children && phase.children.length > 0 ? (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<Text strong style={{ color: token.colorText }}>{t('tasksInPhase.title')}</Text>
|
|
||||||
}
|
|
||||||
className="shadow-sm flex-1 flex flex-col"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px' }}
|
|
||||||
>
|
|
||||||
<div className="space-y-3 flex-1 overflow-y-auto">
|
|
||||||
{phase.children.map((task) => {
|
|
||||||
const taskStatus = getTaskStatus(task);
|
|
||||||
const taskStatusColor = getTaskStatusColor(taskStatus);
|
|
||||||
|
|
||||||
const assigneeMembers = convertAssigneesToMembers(task.assignees);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className={`p-3 rounded-md border transition-colors hover:shadow-sm ${
|
|
||||||
task.progress === 100
|
|
||||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: task.progress === 100
|
|
||||||
? undefined
|
|
||||||
: token.colorBgContainer,
|
|
||||||
borderColor: task.progress === 100
|
|
||||||
? undefined
|
|
||||||
: token.colorBorder
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Main row with task info */}
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-2">
|
|
||||||
{/* Left side: Status icon, task name, and priority */}
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
{task.progress === 100 ? (
|
|
||||||
<CheckCircleOutlined
|
|
||||||
className="flex-shrink-0"
|
|
||||||
style={{ color: token.colorSuccess, fontSize: '14px' }}
|
|
||||||
/>
|
|
||||||
) : taskStatus === 'overdue' ? (
|
|
||||||
<ClockCircleOutlined
|
|
||||||
className="flex-shrink-0"
|
|
||||||
style={{ color: token.colorError, fontSize: '14px' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ClockCircleOutlined
|
|
||||||
className="flex-shrink-0"
|
|
||||||
style={{ color: token.colorWarning, fontSize: '14px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
className="text-sm truncate flex-1"
|
|
||||||
style={{ color: token.colorText }}
|
|
||||||
title={task.name}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Priority Icon */}
|
|
||||||
{task.priority && (
|
|
||||||
<Tooltip title={`Priority: ${task.priority}`}>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: getPriorityColor(task.priority),
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getPriorityIcon(task.priority)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side: Status tag */}
|
|
||||||
<Tag
|
|
||||||
color={taskStatusColor}
|
|
||||||
className="text-xs font-medium flex-shrink-0"
|
|
||||||
>
|
|
||||||
{getTaskStatusText(taskStatus)}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom row with assignees, progress, and due date */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
{/* Assignees */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{assigneeMembers.length > 0 ? (
|
|
||||||
<AvatarGroup
|
|
||||||
members={assigneeMembers}
|
|
||||||
maxCount={3}
|
|
||||||
size={20}
|
|
||||||
isDarkMode={token.mode === 'dark'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1 text-gray-400">
|
|
||||||
<UserOutlined className="text-xs" />
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
Unassigned
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
|
||||||
<div className="flex items-center justify-end flex-1">
|
|
||||||
{task.end_date ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<CalendarOutlined
|
|
||||||
className="text-xs"
|
|
||||||
style={{
|
|
||||||
color: taskStatus === 'overdue' ? token.colorError : token.colorTextTertiary
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
className={`text-xs ${taskStatus === 'overdue' ? 'text-red-500 dark:text-red-400' : ''}`}
|
|
||||||
>
|
|
||||||
{dayjs(task.end_date).format('MMM DD')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary" className="text-xs italic">
|
|
||||||
No due date
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="shadow-sm flex-1 flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
|
|
||||||
bodyStyle={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<ClockCircleOutlined className="text-4xl mb-3" style={{ color: token.colorTextTertiary }} />
|
|
||||||
<Text type="secondary" className="text-lg">
|
|
||||||
{t('tasksInPhase.noTasks')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PhaseDetailsModal;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export const GANTT_COLUMN_WIDTH = 80; // Base column width in pixels
|
|
||||||
|
|
||||||
export const getColumnWidth = (viewMode: string): number => {
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
return 40;
|
|
||||||
case 'week':
|
|
||||||
return 60;
|
|
||||||
case 'month':
|
|
||||||
return 80;
|
|
||||||
case 'quarter':
|
|
||||||
return 120;
|
|
||||||
case 'year':
|
|
||||||
return 160;
|
|
||||||
default:
|
|
||||||
return 80;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React, { createContext, useContext } from 'react';
|
|
||||||
import { GanttContextType } from '../types/gantt-types';
|
|
||||||
|
|
||||||
const GanttContext = createContext<GanttContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const GanttProvider: React.FC<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
value: GanttContextType;
|
|
||||||
}> = ({ children, value }) => {
|
|
||||||
return <GanttContext.Provider value={value}>{children}</GanttContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGanttContext = () => {
|
|
||||||
const context = useContext(GanttContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useGanttContext must be used within a GanttProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gantt task list specific styles */
|
|
||||||
.gantt-task-list-container {
|
|
||||||
/* Ensure the task list stays fixed and doesn't interfere with timeline scrolling */
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-timeline-container {
|
|
||||||
/* Ensure timeline scrolling doesn't affect task list positioning */
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar for task list - hide scrollbar */
|
|
||||||
.gantt-task-list-scroll {
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-width: none; /* Firefox - hide scrollbar */
|
|
||||||
-ms-overflow-style: none; /* IE and Edge - hide scrollbar */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Webkit scrollbar styling - hide scrollbar */
|
|
||||||
.gantt-task-list-scroll::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gantt chart scrollbar - show both vertical and horizontal */
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #cbd5e0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox - hide horizontal scrollbar */
|
|
||||||
.gantt-chart-scroll {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #cbd5e0 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode chart scrollbar */
|
|
||||||
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .gantt-chart-scroll {
|
|
||||||
scrollbar-color: #4a5568 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure consistent row heights and alignment */
|
|
||||||
.gantt-task-list-scroll,
|
|
||||||
.gantt-chart-scroll {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
/* Ensure same scrolling behavior */
|
|
||||||
scroll-behavior: auto;
|
|
||||||
/* Prevent sub-pixel rendering issues */
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: scroll-position;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure all rows have exact same box model */
|
|
||||||
.gantt-task-list-scroll > div > div > div,
|
|
||||||
.gantt-chart-scroll > div > div > div {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Force consistent border rendering */
|
|
||||||
.gantt-task-list-scroll .border-b,
|
|
||||||
.gantt-chart-scroll .border-b {
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve visual hierarchy for phase rows */
|
|
||||||
.gantt-phase-row {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-phase-row:hover {
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-phase-row::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 4px;
|
|
||||||
background-color: currentColor;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-phase-row:hover::before {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better hover states */
|
|
||||||
.gantt-task-row:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .gantt-task-row:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improved button styles */
|
|
||||||
.gantt-add-task-btn {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-add-task-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Phase expansion transitions */
|
|
||||||
.gantt-phase-children {
|
|
||||||
overflow: hidden;
|
|
||||||
transition:
|
|
||||||
max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
opacity 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-phase-children.collapsed {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-phase-children.expanded {
|
|
||||||
max-height: 2000px; /* Adjust based on expected max children */
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual task transitions */
|
|
||||||
.gantt-task-row,
|
|
||||||
.gantt-add-task-inline {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Staggered animation for multiple tasks */
|
|
||||||
.gantt-task-stagger-1 { animation-delay: 0.05s; }
|
|
||||||
.gantt-task-stagger-2 { animation-delay: 0.1s; }
|
|
||||||
.gantt-task-stagger-3 { animation-delay: 0.15s; }
|
|
||||||
.gantt-task-stagger-4 { animation-delay: 0.2s; }
|
|
||||||
.gantt-task-stagger-5 { animation-delay: 0.25s; }
|
|
||||||
|
|
||||||
/* Expand/collapse icon transitions */
|
|
||||||
.gantt-expand-icon {
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-expand-icon.expanded {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task row slide-in animation */
|
|
||||||
.gantt-task-slide-in {
|
|
||||||
animation: slideIn 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-task-slide-out {
|
|
||||||
animation: slideOut 0.2s ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
max-height: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
max-height: 36px; /* Height of a task row */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOut {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
max-height: 36px;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
max-height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add task button specific styles */
|
|
||||||
.gantt-add-task-inline {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
animation: fadeIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Timeline task bar transitions */
|
|
||||||
.gantt-chart-scroll .gantt-task-slide-in {
|
|
||||||
animation: slideInTimeline 0.3s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInTimeline {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px) scale(0.95);
|
|
||||||
max-height: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
max-height: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced timeline task bar styling */
|
|
||||||
.gantt-chart-scroll .relative {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure timeline task bars have smooth hover transitions */
|
|
||||||
.gantt-chart-scroll .hover\\:bg-gray-50:hover {
|
|
||||||
transition: background-color 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, RefObject } from 'react';
|
|
||||||
import { GanttViewMode } from '../types/gantt-types';
|
|
||||||
import { getColumnWidth } from '../constants/gantt-constants';
|
|
||||||
|
|
||||||
export const useGanttDimensions = (
|
|
||||||
viewMode: GanttViewMode,
|
|
||||||
containerRef: RefObject<HTMLDivElement>,
|
|
||||||
columnsCount: number
|
|
||||||
) => {
|
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
|
||||||
|
|
||||||
const updateContainerWidth = useCallback(() => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
setContainerWidth(containerRef.current.offsetWidth);
|
|
||||||
}
|
|
||||||
}, [containerRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateContainerWidth();
|
|
||||||
window.addEventListener('resize', updateContainerWidth);
|
|
||||||
return () => window.removeEventListener('resize', updateContainerWidth);
|
|
||||||
}, [updateContainerWidth]);
|
|
||||||
|
|
||||||
const baseColumnWidth = getColumnWidth(viewMode);
|
|
||||||
const minTotalWidth = columnsCount * baseColumnWidth;
|
|
||||||
|
|
||||||
// For day/week views with many columns, always use base width to enable scrolling
|
|
||||||
// For month/quarter/year views, stretch to fill container if wider
|
|
||||||
const shouldStretch = viewMode !== 'day' && viewMode !== 'week';
|
|
||||||
|
|
||||||
const actualColumnWidth =
|
|
||||||
shouldStretch && containerWidth > minTotalWidth
|
|
||||||
? containerWidth / columnsCount
|
|
||||||
: baseColumnWidth;
|
|
||||||
|
|
||||||
const totalWidth = columnsCount * actualColumnWidth;
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerWidth,
|
|
||||||
actualColumnWidth,
|
|
||||||
totalWidth,
|
|
||||||
columnsCount,
|
|
||||||
shouldScroll: totalWidth > containerWidth,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
||||||
import { API_BASE_URL } from '@/shared/constants';
|
|
||||||
import { IServerResponse } from '@/types/common.types';
|
|
||||||
import { getCsrfToken, refreshCsrfToken } from '@/api/api-client';
|
|
||||||
import config from '@/config/env';
|
|
||||||
import { GanttTask, GanttPhase } from '../types/gantt-types';
|
|
||||||
|
|
||||||
const rootUrl = '/gantt';
|
|
||||||
|
|
||||||
export interface RoadmapTasksResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
start_date: string | null;
|
|
||||||
end_date: string | null;
|
|
||||||
done: boolean;
|
|
||||||
progress: number;
|
|
||||||
roadmap_sort_order: number;
|
|
||||||
parent_task_id: string | null;
|
|
||||||
status_name: string;
|
|
||||||
status_color: string;
|
|
||||||
priority_name: string;
|
|
||||||
priority_value: number;
|
|
||||||
priority_color: string;
|
|
||||||
phases: Array<{
|
|
||||||
phase_id: string;
|
|
||||||
phase_name: string;
|
|
||||||
phase_color: string;
|
|
||||||
}>;
|
|
||||||
assignees: Array<{
|
|
||||||
team_member_id: string;
|
|
||||||
assignee_name: string;
|
|
||||||
avatar_url?: string;
|
|
||||||
}>;
|
|
||||||
dependencies: Array<{
|
|
||||||
related_task_id: string;
|
|
||||||
dependency_type: string;
|
|
||||||
related_task_name: string;
|
|
||||||
}>;
|
|
||||||
subtasks: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
start_date: string | null;
|
|
||||||
end_date: string | null;
|
|
||||||
done: boolean;
|
|
||||||
progress: number;
|
|
||||||
roadmap_sort_order: number;
|
|
||||||
parent_task_id: string;
|
|
||||||
phase_id?: string | null; // Keep this for subtasks compatibility
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectPhaseResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color_code: string;
|
|
||||||
start_date: string | null;
|
|
||||||
end_date: string | null;
|
|
||||||
sort_index: number;
|
|
||||||
todo_progress: number;
|
|
||||||
doing_progress: number;
|
|
||||||
done_progress: number;
|
|
||||||
total_tasks: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateTaskDatesRequest {
|
|
||||||
task_id: string;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePhaseRequest {
|
|
||||||
project_id: string;
|
|
||||||
name: string;
|
|
||||||
color_code?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTaskRequest {
|
|
||||||
project_id: string;
|
|
||||||
name: string;
|
|
||||||
phase_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
priority_id?: string;
|
|
||||||
status_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePhaseRequest {
|
|
||||||
phase_id: string;
|
|
||||||
project_id: string;
|
|
||||||
name?: string;
|
|
||||||
color_code?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ganttApi = createApi({
|
|
||||||
reducerPath: 'ganttApi',
|
|
||||||
baseQuery: fetchBaseQuery({
|
|
||||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
|
||||||
prepareHeaders: async headers => {
|
|
||||||
// Get CSRF token, refresh if needed
|
|
||||||
let token = getCsrfToken();
|
|
||||||
if (!token) {
|
|
||||||
token = await refreshCsrfToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers.set('X-CSRF-Token', token);
|
|
||||||
}
|
|
||||||
headers.set('Content-Type', 'application/json');
|
|
||||||
return headers;
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
}),
|
|
||||||
tagTypes: ['GanttTasks', 'GanttPhases'],
|
|
||||||
endpoints: builder => ({
|
|
||||||
getRoadmapTasks: builder.query<IServerResponse<RoadmapTasksResponse[]>, { projectId: string }>({
|
|
||||||
query: ({ projectId }) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
project_id: projectId,
|
|
||||||
});
|
|
||||||
return `${rootUrl}/roadmap-tasks?${params.toString()}`;
|
|
||||||
},
|
|
||||||
providesTags: (result, error, { projectId }) => [
|
|
||||||
{ type: 'GanttTasks', id: projectId },
|
|
||||||
{ type: 'GanttTasks', id: 'LIST' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
getProjectPhases: builder.query<IServerResponse<ProjectPhaseResponse[]>, { projectId: string }>(
|
|
||||||
{
|
|
||||||
query: ({ projectId }) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
project_id: projectId,
|
|
||||||
});
|
|
||||||
return `${rootUrl}/project-phases?${params.toString()}`;
|
|
||||||
},
|
|
||||||
providesTags: (result, error, { projectId }) => [
|
|
||||||
{ type: 'GanttPhases', id: projectId },
|
|
||||||
{ type: 'GanttPhases', id: 'LIST' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
updateTaskDates: builder.mutation<IServerResponse<any>, UpdateTaskDatesRequest>({
|
|
||||||
query: body => ({
|
|
||||||
url: `${rootUrl}/update-task-dates`,
|
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
invalidatesTags: (result, error, { task_id }) => [{ type: 'GanttTasks', id: 'LIST' }],
|
|
||||||
}),
|
|
||||||
|
|
||||||
createPhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, CreatePhaseRequest>({
|
|
||||||
query: body => ({
|
|
||||||
url: `${rootUrl}/create-phase`,
|
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
invalidatesTags: (result, error, { project_id }) => [
|
|
||||||
{ type: 'GanttPhases', id: project_id },
|
|
||||||
{ type: 'GanttPhases', id: 'LIST' },
|
|
||||||
{ type: 'GanttTasks', id: project_id },
|
|
||||||
{ type: 'GanttTasks', id: 'LIST' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
createTask: builder.mutation<IServerResponse<RoadmapTasksResponse>, CreateTaskRequest>({
|
|
||||||
query: body => ({
|
|
||||||
url: `${rootUrl}/create-task`,
|
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
invalidatesTags: (result, error, { project_id }) => [
|
|
||||||
{ type: 'GanttTasks', id: project_id },
|
|
||||||
{ type: 'GanttTasks', id: 'LIST' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
updatePhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, UpdatePhaseRequest>({
|
|
||||||
query: body => ({
|
|
||||||
url: `${rootUrl}/update-phase`,
|
|
||||||
method: 'PUT',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
invalidatesTags: (result, error, { project_id }) => [
|
|
||||||
{ type: 'GanttPhases', id: project_id },
|
|
||||||
{ type: 'GanttPhases', id: 'LIST' },
|
|
||||||
{ type: 'GanttTasks', id: project_id },
|
|
||||||
{ type: 'GanttTasks', id: 'LIST' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const {
|
|
||||||
useGetRoadmapTasksQuery,
|
|
||||||
useGetProjectPhasesQuery,
|
|
||||||
useUpdateTaskDatesMutation,
|
|
||||||
useCreatePhaseMutation,
|
|
||||||
useCreateTaskMutation,
|
|
||||||
useUpdatePhaseMutation,
|
|
||||||
} = ganttApi;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform API response to Gantt task format with phases as milestones
|
|
||||||
*/
|
|
||||||
export const transformToGanttTasks = (
|
|
||||||
apiTasks: RoadmapTasksResponse[],
|
|
||||||
apiPhases: ProjectPhaseResponse[]
|
|
||||||
): GanttTask[] => {
|
|
||||||
// Group tasks by phase
|
|
||||||
const tasksByPhase = new Map<string, RoadmapTasksResponse[]>();
|
|
||||||
const unassignedTasks: RoadmapTasksResponse[] = [];
|
|
||||||
|
|
||||||
apiTasks.forEach(task => {
|
|
||||||
// Tasks now have phases array instead of direct phase_id
|
|
||||||
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
|
|
||||||
|
|
||||||
if (taskPhaseId) {
|
|
||||||
if (!tasksByPhase.has(taskPhaseId)) {
|
|
||||||
tasksByPhase.set(taskPhaseId, []);
|
|
||||||
}
|
|
||||||
tasksByPhase.get(taskPhaseId)!.push(task);
|
|
||||||
} else {
|
|
||||||
unassignedTasks.push(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: GanttTask[] = [];
|
|
||||||
|
|
||||||
// Create phase milestones with their tasks (sorted by phase order)
|
|
||||||
[...apiPhases]
|
|
||||||
.sort((a, b) => a.sort_index - b.sort_index)
|
|
||||||
.forEach(phase => {
|
|
||||||
const phaseTasks = tasksByPhase.get(phase.id) || [];
|
|
||||||
|
|
||||||
// Create phase milestone
|
|
||||||
const phaseMilestone: GanttTask = {
|
|
||||||
id: `phase-${phase.id}`,
|
|
||||||
name: phase.name,
|
|
||||||
start_date: phase.start_date ? new Date(phase.start_date) : null,
|
|
||||||
end_date: phase.end_date ? new Date(phase.end_date) : null,
|
|
||||||
progress: 0,
|
|
||||||
level: 0,
|
|
||||||
expanded: true,
|
|
||||||
color: phase.color_code,
|
|
||||||
type: 'milestone',
|
|
||||||
is_milestone: true,
|
|
||||||
phase_id: phase.id,
|
|
||||||
// Pass through phase progress data from backend
|
|
||||||
todo_progress: phase.todo_progress,
|
|
||||||
doing_progress: phase.doing_progress,
|
|
||||||
done_progress: phase.done_progress,
|
|
||||||
total_tasks: phase.total_tasks,
|
|
||||||
children: phaseTasks.map(task => transformTask(task, 1)),
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push(phaseMilestone);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always create unmapped phase at the bottom (even if empty)
|
|
||||||
const unmappedPhase: GanttTask = {
|
|
||||||
id: 'phase-unmapped',
|
|
||||||
name: 'Unmapped',
|
|
||||||
start_date: null,
|
|
||||||
end_date: null,
|
|
||||||
progress: 0,
|
|
||||||
level: 0,
|
|
||||||
expanded: true,
|
|
||||||
color: '#9CA3AF', // Gray color for unmapped phase
|
|
||||||
type: 'milestone',
|
|
||||||
is_milestone: true,
|
|
||||||
phase_id: null,
|
|
||||||
children: unassignedTasks.map(task => transformTask(task, 1)),
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push(unmappedPhase);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to transform individual task
|
|
||||||
*/
|
|
||||||
const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => {
|
|
||||||
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: task.id,
|
|
||||||
name: task.name,
|
|
||||||
start_date: task.start_date ? new Date(task.start_date) : null,
|
|
||||||
end_date: task.end_date ? new Date(task.end_date) : null,
|
|
||||||
progress: task.progress,
|
|
||||||
dependencies: task.dependencies.map(dep => dep.related_task_id),
|
|
||||||
dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by',
|
|
||||||
parent_id: task.parent_task_id,
|
|
||||||
children: task.subtasks.map(subtask => ({
|
|
||||||
id: subtask.id,
|
|
||||||
name: subtask.name,
|
|
||||||
start_date: subtask.start_date ? new Date(subtask.start_date) : null,
|
|
||||||
end_date: subtask.end_date ? new Date(subtask.end_date) : null,
|
|
||||||
progress: subtask.progress,
|
|
||||||
parent_id: subtask.parent_task_id,
|
|
||||||
level: level + 1,
|
|
||||||
type: 'task',
|
|
||||||
phase_id: subtask.phase_id, // Subtasks might still use direct phase_id
|
|
||||||
})),
|
|
||||||
level,
|
|
||||||
expanded: true,
|
|
||||||
color: task.status_color || task.priority_color,
|
|
||||||
assignees: task.assignees.map(a => a.assignee_name),
|
|
||||||
priority: task.priority_name,
|
|
||||||
status: task.status_name,
|
|
||||||
phase_id: taskPhaseId,
|
|
||||||
is_milestone: false,
|
|
||||||
type: 'task',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform API response to Gantt phases format
|
|
||||||
*/
|
|
||||||
export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): GanttPhase[] => {
|
|
||||||
return apiPhases.map(phase => ({
|
|
||||||
id: phase.id,
|
|
||||||
name: phase.name,
|
|
||||||
color_code: phase.color_code,
|
|
||||||
start_date: phase.start_date ? new Date(phase.start_date) : null,
|
|
||||||
end_date: phase.end_date ? new Date(phase.end_date) : null,
|
|
||||||
sort_index: phase.sort_index,
|
|
||||||
todo_progress: phase.todo_progress,
|
|
||||||
doing_progress: phase.doing_progress,
|
|
||||||
done_progress: phase.done_progress,
|
|
||||||
total_tasks: phase.total_tasks,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
||||||
|
|
||||||
export type DependencyType =
|
|
||||||
| 'blocked_by'
|
|
||||||
| 'finish_to_start'
|
|
||||||
| 'start_to_start'
|
|
||||||
| 'finish_to_finish'
|
|
||||||
| 'start_to_finish';
|
|
||||||
|
|
||||||
export interface GanttTask {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
start_date: Date | null;
|
|
||||||
end_date: Date | null;
|
|
||||||
progress: number;
|
|
||||||
dependencies?: string[];
|
|
||||||
dependencyType?: DependencyType;
|
|
||||||
parent_id?: string;
|
|
||||||
children?: GanttTask[];
|
|
||||||
level?: number;
|
|
||||||
expanded?: boolean;
|
|
||||||
color?: string;
|
|
||||||
assignees?: string[];
|
|
||||||
priority?: string;
|
|
||||||
status?: string;
|
|
||||||
phase_id?: string;
|
|
||||||
is_milestone?: boolean;
|
|
||||||
type?: 'task' | 'milestone' | 'phase' | 'add-task-button';
|
|
||||||
// Add task row specific properties
|
|
||||||
parent_phase_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttPhase {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color_code: string;
|
|
||||||
start_date: Date | null;
|
|
||||||
end_date: Date | null;
|
|
||||||
sort_index: number;
|
|
||||||
tasks?: GanttTask[];
|
|
||||||
expanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttMilestone extends Omit<GanttTask, 'type'> {
|
|
||||||
type: 'milestone';
|
|
||||||
phase_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttDependency {
|
|
||||||
id: string;
|
|
||||||
task_id: string;
|
|
||||||
related_task_id: string;
|
|
||||||
dependency_type: DependencyType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttContextType {
|
|
||||||
tasks: GanttTask[];
|
|
||||||
phases: GanttPhase[];
|
|
||||||
viewMode: GanttViewMode;
|
|
||||||
projectId: string;
|
|
||||||
dateRange: { start: Date; end: Date };
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { GanttViewMode, GanttTask } from '../types/gantt-types';
|
|
||||||
|
|
||||||
export interface TimelinePosition {
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
isValid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimelineBounds {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
totalDays: number;
|
|
||||||
pixelsPerDay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimelineCalculator {
|
|
||||||
private viewMode: GanttViewMode;
|
|
||||||
private columnWidth: number;
|
|
||||||
private timelineBounds: TimelineBounds;
|
|
||||||
|
|
||||||
constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) {
|
|
||||||
this.viewMode = viewMode;
|
|
||||||
this.columnWidth = columnWidth;
|
|
||||||
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate timeline bounds and pixels per day
|
|
||||||
*/
|
|
||||||
private calculateTimelineBounds(startDate: Date, endDate: Date): TimelineBounds {
|
|
||||||
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
||||||
const columnsCount = this.getColumnsCount();
|
|
||||||
const totalWidth = columnsCount * this.columnWidth;
|
|
||||||
const pixelsPerDay = totalWidth / totalDays;
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
totalDays,
|
|
||||||
pixelsPerDay,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get number of columns based on view mode
|
|
||||||
*/
|
|
||||||
private getColumnsCount(): number {
|
|
||||||
switch (this.viewMode) {
|
|
||||||
case 'day':
|
|
||||||
return 30;
|
|
||||||
case 'week':
|
|
||||||
return 12;
|
|
||||||
case 'month':
|
|
||||||
return 12;
|
|
||||||
case 'quarter':
|
|
||||||
return 8;
|
|
||||||
case 'year':
|
|
||||||
return 5;
|
|
||||||
default:
|
|
||||||
return 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate task bar position and width
|
|
||||||
*/
|
|
||||||
calculateTaskPosition(task: GanttTask): TimelinePosition {
|
|
||||||
if (!task.start_date || !task.end_date) {
|
|
||||||
return { left: 0, width: 0, isValid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskStart = new Date(task.start_date);
|
|
||||||
const taskEnd = new Date(task.end_date);
|
|
||||||
|
|
||||||
// Ensure task dates are within timeline bounds
|
|
||||||
const clampedStart = new Date(
|
|
||||||
Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())
|
|
||||||
);
|
|
||||||
const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime()));
|
|
||||||
|
|
||||||
// Calculate days from timeline start
|
|
||||||
const daysFromStart = Math.floor(
|
|
||||||
(clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
);
|
|
||||||
const taskDuration = Math.ceil(
|
|
||||||
(clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate pixel positions
|
|
||||||
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
|
|
||||||
const width = Math.max(taskDuration * this.timelineBounds.pixelsPerDay, 10); // Minimum 10px width
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: Math.max(0, left),
|
|
||||||
width,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate milestone position (single point in time)
|
|
||||||
*/
|
|
||||||
calculateMilestonePosition(date: Date): { left: number; isValid: boolean } {
|
|
||||||
if (!date) {
|
|
||||||
return { left: 0, isValid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestoneDate = new Date(date);
|
|
||||||
|
|
||||||
// Check if milestone is within timeline bounds
|
|
||||||
if (
|
|
||||||
milestoneDate < this.timelineBounds.startDate ||
|
|
||||||
milestoneDate > this.timelineBounds.endDate
|
|
||||||
) {
|
|
||||||
return { left: 0, isValid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const daysFromStart = Math.floor(
|
|
||||||
(milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
);
|
|
||||||
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: Math.max(0, left),
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate dependency line coordinates
|
|
||||||
*/
|
|
||||||
calculateDependencyLine(
|
|
||||||
fromTask: GanttTask,
|
|
||||||
toTask: GanttTask,
|
|
||||||
rowHeight: number = 36
|
|
||||||
): {
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
isValid: boolean;
|
|
||||||
} {
|
|
||||||
const fromPosition = this.calculateTaskPosition(fromTask);
|
|
||||||
const toPosition = this.calculateTaskPosition(toTask);
|
|
||||||
|
|
||||||
if (!fromPosition.isValid || !toPosition.isValid) {
|
|
||||||
return { x1: 0, y1: 0, x2: 0, y2: 0, isValid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assume tasks are in different rows - would need actual row indices in real implementation
|
|
||||||
const fromY = 0; // Would be calculated based on task index * rowHeight
|
|
||||||
const toY = rowHeight; // Would be calculated based on task index * rowHeight
|
|
||||||
|
|
||||||
return {
|
|
||||||
x1: fromPosition.left + fromPosition.width, // End of source task
|
|
||||||
y1: fromY + rowHeight / 2,
|
|
||||||
x2: toPosition.left, // Start of target task
|
|
||||||
y2: toY + rowHeight / 2,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert pixel position back to date
|
|
||||||
*/
|
|
||||||
pixelToDate(pixelPosition: number): Date {
|
|
||||||
const daysFromStart = pixelPosition / this.timelineBounds.pixelsPerDay;
|
|
||||||
const targetDate = new Date(this.timelineBounds.startDate);
|
|
||||||
targetDate.setDate(targetDate.getDate() + daysFromStart);
|
|
||||||
return targetDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today line position
|
|
||||||
*/
|
|
||||||
getTodayLinePosition(): { left: number; isVisible: boolean } {
|
|
||||||
const today = new Date();
|
|
||||||
const position = this.calculateMilestonePosition(today);
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: position.left,
|
|
||||||
isVisible: position.isValid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate weekend/holiday shading areas
|
|
||||||
*/
|
|
||||||
getWeekendAreas(): Array<{ left: number; width: number }> {
|
|
||||||
const weekendAreas: Array<{ left: number; width: number }> = [];
|
|
||||||
const current = new Date(this.timelineBounds.startDate);
|
|
||||||
|
|
||||||
while (current <= this.timelineBounds.endDate) {
|
|
||||||
// Saturday (6) and Sunday (0)
|
|
||||||
if (current.getDay() === 0 || current.getDay() === 6) {
|
|
||||||
const position = this.calculateMilestonePosition(current);
|
|
||||||
if (position.isValid) {
|
|
||||||
weekendAreas.push({
|
|
||||||
left: position.left,
|
|
||||||
width: this.timelineBounds.pixelsPerDay,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current.setDate(current.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return weekendAreas;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get timeline bounds for external use
|
|
||||||
*/
|
|
||||||
getTimelineBounds(): TimelineBounds {
|
|
||||||
return { ...this.timelineBounds };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update calculator with new parameters
|
|
||||||
*/
|
|
||||||
updateParameters(
|
|
||||||
viewMode: GanttViewMode,
|
|
||||||
columnWidth: number,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date
|
|
||||||
): void {
|
|
||||||
this.viewMode = viewMode;
|
|
||||||
this.columnWidth = columnWidth;
|
|
||||||
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility functions for timeline calculations
|
|
||||||
*/
|
|
||||||
export const TimelineUtils = {
|
|
||||||
/**
|
|
||||||
* Get smart timeline date range based on project tasks
|
|
||||||
*/
|
|
||||||
getSmartDateRange(tasks: GanttTask[], viewMode: GanttViewMode): { start: Date; end: Date } {
|
|
||||||
if (!tasks.length) {
|
|
||||||
// Default to current year
|
|
||||||
const start = new Date();
|
|
||||||
start.setMonth(0, 1); // January 1st
|
|
||||||
const end = new Date();
|
|
||||||
end.setMonth(11, 31); // December 31st
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find earliest start date and latest end date
|
|
||||||
let earliestStart: Date | null = null;
|
|
||||||
let latestEnd: Date | null = null;
|
|
||||||
|
|
||||||
tasks.forEach(task => {
|
|
||||||
if (task.start_date) {
|
|
||||||
if (!earliestStart || task.start_date < earliestStart) {
|
|
||||||
earliestStart = task.start_date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (task.end_date) {
|
|
||||||
if (!latestEnd || task.end_date > latestEnd) {
|
|
||||||
latestEnd = task.end_date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subtasks too
|
|
||||||
if (task.children) {
|
|
||||||
task.children.forEach(subtask => {
|
|
||||||
if (subtask.start_date && (!earliestStart || subtask.start_date < earliestStart)) {
|
|
||||||
earliestStart = subtask.start_date;
|
|
||||||
}
|
|
||||||
if (subtask.end_date && (!latestEnd || subtask.end_date > latestEnd)) {
|
|
||||||
latestEnd = subtask.end_date;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add padding based on view mode
|
|
||||||
const start = earliestStart || new Date();
|
|
||||||
const end = latestEnd || new Date();
|
|
||||||
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
start.setDate(start.getDate() - 7); // 1 week before
|
|
||||||
end.setDate(end.getDate() + 7); // 1 week after
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
start.setDate(start.getDate() - 14); // 2 weeks before
|
|
||||||
end.setDate(end.getDate() + 14); // 2 weeks after
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
start.setMonth(start.getMonth() - 1); // 1 month before
|
|
||||||
end.setMonth(end.getMonth() + 1); // 1 month after
|
|
||||||
break;
|
|
||||||
case 'quarter':
|
|
||||||
start.setMonth(start.getMonth() - 3); // 1 quarter before
|
|
||||||
end.setMonth(end.getMonth() + 3); // 1 quarter after
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
start.setFullYear(start.getFullYear() - 1); // 1 year before
|
|
||||||
end.setFullYear(end.getFullYear() + 1); // 1 year after
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date based on view mode
|
|
||||||
*/
|
|
||||||
formatDateForViewMode(date: Date, viewMode: GanttViewMode): string {
|
|
||||||
switch (viewMode) {
|
|
||||||
case 'day':
|
|
||||||
return date.toLocaleDateString('en-US', { day: '2-digit', month: 'short' });
|
|
||||||
case 'week':
|
|
||||||
return `Week ${this.getWeekNumber(date)}`;
|
|
||||||
case 'month':
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short' });
|
|
||||||
case 'quarter':
|
|
||||||
return `Q${Math.ceil((date.getMonth() + 1) / 3)} ${date.getFullYear()}`;
|
|
||||||
case 'year':
|
|
||||||
return date.getFullYear().toString();
|
|
||||||
default:
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get week number of the year
|
|
||||||
*/
|
|
||||||
getWeekNumber(date: Date): number {
|
|
||||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
||||||
const dayNum = d.getUTCDay() || 7;
|
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
||||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
||||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -188,16 +188,16 @@ export interface AdvancedGanttProps {
|
|||||||
// Data
|
// Data
|
||||||
tasks: GanttTask[];
|
tasks: GanttTask[];
|
||||||
columns?: ColumnConfig[];
|
columns?: ColumnConfig[];
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
timelineConfig?: Partial<TimelineConfig>;
|
timelineConfig?: Partial<TimelineConfig>;
|
||||||
virtualScrollConfig?: Partial<VirtualScrollConfig>;
|
virtualScrollConfig?: Partial<VirtualScrollConfig>;
|
||||||
zoomLevels?: ZoomLevel[];
|
zoomLevels?: ZoomLevel[];
|
||||||
|
|
||||||
// Initial State
|
// Initial State
|
||||||
initialViewState?: Partial<GanttViewState>;
|
initialViewState?: Partial<GanttViewState>;
|
||||||
initialSelection?: string[];
|
initialSelection?: string[];
|
||||||
|
|
||||||
// Event Handlers
|
// Event Handlers
|
||||||
onTaskUpdate?: (taskId: string, updates: Partial<GanttTask>) => void;
|
onTaskUpdate?: (taskId: string, updates: Partial<GanttTask>) => void;
|
||||||
onTaskCreate?: (task: Omit<GanttTask, 'id'>) => void;
|
onTaskCreate?: (task: Omit<GanttTask, 'id'>) => void;
|
||||||
@@ -209,13 +209,13 @@ export interface AdvancedGanttProps {
|
|||||||
onColumnResize?: ColumnResizeHandler;
|
onColumnResize?: ColumnResizeHandler;
|
||||||
onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void;
|
onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void;
|
||||||
onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void;
|
onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void;
|
||||||
|
|
||||||
// UI Customization
|
// UI Customization
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
theme?: 'light' | 'dark' | 'auto';
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
enableDragDrop?: boolean;
|
enableDragDrop?: boolean;
|
||||||
enableResize?: boolean;
|
enableResize?: boolean;
|
||||||
@@ -225,7 +225,7 @@ export interface AdvancedGanttProps {
|
|||||||
enableTooltips?: boolean;
|
enableTooltips?: boolean;
|
||||||
enableExport?: boolean;
|
enableExport?: boolean;
|
||||||
enablePrint?: boolean;
|
enablePrint?: boolean;
|
||||||
|
|
||||||
// Performance Options
|
// Performance Options
|
||||||
enableVirtualScrolling?: boolean;
|
enableVirtualScrolling?: boolean;
|
||||||
enableDebouncing?: boolean;
|
enableDebouncing?: boolean;
|
||||||
@@ -258,14 +258,7 @@ export interface ExportOptions {
|
|||||||
// Filter and Search
|
// Filter and Search
|
||||||
export interface FilterConfig {
|
export interface FilterConfig {
|
||||||
field: string;
|
field: string;
|
||||||
operator:
|
operator: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between';
|
||||||
| 'equals'
|
|
||||||
| 'contains'
|
|
||||||
| 'startsWith'
|
|
||||||
| 'endsWith'
|
|
||||||
| 'greaterThan'
|
|
||||||
| 'lessThan'
|
|
||||||
| 'between';
|
|
||||||
value: any;
|
value: any;
|
||||||
logic?: 'and' | 'or';
|
logic?: 'and' | 'or';
|
||||||
}
|
}
|
||||||
@@ -311,4 +304,4 @@ export interface KeyboardShortcut {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
handler: (event: KeyboardEvent) => void;
|
handler: (event: KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
@@ -2,37 +2,37 @@ import { useMemo, useCallback, useRef, useEffect } from 'react';
|
|||||||
import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types';
|
import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types';
|
||||||
|
|
||||||
// Debounce utility for drag operations
|
// Debounce utility for drag operations
|
||||||
export function useDebounce<T extends (...args: any[]) => any>(callback: T, delay: number): T {
|
export function useDebounce<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number
|
||||||
|
): T {
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
return useCallback(
|
return useCallback((...args: Parameters<T>) => {
|
||||||
(...args: Parameters<T>) => {
|
if (timeoutRef.current) {
|
||||||
if (timeoutRef.current) {
|
clearTimeout(timeoutRef.current);
|
||||||
clearTimeout(timeoutRef.current);
|
}
|
||||||
}
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
timeoutRef.current = setTimeout(() => {
|
callback(...args);
|
||||||
callback(...args);
|
}, delay);
|
||||||
}, delay);
|
}, [callback, delay]) as T;
|
||||||
},
|
|
||||||
[callback, delay]
|
|
||||||
) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle utility for scroll events
|
// Throttle utility for scroll events
|
||||||
export function useThrottle<T extends (...args: any[]) => any>(callback: T, delay: number): T {
|
export function useThrottle<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number
|
||||||
|
): T {
|
||||||
const lastCall = useRef<number>(0);
|
const lastCall = useRef<number>(0);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback((...args: Parameters<T>) => {
|
||||||
(...args: Parameters<T>) => {
|
const now = Date.now();
|
||||||
const now = Date.now();
|
if (now - lastCall.current >= delay) {
|
||||||
if (now - lastCall.current >= delay) {
|
lastCall.current = now;
|
||||||
lastCall.current = now;
|
callback(...args);
|
||||||
callback(...args);
|
}
|
||||||
}
|
}, [callback, delay]) as T;
|
||||||
},
|
|
||||||
[callback, delay]
|
|
||||||
) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized task calculations
|
// Memoized task calculations
|
||||||
@@ -41,23 +41,23 @@ export const useTaskCalculations = (tasks: GanttTask[]) => {
|
|||||||
const taskMap = new Map<string, GanttTask>();
|
const taskMap = new Map<string, GanttTask>();
|
||||||
const parentChildMap = new Map<string, string[]>();
|
const parentChildMap = new Map<string, string[]>();
|
||||||
const dependencyMap = new Map<string, string[]>();
|
const dependencyMap = new Map<string, string[]>();
|
||||||
|
|
||||||
// Build maps for efficient lookups
|
// Build maps for efficient lookups
|
||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
taskMap.set(task.id, task);
|
taskMap.set(task.id, task);
|
||||||
|
|
||||||
if (task.parent) {
|
if (task.parent) {
|
||||||
if (!parentChildMap.has(task.parent)) {
|
if (!parentChildMap.has(task.parent)) {
|
||||||
parentChildMap.set(task.parent, []);
|
parentChildMap.set(task.parent, []);
|
||||||
}
|
}
|
||||||
parentChildMap.get(task.parent)!.push(task.id);
|
parentChildMap.get(task.parent)!.push(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.dependencies) {
|
if (task.dependencies) {
|
||||||
dependencyMap.set(task.id, task.dependencies);
|
dependencyMap.set(task.id, task.dependencies);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskMap,
|
taskMap,
|
||||||
parentChildMap,
|
parentChildMap,
|
||||||
@@ -95,7 +95,7 @@ export const useVirtualScrolling = (
|
|||||||
);
|
);
|
||||||
const visibleItems = tasks.slice(startIndex, endIndex + 1);
|
const visibleItems = tasks.slice(startIndex, endIndex + 1);
|
||||||
const offsetY = startIndex * itemHeight;
|
const offsetY = startIndex * itemHeight;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startIndex,
|
startIndex,
|
||||||
endIndex,
|
endIndex,
|
||||||
@@ -124,31 +124,29 @@ export const useTimelineVirtualScrolling = (
|
|||||||
overscan: number = 10
|
overscan: number = 10
|
||||||
): TimelineVirtualData => {
|
): TimelineVirtualData => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const totalDays = Math.ceil(
|
const totalDays = Math.ceil((projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
(projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
);
|
|
||||||
const totalWidth = totalDays * dayWidth;
|
const totalWidth = totalDays * dayWidth;
|
||||||
|
|
||||||
const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan);
|
const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan);
|
||||||
const endDayIndex = Math.min(
|
const endDayIndex = Math.min(
|
||||||
totalDays - 1,
|
totalDays - 1,
|
||||||
Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan
|
Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleDays: Date[] = [];
|
const visibleDays: Date[] = [];
|
||||||
for (let i = startDayIndex; i <= endDayIndex; i++) {
|
for (let i = startDayIndex; i <= endDayIndex; i++) {
|
||||||
const date = new Date(projectStartDate);
|
const date = new Date(projectStartDate);
|
||||||
date.setDate(date.getDate() + i);
|
date.setDate(date.getDate() + i);
|
||||||
visibleDays.push(date);
|
visibleDays.push(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetX = startDayIndex * dayWidth;
|
const offsetX = startDayIndex * dayWidth;
|
||||||
const startDate = new Date(projectStartDate);
|
const startDate = new Date(projectStartDate);
|
||||||
startDate.setDate(startDate.getDate() + startDayIndex);
|
startDate.setDate(startDate.getDate() + startDayIndex);
|
||||||
|
|
||||||
const endDate = new Date(projectStartDate);
|
const endDate = new Date(projectStartDate);
|
||||||
endDate.setDate(endDate.getDate() + endDayIndex);
|
endDate.setDate(endDate.getDate() + endDayIndex);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -171,25 +169,25 @@ export const usePerformanceMonitoring = (): {
|
|||||||
taskCount: 0,
|
taskCount: 0,
|
||||||
visibleTaskCount: 0,
|
visibleTaskCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const measurementsRef = useRef<Map<string, number>>(new Map());
|
const measurementsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
const startMeasure = useCallback((name: string) => {
|
const startMeasure = useCallback((name: string) => {
|
||||||
measurementsRef.current.set(name, performance.now());
|
measurementsRef.current.set(name, performance.now());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const endMeasure = useCallback((name: string) => {
|
const endMeasure = useCallback((name: string) => {
|
||||||
const startTime = measurementsRef.current.get(name);
|
const startTime = measurementsRef.current.get(name);
|
||||||
if (startTime) {
|
if (startTime) {
|
||||||
const duration = performance.now() - startTime;
|
const duration = performance.now() - startTime;
|
||||||
measurementsRef.current.delete(name);
|
measurementsRef.current.delete(name);
|
||||||
|
|
||||||
if (name === 'render') {
|
if (name === 'render') {
|
||||||
metricsRef.current.renderTime = duration;
|
metricsRef.current.renderTime = duration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const recordMetric = useCallback((name: string, value: number) => {
|
const recordMetric = useCallback((name: string, value: number) => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'taskCount':
|
case 'taskCount':
|
||||||
@@ -206,7 +204,7 @@ export const usePerformanceMonitoring = (): {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metrics: metricsRef.current,
|
metrics: metricsRef.current,
|
||||||
startMeasure,
|
startMeasure,
|
||||||
@@ -222,26 +220,26 @@ export const useIntersectionObserver = (
|
|||||||
) => {
|
) => {
|
||||||
const targetRef = useRef<HTMLElement>(null);
|
const targetRef = useRef<HTMLElement>(null);
|
||||||
const observerRef = useRef<IntersectionObserver>();
|
const observerRef = useRef<IntersectionObserver>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetRef.current) return;
|
if (!targetRef.current) return;
|
||||||
|
|
||||||
observerRef.current = new IntersectionObserver(callback, {
|
observerRef.current = new IntersectionObserver(callback, {
|
||||||
root: null,
|
root: null,
|
||||||
rootMargin: '100px',
|
rootMargin: '100px',
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
observerRef.current.observe(targetRef.current);
|
observerRef.current.observe(targetRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (observerRef.current) {
|
if (observerRef.current) {
|
||||||
observerRef.current.disconnect();
|
observerRef.current.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [callback, options]);
|
}, [callback, options]);
|
||||||
|
|
||||||
return targetRef;
|
return targetRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,47 +247,47 @@ export const useIntersectionObserver = (
|
|||||||
export const useDateCalculations = () => {
|
export const useDateCalculations = () => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const cache = new Map<string, number>();
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
const getDaysBetween = (start: Date, end: Date): number => {
|
const getDaysBetween = (start: Date, end: Date): number => {
|
||||||
const key = `${start.getTime()}-${end.getTime()}`;
|
const key = `${start.getTime()}-${end.getTime()}`;
|
||||||
if (cache.has(key)) {
|
if (cache.has(key)) {
|
||||||
return cache.get(key)!;
|
return cache.get(key)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
cache.set(key, days);
|
cache.set(key, days);
|
||||||
return days;
|
return days;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDays = (date: Date, days: number): Date => {
|
const addDays = (date: Date, days: number): Date => {
|
||||||
const result = new Date(date);
|
const result = new Date(date);
|
||||||
result.setDate(result.getDate() + days);
|
result.setDate(result.getDate() + days);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWeekend = (date: Date): boolean => {
|
const isWeekend = (date: Date): boolean => {
|
||||||
const day = date.getDay();
|
const day = date.getDay();
|
||||||
return day === 0 || day === 6; // Sunday or Saturday
|
return day === 0 || day === 6; // Sunday or Saturday
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWorkingDay = (date: Date, workingDays: number[]): boolean => {
|
const isWorkingDay = (date: Date, workingDays: number[]): boolean => {
|
||||||
return workingDays.includes(date.getDay());
|
return workingDays.includes(date.getDay());
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => {
|
const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const current = new Date(start);
|
const current = new Date(start);
|
||||||
|
|
||||||
while (current <= end) {
|
while (current <= end) {
|
||||||
if (isWorkingDay(current, workingDays)) {
|
if (isWorkingDay(current, workingDays)) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
current.setDate(current.getDate() + 1);
|
current.setDate(current.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDaysBetween,
|
getDaysBetween,
|
||||||
addDays,
|
addDays,
|
||||||
@@ -302,25 +300,25 @@ export const useDateCalculations = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Task position calculations
|
// Task position calculations
|
||||||
export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWidth: number) => {
|
export const useTaskPositions = (
|
||||||
|
tasks: GanttTask[],
|
||||||
|
timelineStart: Date,
|
||||||
|
dayWidth: number
|
||||||
|
) => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const positions = new Map<string, { x: number; width: number; y: number }>();
|
const positions = new Map<string, { x: number; width: number; y: number }>();
|
||||||
|
|
||||||
tasks.forEach((task, index) => {
|
tasks.forEach((task, index) => {
|
||||||
const startDays = Math.floor(
|
const startDays = Math.floor((task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
(task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
|
const endDays = Math.floor((task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
);
|
|
||||||
const endDays = Math.floor(
|
|
||||||
(task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
|
|
||||||
);
|
|
||||||
|
|
||||||
positions.set(task.id, {
|
positions.set(task.id, {
|
||||||
x: startDays * dayWidth,
|
x: startDays * dayWidth,
|
||||||
width: Math.max(1, (endDays - startDays) * dayWidth),
|
width: Math.max(1, (endDays - startDays) * dayWidth),
|
||||||
y: index * 40, // Assuming 40px row height
|
y: index * 40, // Assuming 40px row height
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return positions;
|
return positions;
|
||||||
}, [tasks, timelineStart, dayWidth]);
|
}, [tasks, timelineStart, dayWidth]);
|
||||||
};
|
};
|
||||||
@@ -328,57 +326,57 @@ export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWid
|
|||||||
// Memory management utilities
|
// Memory management utilities
|
||||||
export const useMemoryManagement = () => {
|
export const useMemoryManagement = () => {
|
||||||
const cleanupFunctions = useRef<Array<() => void>>([]);
|
const cleanupFunctions = useRef<Array<() => void>>([]);
|
||||||
|
|
||||||
const addCleanup = useCallback((cleanup: () => void) => {
|
const addCleanup = useCallback((cleanup: () => void) => {
|
||||||
cleanupFunctions.current.push(cleanup);
|
cleanupFunctions.current.push(cleanup);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runCleanup = useCallback(() => {
|
const runCleanup = useCallback(() => {
|
||||||
cleanupFunctions.current.forEach(cleanup => cleanup());
|
cleanupFunctions.current.forEach(cleanup => cleanup());
|
||||||
cleanupFunctions.current = [];
|
cleanupFunctions.current = [];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return runCleanup;
|
return runCleanup;
|
||||||
}, [runCleanup]);
|
}, [runCleanup]);
|
||||||
|
|
||||||
return { addCleanup, runCleanup };
|
return { addCleanup, runCleanup };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Batch update utility for multiple task changes
|
// Batch update utility for multiple task changes
|
||||||
export const useBatchUpdates = <T>(updateFunction: (updates: T[]) => void, delay: number = 100) => {
|
export const useBatchUpdates = <T>(
|
||||||
|
updateFunction: (updates: T[]) => void,
|
||||||
|
delay: number = 100
|
||||||
|
) => {
|
||||||
const batchRef = useRef<T[]>([]);
|
const batchRef = useRef<T[]>([]);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
const addUpdate = useCallback(
|
const addUpdate = useCallback((update: T) => {
|
||||||
(update: T) => {
|
batchRef.current.push(update);
|
||||||
batchRef.current.push(update);
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
if (timeoutRef.current) {
|
clearTimeout(timeoutRef.current);
|
||||||
clearTimeout(timeoutRef.current);
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (batchRef.current.length > 0) {
|
||||||
|
updateFunction([...batchRef.current]);
|
||||||
|
batchRef.current = [];
|
||||||
}
|
}
|
||||||
|
}, delay);
|
||||||
timeoutRef.current = setTimeout(() => {
|
}, [updateFunction, delay]);
|
||||||
if (batchRef.current.length > 0) {
|
|
||||||
updateFunction([...batchRef.current]);
|
|
||||||
batchRef.current = [];
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
},
|
|
||||||
[updateFunction, delay]
|
|
||||||
);
|
|
||||||
|
|
||||||
const flushUpdates = useCallback(() => {
|
const flushUpdates = useCallback(() => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batchRef.current.length > 0) {
|
if (batchRef.current.length > 0) {
|
||||||
updateFunction([...batchRef.current]);
|
updateFunction([...batchRef.current]);
|
||||||
batchRef.current = [];
|
batchRef.current = [];
|
||||||
}
|
}
|
||||||
}, [updateFunction]);
|
}, [updateFunction]);
|
||||||
|
|
||||||
return { addUpdate, flushUpdates };
|
return { addUpdate, flushUpdates };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,24 +385,24 @@ export const useFPSMonitoring = () => {
|
|||||||
const fpsRef = useRef<number>(0);
|
const fpsRef = useRef<number>(0);
|
||||||
const frameCountRef = useRef<number>(0);
|
const frameCountRef = useRef<number>(0);
|
||||||
const lastTimeRef = useRef<number>(performance.now());
|
const lastTimeRef = useRef<number>(performance.now());
|
||||||
|
|
||||||
const measureFPS = useCallback(() => {
|
const measureFPS = useCallback(() => {
|
||||||
frameCountRef.current++;
|
frameCountRef.current++;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
|
|
||||||
if (now - lastTimeRef.current >= 1000) {
|
if (now - lastTimeRef.current >= 1000) {
|
||||||
fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current));
|
fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current));
|
||||||
frameCountRef.current = 0;
|
frameCountRef.current = 0;
|
||||||
lastTimeRef.current = now;
|
lastTimeRef.current = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(measureFPS);
|
requestAnimationFrame(measureFPS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rafId = requestAnimationFrame(measureFPS);
|
const rafId = requestAnimationFrame(measureFPS);
|
||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [measureFPS]);
|
}, [measureFPS]);
|
||||||
|
|
||||||
return fpsRef.current;
|
return fpsRef.current;
|
||||||
};
|
};
|
||||||
@@ -198,17 +198,6 @@ export class ServiceWorkerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for updates
|
|
||||||
async checkForUpdates(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await this.sendMessage('CHECK_FOR_UPDATES');
|
|
||||||
return response.hasUpdates;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force update service worker
|
// Force update service worker
|
||||||
async forceUpdate(): Promise<void> {
|
async forceUpdate(): Promise<void> {
|
||||||
if (!this.registration) return;
|
if (!this.registration) return;
|
||||||
@@ -223,27 +212,6 @@ export class ServiceWorkerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform hard reload (clear cache and reload)
|
|
||||||
async hardReload(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Clear all caches first
|
|
||||||
await this.clearCache();
|
|
||||||
|
|
||||||
// Force update the service worker
|
|
||||||
if (this.registration) {
|
|
||||||
await this.registration.update();
|
|
||||||
await this.sendMessage('SKIP_WAITING');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform hard reload by clearing browser cache
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to perform hard reload:', error);
|
|
||||||
// Fallback to regular reload
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if app is running offline
|
// Check if app is running offline
|
||||||
isOffline(): boolean {
|
isOffline(): boolean {
|
||||||
return !navigator.onLine;
|
return !navigator.onLine;
|
||||||
@@ -300,8 +268,6 @@ export function useServiceWorker() {
|
|||||||
swManager,
|
swManager,
|
||||||
clearCache: () => swManager?.clearCache(),
|
clearCache: () => swManager?.clearCache(),
|
||||||
forceUpdate: () => swManager?.forceUpdate(),
|
forceUpdate: () => swManager?.forceUpdate(),
|
||||||
hardReload: () => swManager?.hardReload(),
|
|
||||||
checkForUpdates: () => swManager?.checkForUpdates(),
|
|
||||||
getVersion: () => swManager?.getVersion(),
|
getVersion: () => swManager?.getVersion(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
export interface SpamDetectionResult {
|
||||||
|
isSpam: boolean;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpamDetector {
|
||||||
|
private static readonly SPAM_PATTERNS = [
|
||||||
|
// URLs and links
|
||||||
|
/https?:\/\//i,
|
||||||
|
/www\./i,
|
||||||
|
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf)\b/i,
|
||||||
|
|
||||||
|
// Common spam phrases
|
||||||
|
/click\s*(here|link|now)/i,
|
||||||
|
/urgent|emergency|immediate/i,
|
||||||
|
/win|won|winner|prize|reward/i,
|
||||||
|
/free|bonus|gift|offer/i,
|
||||||
|
/check\s*(out|this|pay)/i,
|
||||||
|
/blockchain|crypto|bitcoin|compensation/i,
|
||||||
|
/cash|money|dollars?|\$\d+/i,
|
||||||
|
|
||||||
|
// Excessive special characters
|
||||||
|
/[!]{3,}/,
|
||||||
|
/[🔔⬅👆💰$]{2,}/,
|
||||||
|
/\b[A-Z]{5,}\b/,
|
||||||
|
|
||||||
|
// Suspicious formatting
|
||||||
|
/\s{3,}/,
|
||||||
|
/[.]{3,}/
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly SUSPICIOUS_WORDS = [
|
||||||
|
'urgent', 'emergency', 'click', 'link', 'win', 'winner', 'prize',
|
||||||
|
'free', 'bonus', 'cash', 'money', 'blockchain', 'crypto', 'compensation',
|
||||||
|
'check', 'pay', 'reward', 'offer', 'gift'
|
||||||
|
];
|
||||||
|
|
||||||
|
public static detectSpam(text: string): SpamDetectionResult {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return { isSpam: false, score: 0, reasons: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = text.toLowerCase().trim();
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Check for URL patterns
|
||||||
|
for (const pattern of this.SPAM_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
score += 30;
|
||||||
|
if (pattern.toString().includes('https?') || pattern.toString().includes('www')) {
|
||||||
|
reasons.push('Contains suspicious URLs or links');
|
||||||
|
} else if (pattern.toString().includes('urgent|emergency')) {
|
||||||
|
reasons.push('Contains urgent/emergency language');
|
||||||
|
} else if (pattern.toString().includes('win|won|winner')) {
|
||||||
|
reasons.push('Contains prize/winning language');
|
||||||
|
} else if (pattern.toString().includes('cash|money')) {
|
||||||
|
reasons.push('Contains monetary references');
|
||||||
|
} else if (pattern.toString().includes('blockchain|crypto')) {
|
||||||
|
reasons.push('Contains cryptocurrency references');
|
||||||
|
} else if (pattern.toString().includes('[!]{3,}')) {
|
||||||
|
reasons.push('Excessive use of exclamation marks');
|
||||||
|
} else if (pattern.toString().includes('[🔔⬅👆💰$]')) {
|
||||||
|
reasons.push('Contains suspicious emojis or symbols');
|
||||||
|
} else if (pattern.toString().includes('[A-Z]{5,}')) {
|
||||||
|
reasons.push('Contains excessive capital letters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for excessive suspicious words
|
||||||
|
const suspiciousWordCount = this.SUSPICIOUS_WORDS.filter(word =>
|
||||||
|
normalizedText.includes(word)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (suspiciousWordCount >= 2) {
|
||||||
|
score += suspiciousWordCount * 15;
|
||||||
|
reasons.push(`Contains ${suspiciousWordCount} suspicious words`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check text length - very short or very long names are suspicious
|
||||||
|
if (text.length < 2) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push('Text too short');
|
||||||
|
} else if (text.length > 100) {
|
||||||
|
score += 25;
|
||||||
|
reasons.push('Text unusually long');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for repeated characters
|
||||||
|
if (/(.)\1{4,}/.test(text)) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push('Contains repeated characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mixed scripts (potential homograph attack)
|
||||||
|
const hasLatin = /[a-zA-Z]/.test(text);
|
||||||
|
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||||
|
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||||
|
|
||||||
|
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||||
|
score += 40;
|
||||||
|
reasons.push('Contains mixed character scripts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSpam = score >= 50;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSpam,
|
||||||
|
score,
|
||||||
|
reasons: [...new Set(reasons)] // Remove duplicates
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isHighRiskContent(text: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/gclnk\.com/i,
|
||||||
|
/bit\.ly/i,
|
||||||
|
/tinyurl/i,
|
||||||
|
/\$\d{3,}/,
|
||||||
|
/blockchain.*compensation/i,
|
||||||
|
/urgent.*check/i
|
||||||
|
];
|
||||||
|
|
||||||
|
return patterns.some(pattern => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static sanitizeText(text: string): string {
|
||||||
|
if (!text || typeof text !== 'string') return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.replace(/https?:\/\/[^\s]+/gi, '[URL_REMOVED]')
|
||||||
|
.replace(/www\.[^\s]+/gi, '[URL_REMOVED]')
|
||||||
|
.replace(/[🔔⬅👆💰$]{2,}/g, '')
|
||||||
|
.replace(/[!]{3,}/g, '!')
|
||||||
|
.replace(/\s{3,}/g, ' ')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,6 @@ module.exports = {
|
|||||||
'"Noto Color Emoji"',
|
'"Noto Color Emoji"',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
colors: {
|
|
||||||
gray: {
|
|
||||||
750: '#2d3748',
|
|
||||||
850: '#1a202c',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user