updates
79
CLAUDE.md
Normal 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
@@ -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
|
||||||
281
VERSIONING_IMPLEMENTATION.md
Normal 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
|
||||||
@@ -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' && (
|
||||||
|
|||||||
96
client/src/components/ArtefactVersionTimeline.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
56
client/src/components/IssueCard.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
584
client/src/components/IssueDetailPanel.jsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "العنوان أ-ي"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
1523
client/src/pages/Artefacts.jsx
Normal 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} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
client/src/pages/PostCalendar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
337
client/src/pages/PublicIssueSubmit.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
361
client/src/pages/PublicIssueTracker.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
478
client/src/pages/PublicReview.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"uploadMaxSizeMB": 500
|
||||||
|
}
|
||||||
14
server/package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1322
server/server.js
BIN
server/uploads/1771072566241-33864599.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
server/uploads/1771072739201-969303698.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
server/uploads/1771072759898-135641761.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
server/uploads/1771072934623-34184745.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
server/uploads/1771143382819-767971608.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
server/uploads/1771487191141-29566848.pdf
Normal file
BIN
server/uploads/1771487640763-189466186.pdf
Normal file
BIN
server/uploads/1771488197047-971041817.pdf
Normal file
BIN
server/uploads/1771488763920-308345247.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
1
server/uploads/1771489106763-795577997.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test file content
|
||||||
BIN
server/uploads/1771489251343-830971694.jpeg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
server/uploads/1771489807903-473494637.pdf
Normal file
BIN
server/uploads/1771507573241-596517029.png
Normal file
|
After Width: | Height: | Size: 57 KiB |