diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d3d12ea --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/DEPLOYMENT_PLAN.md b/DEPLOYMENT_PLAN.md new file mode 100644 index 0000000..11cf1a2 --- /dev/null +++ b/DEPLOYMENT_PLAN.md @@ -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 +``` + +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= +NOCODB_BASE_ID= +SESSION_SECRET= +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 \ + --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 diff --git a/VERSIONING_IMPLEMENTATION.md b/VERSIONING_IMPLEMENTATION.md new file mode 100644 index 0000000..9d780a4 --- /dev/null +++ b/VERSIONING_IMPLEMENTATION.md @@ -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 diff --git a/client/src/App.jsx b/client/src/App.jsx index 32b7aa3..6a9b8d6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -19,6 +19,12 @@ import Users from './pages/Users' import Settings from './pages/Settings' import Brands from './pages/Brands' 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 Modal from './components/Modal' import { api } from './utils/api' @@ -268,10 +274,15 @@ function AppContent() { : } /> + } /> + } /> + } /> : }> } /> {hasModule('marketing') && <> } /> + } /> + } /> } /> } /> } /> @@ -286,6 +297,7 @@ function AppContent() { } /> } /> } + {hasModule('issues') && } />} } /> } /> {user?.role === 'superadmin' && ( diff --git a/client/src/components/ArtefactVersionTimeline.jsx b/client/src/components/ArtefactVersionTimeline.jsx new file mode 100644 index 0000000..9f715d0 --- /dev/null +++ b/client/src/components/ArtefactVersionTimeline.jsx @@ -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 ( +
+ No versions found +
+ ) + } + + return ( +
+ {versions.map((version, idx) => { + const isActive = version.Id === activeVersionId + const isLatest = idx === versions.length - 1 + + return ( + + ) + })} +
+ ) +} diff --git a/client/src/components/CollapsibleSection.jsx b/client/src/components/CollapsibleSection.jsx index b4db82a..d381a52 100644 --- a/client/src/components/CollapsibleSection.jsx +++ b/client/src/components/CollapsibleSection.jsx @@ -14,7 +14,11 @@ export default function CollapsibleSection({ title, defaultOpen = true, badge, c {title} {badge} - {open && children} +
+
+ {children} +
+
) } diff --git a/client/src/components/IssueCard.jsx b/client/src/components/IssueCard.jsx new file mode 100644 index 0000000..2bf0680 --- /dev/null +++ b/client/src/components/IssueCard.jsx @@ -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 ( +
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 */} +
+ +

{issue.title}

+
+ + {/* Metadata */} +
+ {issue.category && ( + + {issue.category} + + )} + {issue.brand_name && ( + + {issue.brand_name} + + )} +
+ + {/* Footer */} +
+ {issue.submitter_name} + {formatDate(issue.created_at || issue.CreatedAt)} +
+
+ ) +} diff --git a/client/src/components/IssueDetailPanel.jsx b/client/src/components/IssueDetailPanel.jsx new file mode 100644 index 0000000..998d7d7 --- /dev/null +++ b/client/src/components/IssueDetailPanel.jsx @@ -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 ( + +
+
+
+
+ ) + } + + const statusConfig = STATUS_CONFIG[issueData.status] || STATUS_CONFIG.new + const priorityConfig = PRIORITY_CONFIG[issueData.priority] || PRIORITY_CONFIG.medium + + return ( + <> + +
+

{issueData.title}

+ +
+
+ + + {statusConfig.label} + + + {priorityConfig.label} + + + {issueData.type} + + + {issueData.category} + + {issueData.brand_name && ( + + {issueData.brand_name} + + )} +
+ + } + > +
+ {/* Submitter Info */} +
+

Submitter Information

+
+
Name: {issueData.submitter_name}
+
Email: {issueData.submitter_email}
+ {issueData.submitter_phone && ( +
Phone: {issueData.submitter_phone}
+ )} +
Submitted: {formatDate(issueData.created_at)}
+
+
+ + {/* Description */} +
+

Description

+

{issueData.description || 'No description provided'}

+
+ + {/* Assigned To */} +
+ + +
+ + {/* Brand */} +
+ + +
+ + {/* Internal Notes */} +
+ +