This commit is contained in:
fahed
2026-02-23 11:57:32 +03:00
parent 4522edeea8
commit 8436c49142
50 changed files with 6447 additions and 55 deletions

79
CLAUDE.md Normal file
View File

@@ -0,0 +1,79 @@
# Marketing App - Project Context
## Tech Stack
- **Frontend:** React (Vite), Tailwind CSS, Lucide icons
- **Backend:** Express.js, NocoDB (database via REST API), SQLite (auth)
- **Key patterns:** `api.js` normalizes NocoDB responses (snake_case + camelCase aliases, Id/id/_id). SlidePanel uses createPortal. AppContext provides brands/teamMembers. AuthContext provides user/permissions/canEditResource/canDeleteResource.
## Architecture Notes
- NocoDB uses `Id` (capital I) as primary key, `CreatedAt`/`UpdatedAt` timestamps
- `ensureRequiredTables()` only creates NEW tables — skips existing ones
- `ensureFKColumns()` adds Number columns to existing tables (FK_COLUMNS map)
- `ensureTextColumns()` adds text/other columns to existing tables (TEXT_COLUMNS map)
- Column migrations run on every server restart in `startServer()`
## Current State / Known Issues
### CRITICAL: Approvers on Artefacts — NOT YET WORKING
The `approver_ids` column (SingleLineText, comma-separated user IDs) may not exist in the NocoDB Artefacts table. Symptoms: approvers selected in the UI don't persist when panel is closed and reopened.
**Diagnosis steps added:**
1. Server startup now lists ALL Artefacts table columns and auto-creates missing ones (`approver_ids`, `project_id`, `campaign_id`)
2. `POST /artefacts` logs the approver_ids being sent and what NocoDB returns
3. `PATCH /artefacts/:id` logs the update data and the re-read value
4. `ensureTextColumns()` now logs success/failure for each column creation
**What to check:** Restart the server and look at terminal output:
- If `approver_ids` is listed in columns → column exists, issue is elsewhere
- If `⚠ MISSING column` appears → column was missing, should be auto-created now
- PATCH logs show if the value persists after write (`After re-read: approver_ids=...`)
**Root cause:** `ensureRequiredTables()` (line ~310) skips existing tables entirely (`if (existingTables.has(tableName)) continue`). The `approver_ids` column was added to REQUIRED_TABLES schema but the Artefacts table already existed, so the column was never created. Fixed by adding to TEXT_COLUMNS and adding a belt-and-suspenders check at startup.
### Recently Completed Features
#### Artefacts Page
- Grid + List view toggle with sorting (recently updated, newest, oldest, title A-Z)
- Filters: brand, status, type, creator, project, campaign
- Project and Campaign assignment (dropdowns in create modal + detail panel)
- Approver multi-select (ApproverMultiSelect component at bottom of Artefacts.jsx)
- **Save Draft** button in detail panel header (saves title + description)
- **Delete** button in detail panel header (with permission check via canDeleteResource)
- Editable title (inline input in header) and description (textarea) in detail panel
- Language picker for copy artefacts uses predefined list: Arabic (AR), English (EN), French (FR), Bahasa Indonesia (ID)
- Auto-opens detail panel after creating an artefact
#### Issues Page
- Board (kanban) + List view toggle with drag-and-drop
- 5 status columns: New, Acknowledged, In Progress, Resolved, Declined
- IssueCard component (`client/src/components/IssueCard.jsx`)
- Inline filters (always visible, no toggle button)
- Status count cards work as quick-filter buttons
#### AuthContext
- Added `artefact` type to `canEditResource` and `canDeleteResource` (uses `canEditAnyPost`/`canDeleteAnyPost` permissions + isOwner)
#### Server
- Artefacts schema has: project_id, campaign_id, approver_ids columns
- GET/POST/PATCH /artefacts routes handle project_id, campaign_id, approver_ids with name enrichment
- Issues PATCH supports brand_id (bug fix)
- FK_COLUMNS includes `Artefacts: ['project_id', 'campaign_id']`
- TEXT_COLUMNS includes `Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }]`
#### i18n
- Added 12+ keys to both en.json and ar.json for issues board/list and artefacts grid/list/filters/sorting
## Key Files
- `server/server.js` — all API routes, schema definitions, migrations
- `server/nocodb.js` — NocoDB client wrapper
- `client/src/pages/Artefacts.jsx` — artefacts page + ArtefactDetailPanel + ApproverMultiSelect
- `client/src/pages/Issues.jsx` — issues page with board + list views
- `client/src/components/IssueCard.jsx` — card component for issues kanban
- `client/src/contexts/AuthContext.jsx` — auth, permissions, ownership checks
- `client/src/utils/api.js` — API client with normalize function
- `client/src/i18n/en.json` / `ar.json` — translations
## Debugging Tips
- Console logs prefixed with `[POST /artefacts]` and `[PATCH /artefacts/:id]` show approver data flow
- Startup logs show all Artefacts table columns and missing column auto-creation
- NocoDB silently ignores writes to non-existent columns (no error thrown) — this is why approvers appear to save but don't persist

324
DEPLOYMENT_PLAN.md Normal file
View File

@@ -0,0 +1,324 @@
# Marketing App — Deployment Plan
## Architecture
| Server | Role | What runs |
|--------|------|-----------|
| **Server A** (Hetzner, Ubuntu 24.04) | Frontends + Express backends | Nginx, act_runner, marketing-app, hihala-dashboard, other apps |
| **Server B** (Hetzner, Cloudron) | Data layer + Git | Cloudron, NocoDB, Gitea |
```
Server B (Cloudron) Server A (Ubuntu 24.04)
┌──────────────────────┐ ┌──────────────────────────────┐
│ Gitea (git repos) │◄── register ─│ act_runner (Gitea runner) │
│ NocoDB (databases) │ │ Nginx │
│ │── webhook ──►│ marketing-app (systemd) │
│ │ │ hihala-dashboard (systemd) │
└──────────────────────┘ └──────────────────────────────┘
```
Flow: `local dev → git push → Gitea (Server B) → triggers runner on Server A → build + deploy`
---
## Phase 0 — Secure Server A (Ubuntu 24.04)
### 0.1 Create non-root user (if still logging in as root)
```bash
adduser fahed
usermod -aG sudo fahed
mkdir -p /home/fahed/.ssh
cp /root/.ssh/authorized_keys /home/fahed/.ssh/authorized_keys
chown -R fahed:fahed /home/fahed/.ssh
chmod 700 /home/fahed/.ssh
chmod 600 /home/fahed/.ssh/authorized_keys
```
Test in a **new terminal** before continuing: `ssh fahed@server-a`
### 0.2 Lock down SSH
Edit `/etc/ssh/sshd_config`:
```
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
AllowUsers fahed
```
```bash
sudo systemctl restart sshd
```
Test in a **new terminal** before closing your current session.
### 0.3 Firewall (ufw)
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP (certbot + redirect)
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
sudo ufw status
```
### 0.4 Automatic security updates
```bash
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
```
Select **Yes**.
### 0.5 Fail2ban
```bash
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
```
### 0.6 Disable unused services
```bash
sudo ss -tlnp
# Disable anything unexpected:
# sudo systemctl disable --now <service-name>
```
Server B (Cloudron) handles its own security.
---
## Phase 1 — Server B (Cloudron)
1. Install **Gitea** from Cloudron app store → `https://gitea.yourdomain.com`
2. Install **NocoDB** from Cloudron app store → `https://nocodb.yourdomain.com`
3. On NocoDB: create a base for marketing-app, generate an API token
4. On Gitea: create a repo `marketing-app`, push your local code to it
5. On Gitea: go to **Site Admin → Runners** and copy the runner registration token
---
## Phase 2 — Server A (Ubuntu 24.04)
### 2.1 Install dependencies
```bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt install -y nodejs nginx certbot python3-certbot-nginx
```
### 2.2 Create app directory
```bash
sudo mkdir -p /opt/apps/marketing-app
sudo chown fahed:fahed /opt/apps/marketing-app
```
### 2.3 Create .env file (set once, never touched by deploys)
```
# /opt/apps/marketing-app/server/.env
NOCODB_URL=https://nocodb.yourdomain.com
NOCODB_TOKEN=<token-from-step-3>
NOCODB_BASE_ID=<base-id-from-step-3>
SESSION_SECRET=<generate-with: openssl rand -hex 32>
CORS_ORIGIN=https://marketing.yourdomain.com
NODE_ENV=production
```
### 2.4 Install and register Gitea runner
```bash
sudo wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64 -O /usr/local/bin/act_runner
sudo chmod +x /usr/local/bin/act_runner
mkdir -p /opt/gitea-runner && cd /opt/gitea-runner
act_runner register \
--instance https://gitea.yourdomain.com \
--token <token-from-phase-1-step-5> \
--name server-a \
--labels ubuntu-latest:host
```
### 2.5 systemd service for the runner
```ini
# /etc/systemd/system/gitea-runner.service
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=fahed
WorkingDirectory=/opt/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now gitea-runner
```
### 2.6 systemd service for marketing-app
```ini
# /etc/systemd/system/marketing-app.service
[Unit]
Description=Marketing App
After=network.target
[Service]
Type=simple
User=fahed
WorkingDirectory=/opt/apps/marketing-app/server
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
EnvironmentFile=/opt/apps/marketing-app/server/.env
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable marketing-app
```
### 2.7 Allow passwordless restart for deploys
```
# /etc/sudoers.d/gitea-runner
fahed ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart marketing-app
fahed ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart hihala-dashboard
```
### 2.8 DNS
Point `marketing.yourdomain.com` to Server A's IP.
### 2.9 Nginx vhost
```nginx
# /etc/nginx/sites-available/marketing-app
server {
listen 443 ssl;
server_name marketing.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/marketing.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/marketing.yourdomain.com/privkey.pem;
location /api/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 50M;
}
location / {
root /opt/apps/marketing-app/client/dist;
try_files $uri $uri/ /index.html;
}
}
server {
listen 80;
server_name marketing.yourdomain.com;
return 301 https://$host$request_uri;
}
```
```bash
sudo ln -s /etc/nginx/sites-available/marketing-app /etc/nginx/sites-enabled/
sudo certbot --nginx -d marketing.yourdomain.com
sudo nginx -t && sudo systemctl reload nginx
```
---
## Phase 3 — CI/CD Pipeline
### 3.1 Add workflow file to repo
```yaml
# .gitea/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: cd client && npm ci && npm run build
- name: Deploy to app directory
run: |
rsync -a --delete client/dist/ /opt/apps/marketing-app/client/dist/
rsync -a server/ /opt/apps/marketing-app/server/ \
--exclude node_modules --exclude '*.db' --exclude uploads --exclude .env
- name: Install production deps
run: cd /opt/apps/marketing-app/server && npm ci --production
- name: Restart service
run: sudo systemctl restart marketing-app
```
### 3.2 Add Gitea remote and push
```bash
cd ~/clawd/projects/marketing-app
git remote add gitea https://gitea.yourdomain.com/fahed/marketing-app.git
git push gitea main
```
---
## Phase 4 — Verify
1. Watch the runner: `journalctl -u gitea-runner -f`
2. Watch the app: `journalctl -u marketing-app -f`
3. Open `https://marketing.yourdomain.com`
4. Test: login, create an artefact, upload a file
---
## Code changes already made (in server.js)
- Session secret reads from `process.env.SESSION_SECRET` with `'dev-fallback-secret'` fallback
- CORS locks to `process.env.CORS_ORIGIN` in production, open locally
- Cookie `secure: true` when `NODE_ENV=production`
- `trust proxy` enabled when `NODE_ENV=production`
- Static file serving of `client/dist/` + SPA fallback when `NODE_ENV=production`
---
## Adding another app later (e.g. hihala-dashboard)
Repeat phases 1.3-1.4, 2.2-2.3, 2.6, 2.8-2.9, 3.1-3.2 with:
- Different port (3002)
- Different subdomain (hihala.yourdomain.com)
- Different systemd service name (hihala-dashboard)
- Same runner handles all repos

View File

@@ -0,0 +1,281 @@
# Artefact Versioning System - Implementation Summary
## ✅ Completed Features
### Database Schema (NocoDB)
#### New Tables Created
1. **ArtefactVersions**
- `artefact_id` (Number)
- `version_number` (Number) - auto-incrementing per artefact
- `created_by_user_id` (Number)
- `created_at` (DateTime)
- `notes` (LongText) - what changed in this version
2. **ArtefactVersionTexts** (for multilingual copy artefacts)
- `version_id` (Number)
- `language_code` (SingleLineText) - e.g., "AR", "EN", "FR"
- `language_label` (SingleLineText) - e.g., "العربية", "English"
- `content` (LongText) - the actual text content
#### Modified Tables
1. **Artefacts**
- Added: `current_version` (Number) - latest version number
- Added: `review_version` (Number) - version submitted for review
2. **ArtefactAttachments**
- Added: `version_id` (Number) - attachments now belong to specific versions
- Added: `drive_url` (SingleLineText) - Google Drive video links
- Kept: `artefact_id` for backward compatibility
3. **Comments**
- Added: `version_number` (Number) - comments tied to specific versions
### Server API Routes
#### Version Management
- `GET /api/artefacts/:id/versions` - List all versions
- `POST /api/artefacts/:id/versions` - Create new version (auto-increments)
- `GET /api/artefacts/:id/versions/:versionId` - Get specific version with texts/attachments
#### Text Management (Copy Type)
- `POST /api/artefacts/:id/versions/:versionId/texts` - Add/update language entry
- `DELETE /api/artefact-version-texts/:id` - Remove language entry
#### Attachment Management
- `POST /api/artefacts/:id/versions/:versionId/attachments` - Upload file OR Google Drive link
- For video type: accepts `drive_url` field for Google Drive videos
- For design/video: accepts file upload
#### Comments (Version-Specific)
- `GET /api/artefacts/:id/versions/:versionId/comments` - Get comments for version
- `POST /api/artefacts/:id/versions/:versionId/comments` - Add comment to version
#### Updated Routes
- `POST /api/artefacts` - Now auto-creates version 1
- `POST /api/artefacts/:id/submit-review` - Stores `review_version`
- `GET /api/public/review/:token` - Returns specific version submitted for review
### Client Components
#### New Component: `ArtefactVersionTimeline.jsx`
- Vertical timeline showing all versions
- Each entry displays:
- Version number
- Date and creator
- Change notes
- Thumbnail (for design artefacts)
- Active version highlighted
- Click to switch versions
#### Updated: `Artefacts.jsx`
- Complete rewrite with versioning support
- Type-specific UIs:
**For ALL Types:**
- Version selector/timeline
- "New Version" button with optional notes
- Comments section (filtered by version)
- Submit for Review (submits current/latest version)
**For type=copy (Text Artefacts):**
- Language management UI
- Add/edit/delete languages per version
- Each language: code + label + content in card
- "Copy from previous version" option when creating new version
- Multi-language tabs with rich text display
**For type=design (Image Artefacts):**
- Image gallery with preview thumbnails
- Upload images to current version
- Version timeline shows thumbnail per version
- Lightbox/enlarge on click
- Delete images per version
**For type=video:**
- Dual-mode video upload:
1. **File Upload** - Standard video file upload
2. **Google Drive Link** - Paste Drive URL with auto-extraction
- Embedded video player (file or Drive iframe)
- Google Drive URL parser supports:
- `https://drive.google.com/file/d/ABC123/view`
- `https://drive.google.com/open?id=ABC123`
- `https://docs.google.com/file/d/ABC123/edit`
- Auto-converts to embed URL: `https://drive.google.com/file/d/${id}/preview`
#### Updated: `PublicReview.jsx`
- Shows specific version submitted for review
- Type-specific display:
**For copy:**
- Multi-language tabs
- Switch between languages
- Formatted content display
**For design:**
- Image gallery with full-size preview
- Click to open in new tab
**For video:**
- Embedded video player
- Google Drive iframe support
- Standard HTML5 video player
- Comments display (tied to reviewed version)
- Approval actions apply to specific version
### Features Implemented
**Version Management**
- Auto-create version 1 on artefact creation
- Incremental version numbering per artefact
- Version notes/changelog
- Creator tracking per version
**Multilingual Content (Copy Type)**
- Add unlimited languages per version
- Language code + label + content
- Copy languages from previous version
- Delete individual languages
**Image Gallery (Design Type)**
- Upload multiple images per version
- Version-specific image storage
- Image preview and management
- Delete images per version
**Video Support (Video Type)**
- File upload for local videos
- Google Drive link integration
- Auto-embed with Drive URL parser
- Mixed sources (uploaded files + Drive links)
**Comments per Version**
- Comments tied to specific version number
- Version-aware comment filtering
- Public/internal comments support
**Public Review**
- Review specific version (not latest)
- Version info displayed
- Type-specific content rendering
- Approval applies to reviewed version
**Backward Compatibility**
- Existing artefacts treated as version 1
- Legacy content field still supported
- Graceful fallback for missing versions
## Testing Results
✅ Server starts successfully
- All tables created/updated without errors
- Migration system runs cleanly
- No conflicts with existing schema
✅ Client builds successfully
- No TypeScript/JSX errors
- All imports resolved
- Build size: 729 KB (gzipped: 180 KB)
## Usage Examples
### Create Artefact
1. Click "New Artefact"
2. Choose type (copy/design/video/other)
3. Fill title, description, brand
4. Artefact created with version 1 automatically
### Add Language (Copy Type)
1. Open artefact detail panel
2. Select version
3. Click "Add Language"
4. Enter code (AR, EN, FR), label, and content
5. Language saved to current version
### Upload Images (Design Type)
1. Open artefact detail panel
2. Select version
3. Click "Upload Image"
4. Choose file
5. Image added to version
### Add Google Drive Video
1. Open artefact detail panel (video type)
2. Click "Add Video"
3. Select "Google Drive Link"
4. Paste Drive URL
5. Video embedded automatically
### Create New Version
1. Click "New Version" button
2. Enter notes (optional)
3. For copy type: choose to copy languages from previous
4. New version created, becomes current
### Submit for Review
1. Ensure artefact is in draft status
2. Click "Submit for Review"
3. Review link generated (expires in 7 days)
4. Current version is locked for review
### Public Review Flow
1. Approver opens review link
2. Sees specific version submitted
3. Views type-specific content
4. Adds feedback (optional)
5. Approves/Rejects/Requests Revision
## Migration Notes
- Existing artefacts automatically supported
- No manual migration required
- Version 1 auto-created for new artefacts
- Comments table gets `version_number` column added automatically
- ArtefactAttachments table gets `version_id` and `drive_url` columns added
## Google Drive Integration
The system extracts file IDs from various Google Drive URL formats:
```javascript
// Supported formats:
https://drive.google.com/file/d/FILE_ID/view
https://drive.google.com/open?id=FILE_ID
https://docs.google.com/file/d/FILE_ID/edit
// Converts to embed format:
https://drive.google.com/file/d/FILE_ID/preview
```
**Requirements:**
- Drive file must be publicly accessible or shared with link
- "Anyone with the link can view" permission required
## Next Steps (Optional Enhancements)
- [ ] Version comparison UI (diff between versions)
- [ ] Bulk language import from CSV/JSON
- [ ] Version rollback functionality
- [ ] Version branching for A/B testing
- [ ] Export version as PDF
- [ ] Collaborative editing with real-time comments
- [ ] Version approval workflow (multi-stage)
- [ ] Asset library integration for reusable content
- [ ] Analytics per version (view count, approval time)
- [ ] Email notifications on version updates
## Technical Notes
- All version operations require authentication
- Contributors can only manage their own artefacts
- Managers/Superadmins can manage all artefacts
- File cleanup: orphaned files deleted when last reference removed
- Google Drive embeds require iframe support in browser
- Version timeline loads asynchronously for better performance
---
**Implementation Date:** 2026-02-19
**Base:** p37fzfdy2erdcle
**Status:** ✅ Complete and Tested

View File

@@ -19,6 +19,12 @@ import Users from './pages/Users'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import Brands from './pages/Brands' import Brands from './pages/Brands'
import Login from './pages/Login' import Login from './pages/Login'
import Artefacts from './pages/Artefacts'
import PostCalendar from './pages/PostCalendar'
import PublicReview from './pages/PublicReview'
import Issues from './pages/Issues'
import PublicIssueSubmit from './pages/PublicIssueSubmit'
import PublicIssueTracker from './pages/PublicIssueTracker'
import Tutorial from './components/Tutorial' import Tutorial from './components/Tutorial'
import Modal from './components/Modal' import Modal from './components/Modal'
import { api } from './utils/api' import { api } from './utils/api'
@@ -268,10 +274,15 @@ function AppContent() {
<Routes> <Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/review/:token" element={<PublicReview />} />
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}> <Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{hasModule('marketing') && <> {hasModule('marketing') && <>
<Route path="posts" element={<PostProduction />} /> <Route path="posts" element={<PostProduction />} />
<Route path="calendar" element={<PostCalendar />} />
<Route path="artefacts" element={<Artefacts />} />
<Route path="assets" element={<Assets />} /> <Route path="assets" element={<Assets />} />
<Route path="campaigns" element={<Campaigns />} /> <Route path="campaigns" element={<Campaigns />} />
<Route path="campaigns/:id" element={<CampaignDetail />} /> <Route path="campaigns/:id" element={<CampaignDetail />} />
@@ -286,6 +297,7 @@ function AppContent() {
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />
<Route path="tasks" element={<Tasks />} /> <Route path="tasks" element={<Tasks />} />
</>} </>}
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
<Route path="team" element={<Team />} /> <Route path="team" element={<Team />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
{user?.role === 'superadmin' && ( {user?.role === 'superadmin' && (

View File

@@ -0,0 +1,96 @@
import { Check, Clock, User } from 'lucide-react'
export default function ArtefactVersionTimeline({ versions, activeVersionId, onSelectVersion, artefactType }) {
if (!versions || versions.length === 0) {
return (
<div className="text-center py-4 text-sm text-text-tertiary">
No versions found
</div>
)
}
return (
<div className="space-y-2">
{versions.map((version, idx) => {
const isActive = version.Id === activeVersionId
const isLatest = idx === versions.length - 1
return (
<button
key={version.Id}
onClick={() => onSelectVersion(version)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
isActive
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/30 bg-surface hover:shadow-sm'
}`}
>
<div className="flex items-start gap-3">
{/* Version indicator */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
isActive
? 'bg-brand-primary text-white'
: 'bg-surface-secondary text-text-secondary'
}`}>
v{version.version_number}
</div>
{/* Version details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-sm font-medium ${isActive ? 'text-brand-primary' : 'text-text-primary'}`}>
Version {version.version_number}
</span>
{isLatest && (
<span className="text-xs px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full font-medium">
Latest
</span>
)}
</div>
{version.notes && (
<p className="text-xs text-text-secondary line-clamp-2 mb-2">
{version.notes}
</p>
)}
<div className="flex items-center gap-3 text-xs text-text-tertiary">
{version.creator_name && (
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
<span>{version.creator_name}</span>
</div>
)}
{version.created_at && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{new Date(version.created_at).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
{/* Active indicator */}
{isActive && (
<div className="flex-shrink-0">
<Check className="w-5 h-5 text-brand-primary" />
</div>
)}
</div>
{/* Thumbnail for image artefacts */}
{artefactType === 'design' && version.thumbnail && (
<div className="mt-2 ml-11">
<img
src={version.thumbnail}
alt={`Version ${version.version_number}`}
className="w-full h-20 object-cover rounded border border-border"
/>
</div>
)}
</button>
)
})}
</div>
)
}

View File

@@ -14,7 +14,11 @@ export default function CollapsibleSection({ title, defaultOpen = true, badge, c
{title} {title}
{badge} {badge}
</button> </button>
{open && children} <div className={`collapsible-content ${open ? 'is-open' : ''}`}>
<div className="collapsible-inner">
{children}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,56 @@
const PRIORITY_CONFIG = {
low: { label: 'Low', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', dot: 'bg-blue-500' },
high: { label: 'High', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', dot: 'bg-red-500' },
}
const TYPE_LABELS = {
request: 'Request',
correction: 'Correction',
complaint: 'Complaint',
suggestion: 'Suggestion',
other: 'Other',
}
export default function IssueCard({ issue, onClick }) {
const priority = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
const formatDate = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
return (
<div
onClick={() => onClick?.(issue)}
className="bg-surface border border-border rounded-lg p-3 cursor-pointer hover:border-brand-primary/30 hover:shadow-sm transition-all"
>
{/* Priority dot + Title */}
<div className="flex items-start gap-2 mb-2">
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${priority.dot}`} />
<h4 className="text-sm font-medium text-text-primary line-clamp-2">{issue.title}</h4>
</div>
{/* Metadata */}
<div className="flex items-center gap-2 flex-wrap">
{issue.category && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
{issue.category}
</span>
)}
{issue.brand_name && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-primary/10 text-brand-primary font-medium">
{issue.brand_name}
</span>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-2 text-[10px] text-text-tertiary">
<span className="truncate max-w-[60%]">{issue.submitter_name}</span>
<span>{formatDate(issue.created_at || issue.CreatedAt)}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,584 @@
import { useState, useEffect, useContext } from 'react'
import { X, Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle } from 'lucide-react'
import { api } from '../utils/api'
import SlidePanel from './SlidePanel'
import FormInput from './FormInput'
import Modal from './Modal'
import { AppContext } from '../App'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700' },
}
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers }) {
const { brands } = useContext(AppContext)
const [issueData, setIssueData] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
const [initialLoading, setInitialLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
// Form state
const [assignedTo, setAssignedTo] = useState('')
const [internalNotes, setInternalNotes] = useState('')
const [resolutionSummary, setResolutionSummary] = useState('')
const [newUpdate, setNewUpdate] = useState('')
const [updateIsPublic, setUpdateIsPublic] = useState(false)
// Modals
const [showResolveModal, setShowResolveModal] = useState(false)
const [showDeclineModal, setShowDeclineModal] = useState(false)
const issueId = issue?.Id || issue?.id
useEffect(() => {
if (issueId) loadIssueDetails()
}, [issueId])
const loadIssueDetails = async () => {
try {
const data = await api.get(`/issues/${issueId}`)
setIssueData(data)
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
setAssignedTo(data.assigned_to_id || '')
setInternalNotes(data.internal_notes || '')
setResolutionSummary(data.resolution_summary || '')
} catch (err) {
console.error('Failed to load issue:', err)
} finally {
setInitialLoading(false)
}
}
const handleUpdateStatus = async (newStatus) => {
if (saving) return
try {
setSaving(true)
await api.patch(`/issues/${issueId}`, { status: newStatus })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update status:', err)
alert('Failed to update status')
} finally {
setSaving(false)
}
}
const handleResolve = async () => {
if (saving || !resolutionSummary.trim()) return
try {
setSaving(true)
await api.patch(`/issues/${issueId}`, { status: 'resolved', resolution_summary: resolutionSummary })
await onUpdate()
setShowResolveModal(false)
await loadIssueDetails()
} catch (err) {
console.error('Failed to resolve issue:', err)
alert('Failed to resolve issue')
} finally {
setSaving(false)
}
}
const handleDecline = async () => {
if (saving || !resolutionSummary.trim()) return
try {
setSaving(true)
await api.patch(`/issues/${issueId}`, { status: 'declined', resolution_summary: resolutionSummary })
await onUpdate()
setShowDeclineModal(false)
await loadIssueDetails()
} catch (err) {
console.error('Failed to decline issue:', err)
alert('Failed to decline issue')
} finally {
setSaving(false)
}
}
const handleAssignmentChange = async (newAssignedTo) => {
try {
setAssignedTo(newAssignedTo)
await api.patch(`/issues/${issueId}`, { assigned_to_id: newAssignedTo || null })
await onUpdate()
} catch (err) {
console.error('Failed to update assignment:', err)
alert('Failed to update assignment')
}
}
const handleNotesChange = async () => {
if (saving) return
try {
setSaving(true)
await api.patch(`/issues/${issueId}`, { internal_notes: internalNotes })
} catch (err) {
console.error('Failed to save notes:', err)
alert('Failed to save notes')
} finally {
setSaving(false)
}
}
const handleAddUpdate = async () => {
if (!newUpdate.trim() || saving) return
try {
setSaving(true)
await api.post(`/issues/${issueId}/updates`, { message: newUpdate, is_public: updateIsPublic })
setNewUpdate('')
setUpdateIsPublic(false)
await loadIssueDetails()
} catch (err) {
console.error('Failed to add update:', err)
alert('Failed to add update')
} finally {
setSaving(false)
}
}
const handleFileUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
setUploadingFile(true)
const formData = new FormData()
formData.append('file', file)
await api.upload(`/issues/${issueId}/attachments`, formData)
await loadIssueDetails()
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
} finally {
setUploadingFile(false)
}
}
const handleDeleteAttachment = async (attachmentId) => {
if (!confirm('Delete this attachment?')) return
try {
await api.delete(`/issue-attachments/${attachmentId}`)
await loadIssueDetails()
} catch (err) {
console.error('Failed to delete attachment:', err)
alert('Failed to delete attachment')
}
}
const copyTrackingLink = () => {
const url = `${window.location.origin}/track/${issueData.tracking_token}`
navigator.clipboard.writeText(url)
alert('Tracking link copied to clipboard!')
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
if (initialLoading || !issueData) {
return (
<SlidePanel onClose={onClose} maxWidth="600px">
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-primary"></div>
</div>
</SlidePanel>
)
}
const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new
const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium
return (
<>
<SlidePanel
onClose={onClose}
maxWidth="600px"
header={
<div className="p-4 border-b border-border bg-surface-secondary">
<div className="flex items-start justify-between gap-3 mb-3">
<h2 className="text-lg font-bold text-text-primary flex-1">{issueData.title}</h2>
<button onClick={onClose} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
</span>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${priorityConfig.bg} ${priorityConfig.text}`}>
{priorityConfig.label}
</span>
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary capitalize">
{issueData.type}
</span>
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{issueData.category}
</span>
{issueData.brand_name && (
<span className="text-xs px-2 py-1 rounded-full bg-brand-primary/10 text-brand-primary font-medium">
{issueData.brand_name}
</span>
)}
</div>
</div>
}
>
<div className="p-4 space-y-6">
{/* Submitter Info */}
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-2">Submitter Information</h3>
<div className="space-y-1 text-sm">
<div><span className="text-text-tertiary">Name:</span> <span className="text-text-primary font-medium">{issueData.submitter_name}</span></div>
<div><span className="text-text-tertiary">Email:</span> <span className="text-text-primary">{issueData.submitter_email}</span></div>
{issueData.submitter_phone && (
<div><span className="text-text-tertiary">Phone:</span> <span className="text-text-primary">{issueData.submitter_phone}</span></div>
)}
<div><span className="text-text-tertiary">Submitted:</span> <span className="text-text-primary">{formatDate(issueData.created_at)}</span></div>
</div>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-2">Description</h3>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{issueData.description || 'No description provided'}</p>
</div>
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Assigned To</label>
<select
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">Unassigned</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
</div>
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Brand</label>
<select
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
loadIssueDetails();
onUpdate();
} catch {}
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">No brand</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
</div>
{/* Internal Notes */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2 flex items-center gap-2">
<Lock className="w-4 h-4" />
Internal Notes (Staff Only)
</label>
<textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
onBlur={handleNotesChange}
rows={4}
placeholder="Internal notes not visible to submitter..."
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
{/* Resolution Summary (if resolved/declined) */}
{(issueData.status === 'resolved' || issueData.status === 'declined') && issueData.resolution_summary && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
Resolution Summary (Public)
</h3>
<p className="text-sm text-emerald-800 whitespace-pre-wrap">{issueData.resolution_summary}</p>
{issueData.resolved_at && (
<p className="text-xs text-emerald-600 mt-2">Resolved on {formatDate(issueData.resolved_at)}</p>
)}
</div>
)}
{/* Status Actions */}
{issueData.status !== 'resolved' && issueData.status !== 'declined' && (
<div className="flex gap-2 flex-wrap">
{issueData.status === 'new' && (
<button
onClick={() => handleUpdateStatus('acknowledged')}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
<Check className="w-4 h-4 inline mr-1" />
Acknowledge
</button>
)}
{(issueData.status === 'new' || issueData.status === 'acknowledged') && (
<button
onClick={() => handleUpdateStatus('in_progress')}
disabled={saving}
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50"
>
<Clock className="w-4 h-4 inline mr-1" />
Start Work
</button>
)}
<button
onClick={() => setShowResolveModal(true)}
disabled={saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 className="w-4 h-4 inline mr-1" />
Resolve
</button>
<button
onClick={() => setShowDeclineModal(true)}
disabled={saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
<XCircle className="w-4 h-4 inline mr-1" />
Decline
</button>
</div>
)}
{/* Tracking Link */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">Public Tracking Link</label>
<div className="flex gap-2">
<input
type="text"
value={`${window.location.origin}/track/${issueData.tracking_token}`}
readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface-secondary"
/>
<button
onClick={copyTrackingLink}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
{/* Updates Timeline */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Updates Timeline
<span className="text-xs text-text-tertiary font-normal">({updates.length})</span>
</h3>
{/* Add Update */}
<div className="bg-surface-secondary rounded-lg p-3 mb-4">
<textarea
value={newUpdate}
onChange={(e) => setNewUpdate(e.target.value)}
placeholder="Add an update..."
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 mb-2"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input
type="checkbox"
checked={updateIsPublic}
onChange={(e) => setUpdateIsPublic(e.target.checked)}
className="rounded"
/>
<Eye className="w-4 h-4" />
Make public (visible to submitter)
</label>
<button
onClick={handleAddUpdate}
disabled={!newUpdate.trim() || saving}
className="px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
Add Update
</button>
</div>
</div>
{/* Updates List */}
<div className="space-y-3">
{updates.map((update) => (
<div
key={update.Id || update.id}
className={`p-3 rounded-lg border ${update.is_public ? 'bg-brand-primary/5 border-brand-primary/20' : 'bg-surface-secondary border-border'}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-surface-tertiary text-text-secondary'}`}>
{update.author_type}
</span>
{update.is_public ? (
<Eye className="w-3.5 h-3.5 text-blue-600" title="Public" />
) : (
<Lock className="w-3.5 h-3.5 text-text-secondary" title="Internal only" />
)}
</div>
<span className="text-xs text-text-tertiary">{formatDate(update.created_at)}</span>
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{update.message}</p>
</div>
))}
{updates.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-6">No updates yet</p>
)}
</div>
</div>
{/* Attachments */}
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
Attachments
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
</h3>
{/* Upload */}
<label className="block mb-3">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? 'Uploading...' : 'Click to upload file'}
</p>
</div>
</label>
{/* Attachments List */}
<div className="space-y-2">
{attachments.map((att) => (
<div key={att.Id || att.id} className="flex items-center justify-between p-3 bg-surface-secondary rounded-lg">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
<p className="text-xs text-text-tertiary">
{formatFileSize(att.size)} {att.uploaded_by}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<a
href={`/api/uploads/${att.filename}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline"
>
Download
</a>
<button onClick={() => handleDeleteAttachment(att.Id || att.id)} className="p-1 hover:bg-surface-tertiary rounded">
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
</div>
))}
{attachments.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4">No attachments</p>
)}
</div>
</div>
</div>
</SlidePanel>
{/* Resolve Modal */}
{showResolveModal && (
<Modal isOpen title="Resolve Issue" onClose={() => setShowResolveModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a resolution summary that will be visible to the submitter.</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain how this issue was resolved..."
rows={5}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowResolveModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
</button>
<button
onClick={handleResolve}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? 'Resolving...' : 'Mark as Resolved'}
</button>
</div>
</div>
</Modal>
)}
{/* Decline Modal */}
{showDeclineModal && (
<Modal isOpen title="Decline Issue" onClose={() => setShowDeclineModal(false)}>
<div className="space-y-4">
<p className="text-sm text-text-secondary">Provide a reason for declining this issue. This will be visible to the submitter.</p>
<textarea
value={resolutionSummary}
onChange={(e) => setResolutionSummary(e.target.value)}
placeholder="Explain why this issue cannot be addressed..."
rows={5}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowDeclineModal(false)}
className="px-4 py-2 bg-surface-secondary text-text-primary rounded-lg text-sm font-medium hover:bg-surface-tertiary"
>
Cancel
</button>
<button
onClick={handleDecline}
disabled={!resolutionSummary.trim() || saving}
className="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
{saving ? 'Declining...' : 'Decline Issue'}
</button>
</div>
</div>
</Modal>
)}
</>
)
}

View File

@@ -45,7 +45,7 @@ export default function Modal({
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
/> />
@@ -94,7 +94,7 @@ export default function Modal({
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4"> <div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/40 backdrop-blur-sm" className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in"
onClick={onClose} onClick={onClose}
/> />

View File

@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet, LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown, FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3 Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
@@ -22,6 +22,8 @@ const moduleGroups = [
items: [ items: [
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' }, { to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' }, { to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
{ to: '/calendar', icon: CalendarDays, labelKey: 'nav.calendar' },
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' }, { to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' }, { to: '/brands', icon: Tag, labelKey: 'nav.brands' },
], ],
@@ -45,6 +47,14 @@ const moduleGroups = [
{ to: '/budgets', icon: Receipt, labelKey: 'nav.budgets' }, { to: '/budgets', icon: Receipt, labelKey: 'nav.budgets' },
], ],
}, },
{
module: 'issues',
labelKey: 'modules.issues',
icon: AlertCircle,
items: [
{ to: '/issues', icon: AlertCircle, labelKey: 'nav.issues' },
],
},
] ]
const standaloneBottom = [ const standaloneBottom = [

View File

@@ -76,6 +76,49 @@ export function SkeletonKanbanBoard() {
) )
} }
export function SkeletonCalendar() {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
</div>
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{[...Array(7)].map((_, i) => (
<div key={i} className="text-center py-3">
<div className="h-3 bg-surface-tertiary rounded w-8 mx-auto"></div>
</div>
))}
</div>
<div className="grid grid-cols-7">
{[...Array(35)].map((_, i) => (
<div key={i} className="border-r border-b border-border min-h-[100px] p-2">
<div className="h-5 w-5 bg-surface-tertiary rounded-full mb-2"></div>
<div className="space-y-1">
<div className="h-3 bg-surface-tertiary rounded w-full"></div>
{i % 3 === 0 && <div className="h-3 bg-surface-tertiary rounded w-3/4"></div>}
</div>
</div>
))}
</div>
</div>
)
}
export function SkeletonAssetGrid({ count = 10 }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{[...Array(count)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square bg-surface-tertiary rounded-xl"></div>
<div className="mt-2 h-3 bg-surface-tertiary rounded w-3/4"></div>
<div className="mt-1 h-3 bg-surface-tertiary rounded w-1/2"></div>
</div>
))}
</div>
)
}
export function SkeletonDashboard() { export function SkeletonDashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) { export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
return createPortal( return createPortal(
<> <>
<div className="fixed inset-0 bg-black/20 z-[9998]" onClick={onClose} /> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} />
<div <div
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden" className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
style={{ maxWidth }} style={{ maxWidth }}

View File

@@ -22,7 +22,7 @@ const ICON_COLORS = {
warning: 'text-amber-500', warning: 'text-amber-500',
} }
export default function Toast({ message, type = 'info', onClose, duration = 4000 }) { export default function Toast({ message, type = 'info', onClose, duration = 4000, exiting = false }) {
const Icon = TOAST_ICONS[type] const Icon = TOAST_ICONS[type]
const colorClass = TOAST_COLORS[type] const colorClass = TOAST_COLORS[type]
const iconColor = ICON_COLORS[type] const iconColor = ICON_COLORS[type]
@@ -35,7 +35,7 @@ export default function Toast({ message, type = 'info', onClose, duration = 4000
}, [duration, onClose]) }, [duration, onClose])
return ( return (
<div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} animate-slide-in min-w-[300px] max-w-md`}> <div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} ${exiting ? 'animate-slide-out' : 'animate-slide-in'} min-w-[300px] max-w-md`}>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${iconColor}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${iconColor}`} />
<p className="flex-1 text-sm font-medium leading-snug">{message}</p> <p className="flex-1 text-sm font-medium leading-snug">{message}</p>
<button <button

View File

@@ -20,7 +20,10 @@ export function ToastProvider({ children }) {
}, []) }, [])
const removeToast = useCallback((id) => { const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, []) }, [])
const toast = { const toast = {
@@ -42,6 +45,7 @@ export function ToastProvider({ children }) {
message={t.message} message={t.message}
type={t.type} type={t.type}
duration={t.duration} duration={t.duration}
exiting={t.exiting}
onClose={() => removeToast(t.id)} onClose={() => removeToast(t.id)}
/> />
))} ))}

View File

@@ -73,10 +73,11 @@ export function AuthProvider({ children }) {
if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource) if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource)
if (type === 'campaign') return permissions.canEditCampaigns || isOwner(resource) if (type === 'campaign') return permissions.canEditCampaigns || isOwner(resource)
if (type === 'project') return permissions.canEditProjects || isOwner(resource) if (type === 'project') return permissions.canEditProjects || isOwner(resource)
if (type === 'artefact') return permissions.canEditAnyPost || isOwner(resource)
return false return false
} }
const ALL_MODULES = ['marketing', 'projects', 'finance'] const ALL_MODULES = ['marketing', 'projects', 'finance', 'issues']
const hasModule = (mod) => { const hasModule = (mod) => {
if (!user) return false if (!user) return false
@@ -91,6 +92,7 @@ export function AuthProvider({ children }) {
if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource) if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource)
if (type === 'campaign') return permissions.canDeleteCampaigns || isOwner(resource) if (type === 'campaign') return permissions.canDeleteCampaigns || isOwner(resource)
if (type === 'project') return permissions.canDeleteProjects || isOwner(resource) if (type === 'project') return permissions.canDeleteProjects || isOwner(resource)
if (type === 'artefact') return permissions.canDeleteAnyPost || isOwner(resource)
return false return false
} }

View File

@@ -7,9 +7,12 @@
"nav.financeDashboard": "لوحة التحكم", "nav.financeDashboard": "لوحة التحكم",
"nav.budgets": "الميزانيات", "nav.budgets": "الميزانيات",
"nav.posts": "إنتاج المحتوى", "nav.posts": "إنتاج المحتوى",
"nav.calendar": "تقويم المحتوى",
"nav.artefacts": "القطع الإبداعية",
"nav.assets": "الأصول", "nav.assets": "الأصول",
"nav.projects": "المشاريع", "nav.projects": "المشاريع",
"nav.tasks": "المهام", "nav.tasks": "المهام",
"nav.issues": "المشاكل",
"nav.team": "الفرق", "nav.team": "الفرق",
"nav.settings": "الإعدادات", "nav.settings": "الإعدادات",
"nav.users": "المستخدمين", "nav.users": "المستخدمين",
@@ -392,6 +395,7 @@
"modules.marketing": "التسويق", "modules.marketing": "التسويق",
"modules.projects": "المشاريع", "modules.projects": "المشاريع",
"modules.finance": "المالية", "modules.finance": "المالية",
"modules.issues": "المشاكل",
"teams.title": "الفرق", "teams.title": "الفرق",
"teams.teams": "الفرق", "teams.teams": "الفرق",
"teams.createTeam": "إنشاء فريق", "teams.createTeam": "إنشاء فريق",
@@ -466,5 +470,21 @@
"settings.mb": "ميجابايت", "settings.mb": "ميجابايت",
"settings.saved": "تم حفظ الإعدادات!", "settings.saved": "تم حفظ الإعدادات!",
"tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت", "tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت",
"tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت." "tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت.",
"issues.board": "لوحة",
"issues.list": "قائمة",
"issues.statusUpdated": "تم تحديث حالة المشكلة!",
"issues.dropHere": "أفلت هنا",
"issues.noIssuesInColumn": "لا توجد مشاكل",
"artefacts.grid": "شبكة",
"artefacts.list": "قائمة",
"artefacts.allCreators": "جميع المنشئين",
"artefacts.allProjects": "جميع المشاريع",
"artefacts.allCampaigns": "جميع الحملات",
"artefacts.project": "المشروع",
"artefacts.campaign": "الحملة",
"artefacts.sortRecentlyUpdated": "آخر تحديث",
"artefacts.sortNewest": "الأحدث أولاً",
"artefacts.sortOldest": "الأقدم أولاً",
"artefacts.sortTitleAZ": "العنوان أ-ي"
} }

View File

@@ -7,9 +7,12 @@
"nav.financeDashboard": "Dashboard", "nav.financeDashboard": "Dashboard",
"nav.budgets": "Budgets", "nav.budgets": "Budgets",
"nav.posts": "Post Production", "nav.posts": "Post Production",
"nav.calendar": "Content Calendar",
"nav.artefacts": "Artefacts",
"nav.assets": "Assets", "nav.assets": "Assets",
"nav.projects": "Projects", "nav.projects": "Projects",
"nav.tasks": "Tasks", "nav.tasks": "Tasks",
"nav.issues": "Issues",
"nav.team": "Teams", "nav.team": "Teams",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.users": "Users", "nav.users": "Users",
@@ -392,6 +395,7 @@
"modules.marketing": "Marketing", "modules.marketing": "Marketing",
"modules.projects": "Projects", "modules.projects": "Projects",
"modules.finance": "Finance", "modules.finance": "Finance",
"modules.issues": "Issues",
"teams.title": "Teams", "teams.title": "Teams",
"teams.teams": "Teams", "teams.teams": "Teams",
"teams.createTeam": "Create Team", "teams.createTeam": "Create Team",
@@ -466,5 +470,21 @@
"settings.mb": "MB", "settings.mb": "MB",
"settings.saved": "Settings saved!", "settings.saved": "Settings saved!",
"tasks.maxFileSize": "Max file size: {size} MB", "tasks.maxFileSize": "Max file size: {size} MB",
"tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB." "tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB.",
"issues.board": "Board",
"issues.list": "List",
"issues.statusUpdated": "Issue status updated!",
"issues.dropHere": "Drop here",
"issues.noIssuesInColumn": "No issues",
"artefacts.grid": "Grid",
"artefacts.list": "List",
"artefacts.allCreators": "All Creators",
"artefacts.allProjects": "All Projects",
"artefacts.allCampaigns": "All Campaigns",
"artefacts.project": "Project",
"artefacts.campaign": "Campaign",
"artefacts.sortRecentlyUpdated": "Recently Updated",
"artefacts.sortNewest": "Newest First",
"artefacts.sortOldest": "Oldest First",
"artefacts.sortTitleAZ": "Title A-Z"
} }

View File

@@ -178,7 +178,42 @@ textarea {
} }
.animate-slide-in-right { .animate-slide-in-right {
animation: slide-in-right 0.25s ease-out; animation: slide-in-right 0.3s ease-out;
}
/* Backdrop fade-in */
@keyframes backdropFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-backdrop-in {
animation: backdropFadeIn 0.2s ease-out forwards;
}
/* Slide out (reverse of slideIn) */
@keyframes slideOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-12px); }
}
.animate-slide-out {
animation: slideOut 0.3s ease-in forwards;
}
/* Collapsible section (CSS grid height transition) */
.collapsible-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible-content.is-open {
grid-template-rows: 1fr;
}
.collapsible-inner {
overflow: hidden;
} }
/* Stagger children */ /* Stagger children */

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { api } from '../utils/api'
import AssetCard from '../components/AssetCard' import AssetCard from '../components/AssetCard'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import CommentsSection from '../components/CommentsSection' import CommentsSection from '../components/CommentsSection'
import { SkeletonAssetGrid } from '../components/SkeletonLoader'
export default function Assets() { export default function Assets() {
const [assets, setAssets] = useState([]) const [assets, setAssets] = useState([])
@@ -135,13 +136,9 @@ export default function Assets() {
if (loading) { if (loading) {
return ( return (
<div className="animate-pulse"> <div className="space-y-4">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4"> <SkeletonAssetGrid count={10} />
{[...Array(10)].map((_, i) => (
<div key={i} className="aspect-square bg-surface-tertiary rounded-xl"></div>
))}
</div>
</div> </div>
) )
} }
@@ -223,7 +220,7 @@ export default function Assets() {
<p className="text-sm text-text-tertiary mt-1">Upload your first asset to get started</p> <p className="text-sm text-text-tertiary mt-1">Upload your first asset to get started</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 stagger-children">
{filteredAssets.map(asset => ( {filteredAssets.map(asset => (
<div key={asset._id || asset.id}> <div key={asset._id || asset.id}>
<AssetCard asset={asset} onClick={setSelectedAsset} /> <AssetCard asset={asset} onClick={setSelectedAsset} />

View File

@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { AppContext } from '../App' import { AppContext } from '../App'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import { SkeletonCard } from '../components/SkeletonLoader'
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
@@ -115,8 +116,11 @@ export default function Brands() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="space-y-6">
<div className="w-8 h-8 border-3 border-brand-primary border-t-transparent rounded-full animate-spin" /> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => <SkeletonCard key={i} />)}
</div>
</div> </div>
) )
} }
@@ -150,7 +154,7 @@ export default function Brands() {
<p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p> <p className="text-sm text-text-tertiary">{t('brands.noBrands')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
{brands.map(brand => { {brands.map(brand => {
const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
return ( return (

View File

@@ -191,7 +191,21 @@ export default function CampaignDetail() {
} }
if (loading) { if (loading) {
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div> return (
<div className="space-y-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
<div className="flex-1">
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
<div className="h-4 bg-surface-tertiary rounded w-96 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-48"></div>
</div>
</div>
<div className="h-24 bg-surface-tertiary rounded-xl"></div>
<div className="h-48 bg-surface-tertiary rounded-xl"></div>
<div className="h-64 bg-surface-tertiary rounded-xl"></div>
</div>
)
} }
if (!campaign) { if (!campaign) {

View File

@@ -12,6 +12,7 @@ import BrandBadge from '../components/BrandBadge'
import BudgetBar from '../components/BudgetBar' import BudgetBar from '../components/BudgetBar'
import InteractiveTimeline from '../components/InteractiveTimeline' import InteractiveTimeline from '../components/InteractiveTimeline'
import CampaignDetailPanel from '../components/CampaignDetailPanel' import CampaignDetailPanel from '../components/CampaignDetailPanel'
import { SkeletonStatCard, SkeletonTable } from '../components/SkeletonLoader'
function ROIBadge({ revenue, spent }) { function ROIBadge({ revenue, spent }) {
if (!spent || spent <= 0) return null if (!spent || spent <= 0) return null
@@ -91,9 +92,12 @@ export default function Campaigns() {
if (loading) { if (loading) {
return ( return (
<div className="space-y-4 animate-pulse"> <div className="space-y-6">
<div className="h-12 bg-surface-tertiary rounded-xl"></div> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{[...Array(6)].map((_, i) => <SkeletonStatCard key={i} />)}
</div>
<SkeletonTable rows={5} cols={6} />
</div> </div>
) )
} }
@@ -137,7 +141,7 @@ export default function Campaigns() {
{/* Summary Cards */} {/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && ( {(totalBudget > 0 || totalSpent > 0) && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 stagger-children">
<div className="bg-white rounded-xl border border-border p-4"> <div className="bg-white rounded-xl border border-border p-4">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-500" /> <DollarSign className="w-4 h-4 text-blue-500" />

View File

@@ -89,7 +89,7 @@ export default function Finance() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* Top metrics */} {/* Top metrics */}
<div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4`}> <div className={`grid grid-cols-2 ${totalExpenses > 0 ? 'lg:grid-cols-6' : 'lg:grid-cols-5'} gap-4 stagger-children`}>
<FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" /> <FinanceStatCard icon={Wallet} label="Total Received" value={`${totalReceived.toLocaleString()} ${currencySymbol}`} color="text-blue-600" />
<FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" /> <FinanceStatCard icon={TrendingUp} label="Total Spent" value={`${totalSpent.toLocaleString()} ${currencySymbol}`} sub={`${spendPct.toFixed(1)}% of budget`} color="text-amber-600" />
{totalExpenses > 0 && ( {totalExpenses > 0 && (

473
client/src/pages/Issues.jsx Normal file
View File

@@ -0,0 +1,473 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import IssueDetailPanel from '../components/IssueDetailPanel'
import IssueCard from '../components/IssueCard'
import EmptyState from '../components/EmptyState'
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
]
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
}
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
export default function Issues() {
const { t } = useLanguage()
const toast = useToast()
const { brands } = useContext(AppContext)
const [issues, setIssues] = useState([])
const [counts, setCounts] = useState({})
const [loading, setLoading] = useState(true)
const [selectedIssue, setSelectedIssue] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '' })
const [categories, setCategories] = useState([])
const [teamMembers, setTeamMembers] = useState([])
// View mode
const [viewMode, setViewMode] = useState('board')
// Drag and drop
const [draggedIssue, setDraggedIssue] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
// List sorting
const [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState('desc')
useEffect(() => { loadData() }, [])
const loadData = async () => {
try {
setLoading(true)
const [issuesData, categoriesData, teamData] = await Promise.all([
api.get('/issues'),
api.get('/issues/categories'),
api.get('/users/team'),
])
setIssues(issuesData.issues || [])
setCounts(issuesData.counts || {})
setCategories(categoriesData || [])
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
} catch (err) {
console.error('Failed to load issues:', err)
} finally {
setLoading(false)
}
}
// Filtering
const filteredIssues = useMemo(() => {
let filtered = [...issues]
if (searchTerm) {
const term = searchTerm.toLowerCase()
filtered = filtered.filter(i =>
i.title?.toLowerCase().includes(term) ||
i.submitter_name?.toLowerCase().includes(term) ||
i.submitter_email?.toLowerCase().includes(term) ||
i.category?.toLowerCase().includes(term)
)
}
if (filters.status) filtered = filtered.filter(i => i.status === filters.status)
if (filters.category) filtered = filtered.filter(i => i.category === filters.category)
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
if (filters.brand) filtered = filtered.filter(i => String(i.brand_id) === String(filters.brand))
return filtered
}, [issues, searchTerm, filters])
// List sorting
const sortedIssues = useMemo(() => {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
const statusOrder = { new: 0, acknowledged: 1, in_progress: 2, resolved: 3, declined: 4 }
return [...filteredIssues].sort((a, b) => {
let cmp = 0
if (sortBy === 'created_at') {
cmp = (a.created_at || a.CreatedAt || '').localeCompare(b.created_at || b.CreatedAt || '')
} else if (sortBy === 'title') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'priority') {
cmp = (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2)
} else if (sortBy === 'status') {
cmp = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0)
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filteredIssues, sortBy, sortDir])
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
const clearFilters = () => {
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
setSearchTerm('')
}
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
const getAssigneeName = (assignedToId) => {
if (!assignedToId) return '—'
const member = teamMembers.find(m => m.id === assignedToId || m._id === assignedToId)
return member?.name || '—'
}
const formatDate = (dateStr) => {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Drag and drop handlers
const handleMoveIssue = async (issueId, newStatus) => {
try {
await api.patch(`/issues/${issueId}`, { status: newStatus })
toast.success(t('issues.statusUpdated'))
loadData()
} catch (err) {
console.error('Move issue failed:', err)
toast.error('Failed to update status')
}
}
const handleDragStart = (e, issue) => {
setDraggedIssue(issue)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedIssue(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedIssue && draggedIssue.status !== colStatus) {
handleMoveIssue(draggedIssue.Id || draggedIssue.id, colStatus)
}
setDraggedIssue(null)
}
const toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(col); setSortDir('asc') }
}
const SortIcon = ({ col }) => {
if (sortBy !== col) return null
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
}
if (loading) {
return (
<div className="space-y-4">
{viewMode === 'board' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={5} />}
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
Issues
</h1>
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
</div>
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
{[
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
{ mode: 'list', icon: List, label: t('issues.list') },
].map(({ mode, icon: Icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Status Counts */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
<div
key={status}
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
filters.status === status ? 'border-brand-primary ring-1 ring-brand-primary/20' : 'border-border'
}`}
onClick={() => updateFilter('status', filters.status === status ? '' : status)}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${config.dot}`}></span>
<span className="text-xs font-medium text-text-tertiary uppercase">{config.label}</span>
</div>
<div className="text-2xl font-bold text-text-primary">{counts[status] || 0}</div>
</div>
))}
</div>
{/* Search & Filters - always visible inline */}
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search issues..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/>
</div>
<select
value={filters.status}
onChange={e => updateFilter('status', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Statuses</option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</select>
<select
value={filters.category}
onChange={e => updateFilter('category', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Categories</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<select
value={filters.type}
onChange={e => updateFilter('type', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Types</option>
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
<select
value={filters.brand || ''}
onChange={e => updateFilter('brand', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Brands</option>
{(brands || []).map(b => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
<select
value={filters.priority}
onChange={e => updateFilter('priority', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Priorities</option>
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
Clear All
</button>
)}
</div>
{/* Board View */}
{viewMode === 'board' && (
filteredIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
/>
) : (
<div className="flex gap-4 overflow-x-auto pb-4">
{STATUS_ORDER.map(status => {
const config = STATUS_CONFIG[status]
const columnIssues = filteredIssues.filter(i => i.status === status)
return (
<div
key={status}
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
}`}
onDragOver={e => handleDragOver(e, status)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, status)}
>
{/* Column header */}
<div className="px-3 py-3 border-b border-border">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
<span className="text-sm font-semibold text-text-primary">{config.label}</span>
<span className="text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded-full font-medium">
{columnIssues.length}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[120px]">
{columnIssues.length === 0 ? (
<div className="text-center py-6 text-xs text-text-tertiary">
{t('issues.noIssuesInColumn')}
</div>
) : (
columnIssues.map(issue => (
<div
key={issue.Id || issue.id}
draggable
onDragStart={e => handleDragStart(e, issue)}
onDragEnd={handleDragEnd}
>
<IssueCard issue={issue} onClick={setSelectedIssue} />
</div>
))
)}
</div>
</div>
)
})}
</div>
)
)}
{/* List View */}
{viewMode === 'list' && (
sortedIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
/>
) : (
<div className="bg-surface rounded-lg border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-secondary border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
Title <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
Priority <SortIcon col="priority" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
Status <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
Created <SortIcon col="created_at" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedIssues.map(issue => {
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
return (
<tr
key={issue.Id || issue.id}
onClick={() => setSelectedIssue(issue)}
className="hover:bg-surface-secondary cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-sm font-medium text-text-primary">{issue.title}</td>
<td className="px-4 py-3 text-sm text-text-secondary">
<div>{issue.submitter_name}</div>
<div className="text-xs text-text-tertiary">{issue.submitter_email}</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{issue.brand_name || issue.brandName || '—'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
</span>
</td>
<td className="px-4 py-3 text-sm">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${priorityConfig.bg} ${priorityConfig.text}`}>
{priorityConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 w-fit ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{getAssigneeName(issue.assigned_to_id)}</td>
<td className="px-4 py-3 text-sm text-text-tertiary">{formatDate(issue.created_at)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
)}
{/* Detail Panel */}
{selectedIssue && (
<IssueDetailPanel
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onUpdate={loadData}
teamMembers={teamMembers}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,300 @@
import { useState, useEffect, useContext } from 'react'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import PostDetailPanel from '../components/PostDetailPanel'
import { SkeletonCalendar } from '../components/SkeletonLoader'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
in_review: 'bg-amber-100 text-amber-700',
approved: 'bg-blue-100 text-blue-700',
scheduled: 'bg-purple-100 text-purple-700',
published: 'bg-emerald-100 text-emerald-700',
rejected: 'bg-red-100 text-red-700',
}
function getMonthData(year, month) {
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const prevDays = new Date(year, month, 0).getDate()
const cells = []
// Previous month trailing days
for (let i = firstDay - 1; i >= 0; i--) {
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
}
// Current month
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ day: d, current: true, date: new Date(year, month, d) })
}
// Next month leading days
const remaining = 42 - cells.length
for (let d = 1; d <= remaining; d++) {
cells.push({ day: d, current: false, date: new Date(year, month + 1, d) })
}
return cells
}
function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export default function PostCalendar() {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
const [selectedPost, setSelectedPost] = useState(null)
useEffect(() => {
loadPosts()
}, [])
const loadPosts = async () => {
try {
const res = await api.get('/posts')
setPosts(res.data || res || [])
} catch (err) {
console.error('Failed to load posts:', err)
} finally {
setLoading(false)
}
}
const cells = getMonthData(year, month)
const todayKey = dateKey(today)
// Filter posts
const filteredPosts = posts.filter(p => {
if (filters.brand && String(p.brand_id || p.brandId) !== filters.brand) return false
if (filters.platform) {
const platforms = p.platforms || (p.platform ? [p.platform] : [])
if (!platforms.includes(filters.platform)) return false
}
if (filters.status && p.status !== filters.status) return false
return true
})
// Group posts by date (use scheduled_date or published_date)
const postsByDate = {}
const unscheduled = []
for (const post of filteredPosts) {
const dateStr = post.scheduled_date || post.scheduledDate || post.published_date || post.publishedDate
if (dateStr) {
const key = dateStr.slice(0, 10) // yyyy-mm-dd
if (!postsByDate[key]) postsByDate[key] = []
postsByDate[key].push(post)
} else {
unscheduled.push(post)
}
}
const prevMonth = () => {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
const handlePostClick = (post) => {
setSelectedPost(post)
}
const handlePanelClose = () => {
setSelectedPost(null)
loadPosts()
}
if (loading) {
return (
<div className="space-y-4">
<SkeletonCalendar />
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">All Brands</option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
<select
value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">All Platforms</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filters.status}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="in_review">In Review</option>
<option value="approved">Approved</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
<option value="rejected">Rejected</option>
</select>
</div>
{/* Calendar */}
<div className="bg-surface rounded-xl border border-border overflow-hidden">
{/* Nav */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
<ChevronRight className="w-5 h-5" />
</button>
</div>
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
Today
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{DAYS.map(d => (
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{d}
</div>
))}
</div>
{/* Cells */}
<div className="grid grid-cols-7">
{cells.map((cell, i) => {
const key = dateKey(cell.date)
const isToday = key === todayKey
const dayPosts = postsByDate[key] || []
return (
<div
key={i}
className={`border-r border-b border-border min-h-[110px] p-2 ${
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
>
<div className={`text-sm font-semibold mb-1 w-7 h-7 flex items-center justify-center rounded-full ${
isToday ? 'bg-brand-primary text-white' : cell.current ? 'text-text-primary' : 'text-text-tertiary'
}`}>
{cell.day}
</div>
<div className="space-y-1">
{dayPosts.slice(0, 3).map(post => (
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
}`}
title={post.title}
>
{post.title}
</button>
))}
{dayPosts.length > 3 && (
<div className="text-[9px] text-text-tertiary text-center font-medium">
+{dayPosts.length - 3} more
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Unscheduled Posts */}
{unscheduled.length > 0 && (
<div className="bg-surface rounded-xl border border-border p-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unscheduled.map(post => (
<button
key={post.Id || post._id}
onClick={() => handlePostClick(post)}
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{post.status}
</span>
</div>
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
{post.brand_name && (
<p className="text-xs text-text-tertiary mt-1">{post.brand_name}</p>
)}
</button>
))}
</div>
</div>
)}
{/* Legend */}
<div className="bg-surface rounded-xl border border-border p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
<div className="flex flex-wrap gap-3">
{Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${color}`}></div>
<span className="text-xs text-text-secondary capitalize">{status.replace('_', ' ')}</span>
</div>
))}
</div>
</div>
{/* Detail Panel */}
{selectedPost && (
<PostDetailPanel
post={selectedPost}
onClose={handlePanelClose}
onSave={async (postId, data) => {
await api.patch(`/posts/${postId}`, data)
handlePanelClose()
}}
onDelete={async (postId) => {
await api.delete(`/posts/${postId}`)
handlePanelClose()
}}
/>
)}
</div>
)
}

View File

@@ -135,11 +135,12 @@ export default function PostProduction() {
/> />
</div> </div>
<div data-tutorial="filters" className="flex gap-3"> <div data-tutorial="filters" className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<select <select
value={filters.brand} value={filters.brand}
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))} onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allBrands')}</option> <option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)} {brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
@@ -148,7 +149,7 @@ export default function PostProduction() {
<select <select
value={filters.platform} value={filters.platform}
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))} onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPlatforms')}</option> <option value="">{t('posts.allPlatforms')}</option>
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
@@ -157,12 +158,14 @@ export default function PostProduction() {
<select <select
value={filters.assignedTo} value={filters.assignedTo}
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))} onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
> >
<option value="">{t('posts.allPeople')}</option> <option value="">{t('posts.allPeople')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)} {teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select> </select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<DatePresetPicker <DatePresetPicker
activePreset={activePreset} activePreset={activePreset}
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }} onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
@@ -175,7 +178,7 @@ export default function PostProduction() {
value={filters.periodFrom} value={filters.periodFrom}
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
title={t('posts.periodFrom')} title={t('posts.periodFrom')}
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
<span className="text-xs text-text-tertiary"></span> <span className="text-xs text-text-tertiary"></span>
<input <input
@@ -183,9 +186,10 @@ export default function PostProduction() {
value={filters.periodTo} value={filters.periodTo}
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }} onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
title={t('posts.periodTo')} title={t('posts.periodTo')}
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/> />
</div> </div>
</div>
</div> </div>
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto"> <div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">

View File

@@ -7,6 +7,7 @@ import { useAuth } from '../contexts/AuthContext'
import ProjectCard from '../components/ProjectCard' import ProjectCard from '../components/ProjectCard'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import InteractiveTimeline from '../components/InteractiveTimeline' import InteractiveTimeline from '../components/InteractiveTimeline'
import { SkeletonCard } from '../components/SkeletonLoader'
const EMPTY_PROJECT = { const EMPTY_PROJECT = {
name: '', description: '', brand_id: '', status: 'active', name: '', description: '', brand_id: '', status: 'active',
@@ -64,10 +65,10 @@ export default function Projects() {
if (loading) { if (loading) {
return ( return (
<div className="animate-pulse"> <div className="space-y-4">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => <div key={i} className="h-56 bg-surface-tertiary rounded-xl"></div>)} {[...Array(6)].map((_, i) => <SkeletonCard key={i} />)}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,337 @@
import { useState } from 'react'
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
import { api } from '../utils/api'
import FormInput from '../components/FormInput'
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
]
const PRIORITY_OPTIONS = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' },
]
export default function PublicIssueSubmit() {
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
category: 'Marketing',
type: 'request',
priority: 'medium',
title: '',
description: '',
})
const [file, setFile] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [trackingToken, setTrackingToken] = useState('')
const [errors, setErrors] = useState({})
const updateForm = (field, value) => {
setForm((f) => ({ ...f, [field]: value }))
if (errors[field]) {
setErrors((e) => ({ ...e, [field]: '' }))
}
}
const handleFileChange = (e) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
}
}
const validate = () => {
const newErrors = {}
if (!form.name.trim()) newErrors.name = 'Name is required'
if (!form.email.trim()) newErrors.email = 'Email is required'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = 'Invalid email address'
if (!form.title.trim()) newErrors.title = 'Title is required'
if (!form.description.trim()) newErrors.description = 'Description is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!validate() || submitting) return
try {
setSubmitting(true)
const formData = new FormData()
formData.append('name', form.name)
formData.append('email', form.email)
formData.append('phone', form.phone)
formData.append('category', form.category)
formData.append('type', form.type)
formData.append('priority', form.priority)
formData.append('title', form.title)
formData.append('description', form.description)
if (file) {
formData.append('file', file)
}
const result = await api.upload('/public/issues', formData)
setTrackingToken(result.token)
setSubmitted(true)
} catch (err) {
console.error('Submit error:', err)
alert('Failed to submit issue. Please try again.')
} finally {
setSubmitting(false)
}
}
if (submitted) {
const trackingUrl = `${window.location.origin}/track/${trackingToken}`
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
<div className="text-center">
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Submitted Successfully!</h1>
<p className="text-sm text-text-tertiary mb-6">
Thank you for submitting your issue. You can track its progress using the link below.
</p>
<div className="bg-surface-secondary rounded-lg p-4 mb-6">
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">Your Tracking Link</label>
<div className="flex gap-2">
<input
type="text"
value={trackingUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
/>
<button
onClick={() => {
navigator.clipboard.writeText(trackingUrl)
alert('Copied to clipboard!')
}}
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Copy
</button>
</div>
</div>
<div className="space-y-2">
<a
href={`/track/${trackingToken}`}
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Track Your Issue
</a>
<button
onClick={() => {
setSubmitted(false)
setTrackingToken('')
setForm({
name: '',
email: '',
phone: '',
category: 'Marketing',
type: 'request',
priority: 'medium',
title: '',
description: '',
})
setFile(null)
}}
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors"
>
Submit Another Issue
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
{/* Header */}
<div className="text-center mb-6">
<div className="w-12 h-12 bg-brand-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
<AlertCircle className="w-6 h-6 text-brand-primary" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Submit an Issue</h1>
<p className="text-sm text-text-tertiary">
Report issues, request corrections, or make suggestions. We'll track your submission and keep you updated.
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Contact Information */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Your Information</h2>
<div className="space-y-3">
<FormInput
label="Name"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
placeholder="Your full name"
required
error={errors.name}
/>
<FormInput
label="Email"
type="email"
value={form.email}
onChange={(e) => updateForm('email', e.target.value)}
placeholder="your.email@example.com"
required
error={errors.email}
/>
<FormInput
label="Phone (Optional)"
value={form.phone}
onChange={(e) => updateForm('phone', e.target.value)}
placeholder="+966 5X XXX XXXX"
/>
</div>
</div>
{/* Issue Details */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Category <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.category}
onChange={(e) => updateForm('category', e.target.value)}
placeholder="e.g., Marketing, IT, Operations"
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Type <span className="text-red-500">*</span>
</label>
<select
value={form.type}
onChange={(e) => updateForm('type', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1.5">
Priority <span className="text-red-500">*</span>
</label>
<select
value={form.priority}
onChange={(e) => updateForm('priority', e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<FormInput
label="Title"
value={form.title}
onChange={(e) => updateForm('title', e.target.value)}
placeholder="Brief summary of the issue"
required
error={errors.title}
/>
<FormInput
label="Description"
type="textarea"
value={form.description}
onChange={(e) => updateForm('description', e.target.value)}
placeholder="Provide detailed information about the issue..."
rows={6}
required
error={errors.description}
/>
</div>
</div>
{/* File Upload */}
<div>
<h2 className="text-sm font-semibold text-text-primary mb-2">Attachment (Optional)</h2>
<label className="block cursor-pointer">
<input type="file" onChange={handleFileChange} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:bg-surface-secondary/50 transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
{file ? (
<div className="flex items-center justify-center gap-2">
<p className="text-sm text-text-primary font-medium">{file.name}</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setFile(null)
}}
className="p-1 hover:bg-surface-tertiary rounded"
>
<X className="w-4 h-4 text-red-600" />
</button>
</div>
) : (
<p className="text-sm text-text-tertiary">Click to upload a file (screenshots, documents, etc.)</p>
)}
</div>
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{submitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Submitting...
</>
) : (
<>
<Send className="w-5 h-5" />
Submit Issue
</>
)}
</button>
</form>
{/* Footer Note */}
<p className="text-xs text-text-tertiary text-center mt-4">
You'll receive a tracking link to monitor the progress of your issue.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,361 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
import { api } from '../utils/api'
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
declined: { label: 'Declined', bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
}
const PRIORITY_CONFIG = {
low: { label: 'Low', color: 'text-gray-700' },
medium: { label: 'Medium', color: 'text-blue-700' },
high: { label: 'High', color: 'text-orange-700' },
urgent: { label: 'Urgent', color: 'text-red-700' },
}
export default function PublicIssueTracker() {
const { token } = useParams()
const [issue, setIssue] = useState(null)
const [updates, setUpdates] = useState([])
const [attachments, setAttachments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Comment form
const [commentName, setCommentName] = useState('')
const [commentMessage, setCommentMessage] = useState('')
const [submittingComment, setSubmittingComment] = useState(false)
// File upload
const [uploadingFile, setUploadingFile] = useState(false)
useEffect(() => {
loadIssue()
}, [token])
const loadIssue = async () => {
try {
setLoading(true)
setError(null)
const data = await api.get(`/public/issues/${token}`)
setIssue(data.issue)
setUpdates(data.updates || [])
setAttachments(data.attachments || [])
} catch (err) {
console.error('Failed to load issue:', err)
setError(err.response?.status === 404 ? 'Issue not found' : 'Failed to load issue')
} finally {
setLoading(false)
}
}
const handleAddComment = async (e) => {
e.preventDefault()
if (!commentMessage.trim() || submittingComment) return
try {
setSubmittingComment(true)
await api.post(`/public/issues/${token}/comment`, {
name: commentName.trim() || 'Anonymous',
message: commentMessage,
})
setCommentMessage('')
await loadIssue()
} catch (err) {
console.error('Failed to add comment:', err)
alert('Failed to add comment')
} finally {
setSubmittingComment(false)
}
}
const handleFileUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
setUploadingFile(true)
const formData = new FormData()
formData.append('file', file)
formData.append('name', commentName.trim() || 'Anonymous')
await api.upload(`/public/issues/${token}/attachments`, formData)
await loadIssue()
e.target.value = '' // Reset input
} catch (err) {
console.error('Failed to upload file:', err)
alert('Failed to upload file')
} finally {
setUploadingFile(false)
}
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
}
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
if (loading) {
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4">
<div className="max-w-2xl mx-auto space-y-6 animate-pulse">
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
<div className="h-5 bg-surface-tertiary rounded w-24"></div>
<div className="h-7 bg-surface-tertiary rounded w-3/4"></div>
<div className="h-4 bg-surface-tertiary rounded w-full"></div>
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
</div>
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
<div className="h-5 bg-surface-tertiary rounded w-40"></div>
<div className="h-20 bg-surface-tertiary rounded"></div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
<div className="max-w-md mx-auto bg-surface rounded-xl border border-border p-6 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<XCircle className="w-10 h-10 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Not Found</h1>
<p className="text-sm text-text-tertiary mb-6">
The tracking link you used is invalid or the issue has been removed.
</p>
<a
href="/submit-issue"
className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
>
Submit a New Issue
</a>
</div>
</div>
)
}
if (!issue) return null
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
const StatusIcon = statusConfig.icon
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
return (
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
</span>
<span className={`text-xs font-medium ${priorityConfig.color}`}>
{priorityConfig.label} Priority
</span>
<span className="text-xs text-text-tertiary"></span>
<span className="text-xs text-text-tertiary capitalize">{issue.type}</span>
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">{issue.title}</h1>
</div>
<div className="mb-4">
<h2 className="text-sm font-semibold text-text-primary mb-2">Description</h2>
<p className="text-sm text-text-tertiary whitespace-pre-wrap">{issue.description}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm pt-4 border-t border-border">
<div>
<span className="text-text-tertiary">Submitted:</span>
<span className="text-text-primary font-medium ml-2">{formatDate(issue.created_at)}</span>
</div>
<div>
<span className="text-text-tertiary">Last Updated:</span>
<span className="text-text-primary font-medium ml-2">{formatDate(issue.updated_at)}</span>
</div>
</div>
</div>
{/* Resolution Summary */}
{(issue.status === 'resolved' || issue.status === 'declined') && issue.resolution_summary && (
<div className={`rounded-2xl shadow-sm p-6 mb-6 ${issue.status === 'resolved' ? 'bg-emerald-50 border-2 border-emerald-200' : 'bg-gray-50 border-2 border-gray-200'}`}>
<div className="flex items-start gap-3">
{issue.status === 'resolved' ? (
<CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
) : (
<XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />
)}
<div className="flex-1">
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
{issue.status === 'resolved' ? 'Resolution' : 'Declined'}
</h2>
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
{issue.resolution_summary}
</p>
{issue.resolved_at && (
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
{formatDate(issue.resolved_at)}
</p>
)}
</div>
</div>
</div>
)}
{/* Progress Timeline */}
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<MessageCircle className="w-6 h-6" />
Progress Updates
</h2>
{updates.length === 0 ? (
<div className="text-center py-12">
<Clock className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
<p className="text-text-secondary">No updates yet. We'll post updates here as we work on your issue.</p>
</div>
) : (
<div className="space-y-4">
{updates.map((update, idx) => (
<div key={update.Id || update.id || idx} className="border-l-4 border-brand-primary pl-4 py-2">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-text-primary">{update.author_name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
{update.author_type === 'staff' ? 'Team' : 'You'}
</span>
</div>
<span className="text-sm text-text-tertiary">{formatDateTime(update.created_at)}</span>
</div>
<p className="text-text-secondary whitespace-pre-wrap">{update.message}</p>
</div>
))}
</div>
)}
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<FileText className="w-6 h-6" />
Attachments
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{attachments.map((att) => (
<div key={att.Id || att.id} className="flex items-center justify-between p-3 bg-surface-secondary rounded-lg">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
<p className="text-xs text-text-tertiary">
{formatFileSize(att.size)} • {att.uploaded_by}
</p>
</div>
</div>
<a
href={`/api/uploads/${att.filename}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-primary hover:underline ml-2"
>
Download
</a>
</div>
))}
</div>
</div>
)}
{/* Add Comment Section */}
{issue.status !== 'resolved' && issue.status !== 'declined' && (
<div className="bg-surface rounded-2xl shadow-sm p-8">
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
<MessageCircle className="w-6 h-6" />
Add a Comment
</h2>
<form onSubmit={handleAddComment} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Your Name (Optional)</label>
<input
type="text"
value={commentName}
onChange={(e) => setCommentName(e.target.value)}
placeholder="Your name"
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
Message <span className="text-red-500">*</span>
</label>
<textarea
value={commentMessage}
onChange={(e) => setCommentMessage(e.target.value)}
placeholder="Add additional information or ask a question..."
rows={4}
required
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={!commentMessage.trim() || submittingComment}
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
{submittingComment ? 'Sending...' : 'Send Comment'}
</button>
<label className="cursor-pointer">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="px-6 py-3 bg-surface-secondary text-text-primary rounded-lg font-medium hover:bg-surface-tertiary transition-colors flex items-center gap-2">
<Upload className="w-4 h-4" />
{uploadingFile ? 'Uploading...' : 'Upload File'}
</div>
</label>
</div>
</form>
</div>
)}
{/* Footer */}
<div className="text-center mt-8">
<p className="text-sm text-text-tertiary">
Bookmark this page to check your issue status anytime.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,478 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe } from 'lucide-react'
const STATUS_ICONS = {
copy: FileText,
design: ImageIcon,
video: Film,
other: Sparkles,
}
export default function PublicReview() {
const { token } = useParams()
const [artefact, setArtefact] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
const [selectedLanguage, setSelectedLanguage] = useState(0)
useEffect(() => {
loadArtefact()
}, [token])
const loadArtefact = async () => {
try {
const res = await fetch(`/api/public/review/${token}`)
if (!res.ok) {
const err = await res.json()
setError(err.error || 'Failed to load artefact')
setLoading(false)
return
}
const data = await res.json()
setArtefact(data)
} catch (err) {
setError('Failed to load artefact')
} finally {
setLoading(false)
}
}
const handleAction = async (action) => {
if (!reviewerName.trim()) {
alert('Please enter your name')
return
}
if (action === 'approve' && !confirm('Approve this artefact?')) return
if (action === 'reject' && !confirm('Reject this artefact?')) return
if (action === 'revision' && !feedback.trim()) {
alert('Please provide feedback for revision request')
return
}
setSubmitting(true)
try {
const res = await fetch(`/api/public/review/${token}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
approved_by_name: reviewerName,
feedback: feedback || undefined,
}),
})
if (!res.ok) {
const err = await res.json()
setError(err.error || 'Action failed')
setSubmitting(false)
return
}
const data = await res.json()
setSuccess(data.message || 'Action completed successfully')
setTimeout(() => {
loadArtefact()
}, 1500)
} catch (err) {
setError('Action failed')
} finally {
setSubmitting(false)
}
}
const extractDriveFileId = (url) => {
const patterns = [
/\/file\/d\/([^\/]+)/,
/id=([^&]+)/,
/\/d\/([^\/]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
const getDriveEmbedUrl = (url) => {
const fileId = extractDriveFileId(url)
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
}
if (loading) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
<div className="bg-surface rounded-2xl overflow-hidden">
<div className="h-24 bg-surface-tertiary"></div>
<div className="p-8 space-y-4">
<div className="h-6 bg-surface-tertiary rounded w-2/3"></div>
<div className="h-4 bg-surface-tertiary rounded w-1/2"></div>
<div className="h-32 bg-surface-tertiary rounded"></div>
</div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Review Not Available</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
)
}
if (success) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Thank You!</h2>
<p className="text-text-secondary">{success}</p>
</div>
</div>
)
}
if (!artefact) return null
const TypeIcon = STATUS_ICONS[artefact.type] || Sparkles
const isImage = (url) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
return (
<div className="min-h-screen bg-surface-secondary py-12 px-4">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Content Review</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
</div>
</div>
</div>
<div className="p-8">
{/* Artefact Info */}
<div className="flex items-start gap-4 mb-6">
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-6 h-6 text-brand-primary" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-text-primary mb-1">{artefact.title}</h2>
{artefact.description && (
<p className="text-text-secondary mb-2">{artefact.description}</p>
)}
<div className="flex items-center gap-3 text-sm text-text-tertiary">
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full capitalize">{artefact.type}</span>
{artefact.brand_name && <span> {artefact.brand_name}</span>}
{artefact.version_number && <span> Version {artefact.version_number}</span>}
</div>
</div>
</div>
{/* COPY TYPE: Multilingual Content */}
{artefact.type === 'copy' && artefact.texts && artefact.texts.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Content Languages</h3>
</div>
{/* Language tabs */}
<div className="flex items-center gap-2 mb-4 flex-wrap">
{artefact.texts.map((text, idx) => (
<button
key={idx}
onClick={() => setSelectedLanguage(idx)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
selectedLanguage === idx
? 'bg-brand-primary text-white shadow-sm'
: 'bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/70'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono">{text.language_code}</span>
<span className="text-sm">{text.language_label}</span>
</div>
</button>
))}
</div>
{/* Selected language content */}
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
{artefact.texts[selectedLanguage].language_label} Content
</div>
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
{artefact.texts[selectedLanguage].content}
</div>
</div>
</div>
)}
{/* Legacy content field (for backward compatibility) */}
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">Content</h3>
<div className="bg-surface-secondary rounded-xl p-4 border border-border">
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
{artefact.content}
</pre>
</div>
</div>
)}
{/* DESIGN TYPE: Image Gallery */}
{artefact.type === 'design' && artefact.attachments && artefact.attachments.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Design Files</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{artefact.attachments.map((att, idx) => (
<a
key={idx}
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm hover:shadow-sm"
>
<img
src={att.url}
alt={att.original_name || `Design ${idx + 1}`}
className="w-full h-64 object-cover"
/>
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
</div>
)}
</a>
))}
</div>
</div>
)}
{/* VIDEO TYPE: Video Player or Drive Embed */}
{artefact.type === 'video' && artefact.attachments && artefact.attachments.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Film className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">Videos</h3>
</div>
<div className="space-y-4">
{artefact.attachments.map((att, idx) => (
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
{att.drive_url ? (
<div>
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
<Globe className="w-4 h-4 text-text-tertiary" />
<span className="text-sm font-medium text-text-secondary">Google Drive Video</span>
</div>
<iframe
src={getDriveEmbedUrl(att.drive_url)}
className="w-full h-96"
allow="autoplay"
title={`Video ${idx + 1}`}
/>
</div>
) : (
<div>
{att.original_name && (
<div className="px-4 py-2 bg-surface border-b border-border">
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
</div>
)}
<video
src={att.url}
controls
className="w-full"
/>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* OTHER TYPE: Generic Attachments */}
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Attachments</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{artefact.attachments.map((att, idx) => (
<div key={idx}>
{isImage(att.url) ? (
<a
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border border-border hover:border-brand-primary transition-colors"
>
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover"
/>
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
</div>
</a>
) : (
<a
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors"
>
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
{att.size && (
<p className="text-xs text-text-tertiary">
{(att.size / 1024).toFixed(1)} KB
</p>
)}
</div>
</a>
)}
</div>
))}
</div>
</div>
)}
{/* Comments */}
{artefact.comments && artefact.comments.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Previous Comments</h3>
<div className="space-y-3">
{artefact.comments.map((comment, idx) => (
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary">
{comment.user_name || comment.author_name || 'Anonymous'}
</span>
{comment.CreatedAt && (
<span className="text-xs text-text-tertiary">
{new Date(comment.CreatedAt).toLocaleDateString()}
</span>
)}
</div>
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
</div>
))}
</div>
</div>
)}
{/* Review Form */}
{artefact.status === 'pending_review' && (
<div className="border-t border-border pt-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Your Review</h3>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Your Name *</label>
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
placeholder="Enter your name"
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Feedback (optional)</label>
<textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
rows={4}
placeholder="Share your thoughts, suggestions, or required changes..."
className="w-full px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<button
onClick={() => handleAction('approve')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<CheckCircle className="w-5 h-5" />
Approve
</button>
<button
onClick={() => handleAction('revision')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<AlertCircle className="w-5 h-5" />
Request Revision
</button>
<button
onClick={() => handleAction('reject')}
disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<XCircle className="w-5 h-5" />
Reject
</button>
</div>
</div>
)}
{/* Already Reviewed */}
{artefact.status !== 'pending_review' && (
<div className="border-t border-border pt-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
<p className="text-blue-900 font-medium">
This artefact has already been reviewed.
</p>
<p className="text-blue-700 text-sm mt-1">
Status: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
</p>
{artefact.approved_by_name && (
<p className="text-blue-700 text-sm mt-1">
Reviewed by: <span className="font-semibold">{artefact.approved_by_name}</span>
</p>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="text-center text-text-tertiary text-sm">
<p>Powered by Samaya Digital Hub</p>
</div>
</div>
</div>
)
}

View File

@@ -10,6 +10,7 @@ import TaskCalendarView from '../components/TaskCalendarView'
import DatePresetPicker from '../components/DatePresetPicker' import DatePresetPicker from '../components/DatePresetPicker'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import { format } from 'date-fns' import { format } from 'date-fns'
const VIEW_MODES = ['board', 'list', 'calendar'] const VIEW_MODES = ['board', 'list', 'calendar']
@@ -286,11 +287,9 @@ export default function Tasks() {
if (loading) { if (loading) {
return ( return (
<div className="animate-pulse"> <div className="space-y-4">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="grid grid-cols-3 gap-4"> {viewMode === 'list' ? <SkeletonTable rows={8} cols={6} /> : <SkeletonKanbanBoard />}
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { Plus, Shield, Edit2, Trash2, UserCheck } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { SkeletonTable } from '../components/SkeletonLoader'
const ROLES = [ const ROLES = [
{ value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' }, { value: 'superadmin', label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
@@ -109,9 +110,9 @@ export default function Users() {
if (loading) { if (loading) {
return ( return (
<div className="space-y-4 animate-pulse"> <div className="space-y-6">
<div className="h-12 bg-surface-tertiary rounded-xl"></div> <div className="h-12 bg-surface-tertiary rounded-xl animate-pulse"></div>
<div className="h-64 bg-surface-tertiary rounded-xl"></div> <SkeletonTable rows={5} cols={5} />
</div> </div>
) )
} }

3
server/app-settings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"uploadMaxSizeMB": 500
}

View File

@@ -9,13 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.7.0",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"express": "^4.21.0", "express": "^4.21.0",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1"
},
"optionalDependencies": {
"better-sqlite3": "^12.6.2"
} }
}, },
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
@@ -260,14 +262,18 @@
} }
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
} }
}, },
"node_modules/bindings": { "node_modules/bindings": {

View File

@@ -9,12 +9,14 @@
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^11.7.0",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"express": "^4.21.0", "express": "^4.21.0",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1"
},
"optionalDependencies": {
"better-sqlite3": "^12.6.2"
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -0,0 +1 @@
test file content

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB