implement collapsible sidebar and add asset management system with modals and hooks
This commit is contained in:
parent
aef4a3ec8b
commit
19601f2ae7
6
.gitignore
vendored
6
.gitignore
vendored
@ -24,4 +24,8 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env
|
.env
|
||||||
uploads/*
|
uploads/*
|
||||||
|
.github/*
|
||||||
|
.next/*
|
||||||
|
.env.local
|
||||||
|
.vite/*
|
||||||
360
Channel-Frontend/frontend.md
Normal file
360
Channel-Frontend/frontend.md
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
# Frontend Refactoring & UI Standardization Prompt
|
||||||
|
|
||||||
|
Act as a world-class Senior Frontend Architect, UI Engineer, and Design System expert. Your objective is to refactor and standardize the entire frontend application to production-grade quality by following industry best practices, scalable architecture, and clean code principles.
|
||||||
|
|
||||||
|
## Primary Goal
|
||||||
|
|
||||||
|
Transform the existing application into a clean, maintainable, reusable, responsive, and highly consistent frontend without changing any business logic or existing functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Frontend Architecture
|
||||||
|
|
||||||
|
Implement a scalable frontend architecture by following feature-based organization.
|
||||||
|
|
||||||
|
- Organize code into reusable modules.
|
||||||
|
- Separate business logic from UI.
|
||||||
|
- Keep components focused on a single responsibility.
|
||||||
|
- Avoid duplicated logic throughout the application.
|
||||||
|
- Create a clear folder structure.
|
||||||
|
- Use shared utilities wherever possible.
|
||||||
|
- Follow feature-first architecture instead of dumping everything into common folders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Code Standards
|
||||||
|
|
||||||
|
Enforce strict coding standards throughout the project.
|
||||||
|
|
||||||
|
- No component should exceed **500 lines of code**.
|
||||||
|
- Prefer keeping components between **150–300 lines** whenever possible.
|
||||||
|
- Split large pages into smaller reusable components.
|
||||||
|
- Extract repeated JSX into reusable components.
|
||||||
|
- Extract repeated logic into custom hooks.
|
||||||
|
- Move API logic into service files.
|
||||||
|
- Move constants into dedicated constant files.
|
||||||
|
- Move validation into separate files.
|
||||||
|
- Move helper functions into utility files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. DRY Principles
|
||||||
|
|
||||||
|
Strictly follow the DRY (Don't Repeat Yourself) principle.
|
||||||
|
|
||||||
|
- Remove duplicated UI.
|
||||||
|
- Remove duplicated styles.
|
||||||
|
- Remove duplicated business logic.
|
||||||
|
- Remove duplicated API calls.
|
||||||
|
- Reuse existing components instead of recreating them.
|
||||||
|
- Centralize repeated logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Reusable Component Library
|
||||||
|
|
||||||
|
Create reusable components for all common UI patterns.
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
- Button
|
||||||
|
- Input
|
||||||
|
- Textarea
|
||||||
|
- Select
|
||||||
|
- Multi Select
|
||||||
|
- Search Input
|
||||||
|
- Date Picker
|
||||||
|
- Combobox
|
||||||
|
- Modal
|
||||||
|
- Drawer
|
||||||
|
- Dialog
|
||||||
|
- Confirmation Dialog
|
||||||
|
- Table
|
||||||
|
- Pagination
|
||||||
|
- Badge
|
||||||
|
- Card
|
||||||
|
- Empty State
|
||||||
|
- Loading State
|
||||||
|
- Skeleton
|
||||||
|
- Avatar
|
||||||
|
- Tooltip
|
||||||
|
- Popover
|
||||||
|
- Dropdown
|
||||||
|
- Tabs
|
||||||
|
- Accordion
|
||||||
|
- Alert
|
||||||
|
- Toast
|
||||||
|
- Breadcrumb
|
||||||
|
- Status Badge
|
||||||
|
- Section Header
|
||||||
|
- Form Field Wrapper
|
||||||
|
- Page Header
|
||||||
|
- Filter Panel
|
||||||
|
- Search Bar
|
||||||
|
- Action Buttons
|
||||||
|
- Data Cards
|
||||||
|
|
||||||
|
Every page should utilize these common components instead of creating new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Typography System
|
||||||
|
|
||||||
|
Create a consistent typography system across the application.
|
||||||
|
|
||||||
|
Current typography appears oversized. Refine it to create a more professional enterprise UI.
|
||||||
|
|
||||||
|
Standardize:
|
||||||
|
|
||||||
|
- Font Family
|
||||||
|
- Font Size
|
||||||
|
- Font Weight
|
||||||
|
- Line Height
|
||||||
|
- Letter Spacing
|
||||||
|
|
||||||
|
Suggested scale:
|
||||||
|
|
||||||
|
- Page Title: 24px
|
||||||
|
- Section Title: 20px
|
||||||
|
- Card Title: 18px
|
||||||
|
- Table Header: 13px
|
||||||
|
- Body Text: 14px
|
||||||
|
- Secondary Text: 13px
|
||||||
|
- Labels: 13px
|
||||||
|
- Input Text: 14px
|
||||||
|
- Button Text: 14px
|
||||||
|
- Caption: 12px
|
||||||
|
|
||||||
|
Maintain consistent typography across all screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Spacing System
|
||||||
|
|
||||||
|
Implement a consistent spacing system.
|
||||||
|
|
||||||
|
Standardize:
|
||||||
|
|
||||||
|
- Padding
|
||||||
|
- Margin
|
||||||
|
- Gap
|
||||||
|
- Section spacing
|
||||||
|
- Card spacing
|
||||||
|
- Form spacing
|
||||||
|
|
||||||
|
Avoid random spacing values throughout the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Component Sizing
|
||||||
|
|
||||||
|
Reduce oversized UI elements to create a cleaner, more compact enterprise application.
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- Popup sizes
|
||||||
|
- Modal widths
|
||||||
|
- Dialog heights
|
||||||
|
- Table row heights
|
||||||
|
- Table header heights
|
||||||
|
- Form fields
|
||||||
|
- Buttons
|
||||||
|
- Dropdowns
|
||||||
|
- Cards
|
||||||
|
- Navigation
|
||||||
|
- Sidebar spacing
|
||||||
|
|
||||||
|
The interface should feel lightweight, balanced, and visually polished.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Tables
|
||||||
|
|
||||||
|
Refactor all tables to follow consistent standards.
|
||||||
|
|
||||||
|
Ensure:
|
||||||
|
|
||||||
|
- Compact row height
|
||||||
|
- Consistent header styling
|
||||||
|
- Uniform padding
|
||||||
|
- Better column spacing
|
||||||
|
- Proper alignment
|
||||||
|
- Responsive behavior
|
||||||
|
- Sticky headers where appropriate
|
||||||
|
- Reusable table component
|
||||||
|
- Pagination component
|
||||||
|
- Empty state
|
||||||
|
- Loading state
|
||||||
|
- Sort indicators
|
||||||
|
- Filter integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Modal & Popup Standards
|
||||||
|
|
||||||
|
Standardize all modals.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Consistent width
|
||||||
|
- Consistent padding
|
||||||
|
- Consistent header
|
||||||
|
- Consistent footer
|
||||||
|
- Standard button placement
|
||||||
|
- Responsive behavior
|
||||||
|
- Smooth animations
|
||||||
|
- Proper scroll handling
|
||||||
|
- Keyboard accessibility
|
||||||
|
- Reusable modal component
|
||||||
|
|
||||||
|
Avoid oversized dialogs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Form Standards
|
||||||
|
|
||||||
|
All forms should follow a single design system.
|
||||||
|
|
||||||
|
Maintain consistency for:
|
||||||
|
|
||||||
|
- Label placement
|
||||||
|
- Required indicators
|
||||||
|
- Validation messages
|
||||||
|
- Error states
|
||||||
|
- Success states
|
||||||
|
- Disabled states
|
||||||
|
- Placeholder styles
|
||||||
|
- Input heights
|
||||||
|
- Border radius
|
||||||
|
- Focus styles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Color System
|
||||||
|
|
||||||
|
Centralize all colors.
|
||||||
|
|
||||||
|
Do not hardcode colors.
|
||||||
|
|
||||||
|
Use semantic color tokens such as:
|
||||||
|
|
||||||
|
- Primary
|
||||||
|
- Secondary
|
||||||
|
- Success
|
||||||
|
- Warning
|
||||||
|
- Error
|
||||||
|
- Info
|
||||||
|
- Background
|
||||||
|
- Surface
|
||||||
|
- Border
|
||||||
|
- Text Primary
|
||||||
|
- Text Secondary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Responsive Standards
|
||||||
|
|
||||||
|
Ensure the application is fully responsive.
|
||||||
|
|
||||||
|
Optimize for:
|
||||||
|
|
||||||
|
- Mobile
|
||||||
|
- Tablet
|
||||||
|
- Laptop
|
||||||
|
- Desktop
|
||||||
|
- Large screens
|
||||||
|
|
||||||
|
No layout should break across screen sizes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Performance
|
||||||
|
|
||||||
|
Improve frontend performance.
|
||||||
|
|
||||||
|
- Lazy load pages.
|
||||||
|
- Lazy load heavy components.
|
||||||
|
- Memoize expensive computations.
|
||||||
|
- Avoid unnecessary re-renders.
|
||||||
|
- Optimize bundle size.
|
||||||
|
- Remove unused code.
|
||||||
|
- Optimize images and icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Accessibility
|
||||||
|
|
||||||
|
Follow accessibility best practices.
|
||||||
|
|
||||||
|
- Keyboard navigation
|
||||||
|
- Proper ARIA attributes
|
||||||
|
- Focus management
|
||||||
|
- Color contrast
|
||||||
|
- Semantic HTML
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Folder Structure
|
||||||
|
|
||||||
|
Maintain a clean folder structure.
|
||||||
|
|
||||||
|
- components/
|
||||||
|
- features/
|
||||||
|
- hooks/
|
||||||
|
- layouts/
|
||||||
|
- pages/
|
||||||
|
- services/
|
||||||
|
- utils/
|
||||||
|
- constants/
|
||||||
|
- contexts/
|
||||||
|
- types/
|
||||||
|
- assets/
|
||||||
|
- styles/
|
||||||
|
|
||||||
|
Every folder should have a clear responsibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Naming Conventions
|
||||||
|
|
||||||
|
Use consistent naming conventions.
|
||||||
|
|
||||||
|
- PascalCase for components
|
||||||
|
- camelCase for variables and functions
|
||||||
|
- UPPER_SNAKE_CASE for constants
|
||||||
|
- Feature-based file organization
|
||||||
|
|
||||||
|
Avoid ambiguous names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Styling Standards
|
||||||
|
|
||||||
|
Use a consistent styling approach.
|
||||||
|
|
||||||
|
- Prefer Tailwind utility classes.
|
||||||
|
- Extract repeated class combinations into reusable components or utilities.
|
||||||
|
- Avoid inline styles unless absolutely necessary.
|
||||||
|
- Maintain consistent border radius, shadows, spacing, and transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Final Deliverables
|
||||||
|
|
||||||
|
Refactor the entire application to ensure:
|
||||||
|
|
||||||
|
- Clean architecture
|
||||||
|
- Maintainable codebase
|
||||||
|
- Reusable components
|
||||||
|
- Consistent typography
|
||||||
|
- Compact and polished UI
|
||||||
|
- Smaller, balanced popups and tables
|
||||||
|
- Unified spacing system
|
||||||
|
- Enterprise-grade design system
|
||||||
|
- Fully responsive layouts
|
||||||
|
- Improved performance
|
||||||
|
- Better accessibility
|
||||||
|
- Strict adherence to SOLID, DRY, KISS, and Clean Code principles
|
||||||
|
|
||||||
|
Do not change any business logic or application behavior. The objective is to improve code quality, maintainability, scalability, and visual consistency while preserving all existing functionality.
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useThemeStore } from '../../hooks/use-theme';
|
import { useThemeStore } from '../../hooks/use-theme';
|
||||||
import { useAuthStore } from '../../hooks/use-auth';
|
import { useAuthStore } from '../../hooks/use-auth';
|
||||||
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { axiosInstance } from '../../services/axios';
|
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
@ -12,30 +11,13 @@ export const AdminLayout: React.FC = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPendingCount = async () => {
|
|
||||||
try {
|
|
||||||
const res = await axiosInstance.get('/legal/pending');
|
|
||||||
setPendingCount(res.data.length);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch pending count', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPendingCount();
|
|
||||||
|
|
||||||
// Poll every 10s for real-time admin indicators
|
|
||||||
const interval = setInterval(fetchPendingCount, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
||||||
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
||||||
@ -49,23 +31,34 @@ export const AdminLayout: React.FC = () => {
|
|||||||
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
||||||
|
|
||||||
{/* ── Desktop Sidebar ── */}
|
{/* ── Desktop Sidebar ── */}
|
||||||
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
|
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
|
||||||
|
|
||||||
|
{/* Toggle Button Floating on the Border */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
||||||
|
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Branding */}
|
{/* Branding */}
|
||||||
<div className="h-24 flex items-center px-8 border-b border-ink-200">
|
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
|
||||||
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
|
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
||||||
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
{!isCollapsed && (
|
||||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
<div className="flex flex-col animate-fade-in">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||||
</div>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
@ -73,52 +66,58 @@ export const AdminLayout: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
||||||
|
isCollapsed ? 'justify-center px-0' : 'px-4'
|
||||||
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||||
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
||||||
}`}
|
}`}
|
||||||
|
title={isCollapsed ? item.name : undefined}
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
||||||
<span>{item.name}</span>
|
{!isCollapsed && <span>{item.name}</span>}
|
||||||
{item.name === 'Approvals Queue' && pendingCount > 0 && (
|
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||||
<span className="ml-auto flex h-5.5 w-5.5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0 animate-pulse border border-ink-700 shadow-sm px-1.5 py-0.5">
|
|
||||||
{pendingCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isActive && !(item.name === 'Approvals Queue' && pendingCount > 0) && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-5 border-t border-ink-200 bg-ink-50">
|
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
|
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
|
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
{!isCollapsed ? (
|
||||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
|
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
|
||||||
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in" title={user?.email || ''}>
|
||||||
{user?.email?.charAt(0).toUpperCase()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
|
|
||||||
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
|
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
||||||
|
title={isCollapsed ? "Sign Out" : undefined}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign Out</span>
|
{!isCollapsed && <span>Sign Out</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -158,11 +157,6 @@ export const AdminLayout: React.FC = () => {
|
|||||||
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
|
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
|
||||||
<item.icon className="w-5 h-5" />
|
<item.icon className="w-5 h-5" />
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
{item.name === 'Approvals Queue' && pendingCount > 0 && (
|
|
||||||
<span className="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0">
|
|
||||||
{pendingCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@ -177,33 +171,13 @@ export const AdminLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Dynamic Pending Approvals Banner */}
|
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
||||||
{pendingCount > 0 && (
|
|
||||||
<div className="bg-ink-900 text-ink-0 px-6 py-3.5 flex items-center justify-between border-b border-ink-800 shadow-sm relative overflow-hidden shrink-0">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
|
||||||
<div className="flex items-center gap-3 relative z-10">
|
|
||||||
<span className="flex h-2 w-2 rounded-full bg-ink-0 animate-pulse" />
|
|
||||||
<p className="text-xs font-bold tracking-wide">
|
|
||||||
Onboarding Queue: {pendingCount} partner{pendingCount > 1 ? 's are' : ' is'} awaiting legal document approval.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/admin/approvals"
|
|
||||||
className="relative z-10 text-[10px] font-extrabold uppercase tracking-widest bg-ink-0 text-ink-900 px-3.5 py-1.5 rounded-lg hover:bg-ink-100 transition-all shadow-sm flex items-center gap-1 shrink-0"
|
|
||||||
>
|
|
||||||
<span>Review Now</span>
|
|
||||||
<ChevronRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
||||||
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||||
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
|
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useThemeStore } from '../../hooks/use-theme';
|
import { useThemeStore } from '../../hooks/use-theme';
|
||||||
import { useAuthStore } from '../../hooks/use-auth';
|
import { useAuthStore } from '../../hooks/use-auth';
|
||||||
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@ -16,6 +16,7 @@ export const ClientLayout: React.FC = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@ -27,23 +28,34 @@ export const ClientLayout: React.FC = () => {
|
|||||||
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
||||||
|
|
||||||
{/* ── Desktop Sidebar ── */}
|
{/* ── Desktop Sidebar ── */}
|
||||||
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
|
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
|
||||||
|
|
||||||
|
{/* Toggle Button Floating on the Border */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
||||||
|
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Branding */}
|
{/* Branding */}
|
||||||
<div className="h-24 flex items-center px-8 border-b border-ink-200">
|
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
|
||||||
<Link to="/client" className="flex items-center gap-3 shrink-0 group">
|
<Link to="/client" className="flex items-center gap-3 shrink-0 group">
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
||||||
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
{!isCollapsed && (
|
||||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
<div className="flex flex-col animate-fade-in">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
|
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
||||||
</div>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
@ -51,49 +63,60 @@ export const ClientLayout: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
||||||
|
isCollapsed ? 'justify-center px-0' : 'px-4'
|
||||||
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||||
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
||||||
}`}
|
}`}
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
||||||
<span>{item.label}</span>
|
{!isCollapsed && <span>{item.label}</span>}
|
||||||
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-5 border-t border-ink-200 bg-ink-50">
|
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
|
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
|
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
{!isCollapsed ? (
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
|
||||||
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
|
||||||
{isApproved ? 'Verified' : 'Pending'}
|
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||||
|
{isApproved ? 'Verified' : 'Pending'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
|
) : (
|
||||||
</div>
|
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in relative" title={user?.email || ''}>
|
||||||
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
|
<span className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border border-ink-0 ${isApproved ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
|
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
||||||
|
title={isCollapsed ? "Sign Out" : undefined}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign Out</span>
|
{!isCollapsed && <span>Sign Out</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -148,13 +171,13 @@ export const ClientLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
|
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
||||||
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||||
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
|
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
|
||||||
|
|||||||
35
Channel-Frontend/src/components/ui/PageHeader.tsx
Normal file
35
Channel-Frontend/src/components/ui/PageHeader.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge, actions }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-ink-900 truncate">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{badge && <div className="shrink-0">{badge}</div>}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs font-medium text-ink-500 truncate max-w-3xl">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
229
Channel-Frontend/src/features/assets/components/AssetCard.tsx
Normal file
229
Channel-Frontend/src/features/assets/components/AssetCard.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
File,
|
||||||
|
Eye,
|
||||||
|
MoreVertical,
|
||||||
|
Edit3,
|
||||||
|
Share2,
|
||||||
|
Trash2,
|
||||||
|
Lock,
|
||||||
|
ExternalLink,
|
||||||
|
Download,
|
||||||
|
Clock,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import type { User } from '../../../types/auth';
|
||||||
|
|
||||||
|
interface AssetCardProps {
|
||||||
|
asset: Asset;
|
||||||
|
user: User | null;
|
||||||
|
activeMenuId: string | null;
|
||||||
|
setActiveMenuId: (id: string | null) => void;
|
||||||
|
onViewDetails: (asset: Asset) => void;
|
||||||
|
onEdit: (asset: Asset) => void;
|
||||||
|
onShare: (asset: Asset) => void;
|
||||||
|
onDelete: (assetId: string) => void;
|
||||||
|
onOpenViewer: (asset: Asset) => void;
|
||||||
|
onDownload: (asset: Asset) => void;
|
||||||
|
onRequestDownload: (asset: Asset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||||
|
asset,
|
||||||
|
user,
|
||||||
|
activeMenuId,
|
||||||
|
setActiveMenuId,
|
||||||
|
onViewDetails,
|
||||||
|
onEdit,
|
||||||
|
onShare,
|
||||||
|
onDelete,
|
||||||
|
onOpenViewer,
|
||||||
|
onDownload,
|
||||||
|
onRequestDownload
|
||||||
|
}) => {
|
||||||
|
const isMenuOpen = activeMenuId === asset.id;
|
||||||
|
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
||||||
|
const requestStatus = asset.downloadRequests?.[0]?.status;
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAssetIcon = (type: string) => {
|
||||||
|
if (type === 'url') return Globe;
|
||||||
|
if (type.includes('pdf')) return FileText;
|
||||||
|
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
|
||||||
|
return File;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRenderable = (type: string) => {
|
||||||
|
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg');
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = getAssetIcon(asset.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-start mb-3 relative">
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-1.5">
|
||||||
|
{isRenderable(asset.type) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenViewer(asset)}
|
||||||
|
className="p-1 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors"
|
||||||
|
title="Preview Online"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-100 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isMenuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
|
||||||
|
<div className="absolute right-0 mt-8 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onViewDetails(asset);
|
||||||
|
setActiveMenuId(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<File className="w-3.5 h-3.5" />
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
{user?.role === 'ADMIN' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onEdit(asset);
|
||||||
|
setActiveMenuId(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
Edit Asset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onShare(asset);
|
||||||
|
setActiveMenuId(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Share2 className="w-3.5 h-3.5" />
|
||||||
|
Share Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onDelete(asset.id);
|
||||||
|
setActiveMenuId(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-xs font-semibold text-red-650 hover:bg-red-500/10 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Delete Asset
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
|
||||||
|
{asset.categoryId || 'General'}
|
||||||
|
</div>
|
||||||
|
{!asset.isDownloadable && (
|
||||||
|
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
<span>Strict View Only</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-ink-900 text-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
|
||||||
|
{asset.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] font-medium text-ink-400 mt-1">
|
||||||
|
{asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} • {new Date(asset.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-ink-100 flex items-center justify-between mt-auto">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-ink-400">
|
||||||
|
{asset.type === 'url' ? 'Type' : 'Downloads'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-extrabold text-ink-900 mt-0.5">
|
||||||
|
{asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.type === 'url' ? (
|
||||||
|
<a
|
||||||
|
href={asset.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="w-7 h-7 rounded-md bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
|
||||||
|
title="Open External URL"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
) : canDirectDownload ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onDownload(asset)}
|
||||||
|
className="w-7 h-7 rounded-md bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-600 hover:bg-ink-100 hover:border-ink-300 hover:text-ink-900 transition-all shadow-sm"
|
||||||
|
title="Download Asset"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
) : requestStatus === 'PENDING' ? (
|
||||||
|
<div className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-500 bg-ink-50 border border-ink-200 px-2 py-1 rounded-md">
|
||||||
|
<Clock className="w-3 h-3 text-ink-500 animate-pulse" />
|
||||||
|
<span>Pending Access</span>
|
||||||
|
</div>
|
||||||
|
) : requestStatus === 'REJECTED' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onRequestDownload(asset)}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-bold text-red-650 bg-red-500/10 border border-red-500/20 px-2 py-1 rounded-md hover:bg-red-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
<span>Rejected (Retry)</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onRequestDownload(asset)}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-900 bg-ink-100 border border-ink-300 hover:bg-ink-200 px-2 py-1 rounded-md transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
<span>Request Download</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import type { Asset } from '../../../types/assets';
|
||||||
|
|
||||||
|
interface AssetDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
asset: Asset | null;
|
||||||
|
userRole: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
asset,
|
||||||
|
userRole
|
||||||
|
}) => {
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && asset && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-md w-full shadow-xl z-10 space-y-5 text-ink-900"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3 border-b border-ink-100">
|
||||||
|
<h3 className="text-base font-bold text-ink-900">Asset Details</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-xs font-medium">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4>
|
||||||
|
<p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.description && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4>
|
||||||
|
<p className="text-ink-700 mt-1 leading-relaxed">{asset.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold">{asset.categoryId || 'General'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold">{asset.subcategory || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4>
|
||||||
|
<p className="text-ink-900 mt-1 font-bold">{asset.type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{asset.tags.map(tag => (
|
||||||
|
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userRole === 'ADMIN' && asset.sharedWith && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{asset.sharedWith.length === 0 ? (
|
||||||
|
<span className="text-ink-500 font-semibold italic">Not shared with any organization</span>
|
||||||
|
) : (
|
||||||
|
asset.sharedWith.map(sw => (
|
||||||
|
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold">
|
||||||
|
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3.5 border-t border-ink-100 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,345 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
|
||||||
|
import { axiosInstance } from '../../../services/axios';
|
||||||
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import type { User } from '../../../types/auth';
|
||||||
|
|
||||||
|
interface AssetViewerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
asset: Asset | null;
|
||||||
|
user: User | null;
|
||||||
|
onDownload: (asset: Asset) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
asset,
|
||||||
|
user,
|
||||||
|
onDownload
|
||||||
|
}) => {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
const [isLoadingText, setIsLoadingText] = useState(false);
|
||||||
|
const [textPreviewContent, setTextPreviewContent] = useState('');
|
||||||
|
|
||||||
|
const isLocalUrl = (url: string) => {
|
||||||
|
return url.includes('localhost') || url.includes('127.0.0.1');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullAssetUrl = (url: string) => {
|
||||||
|
return url.startsWith('http')
|
||||||
|
? url
|
||||||
|
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGithubRawUrl = (url: string) => {
|
||||||
|
let raw = url.trim();
|
||||||
|
if (raw.endsWith('.git')) {
|
||||||
|
raw = raw.slice(0, -4);
|
||||||
|
}
|
||||||
|
if (raw.endsWith('/')) {
|
||||||
|
raw = raw.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.includes('/blob/')) {
|
||||||
|
return raw.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = raw.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||||
|
if (match && match[1] && match[2]) {
|
||||||
|
const owner = match[1];
|
||||||
|
const repo = match[2];
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
mainUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
|
||||||
|
masterUrl: `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && asset) {
|
||||||
|
setTextPreviewContent('');
|
||||||
|
setIsLoadingText(false);
|
||||||
|
|
||||||
|
const assetUrl = asset.url.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
asset.type !== 'url' &&
|
||||||
|
(assetUrl.endsWith('.md') ||
|
||||||
|
assetUrl.endsWith('.txt') ||
|
||||||
|
asset.type.includes('text') ||
|
||||||
|
asset.type.includes('markdown'))
|
||||||
|
) {
|
||||||
|
setIsLoadingText(true);
|
||||||
|
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' })
|
||||||
|
.then(res => {
|
||||||
|
setTextPreviewContent(res.data);
|
||||||
|
setIsLoadingText(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setTextPreviewContent('Failed to load text preview.');
|
||||||
|
setIsLoadingText(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (asset.type === 'url' && asset.url.includes('github.com')) {
|
||||||
|
const ghInfo = getGithubRawUrl(asset.url);
|
||||||
|
if (ghInfo) {
|
||||||
|
setIsLoadingText(true);
|
||||||
|
const targetUrl = typeof ghInfo === 'string' ? ghInfo : ghInfo.mainUrl;
|
||||||
|
|
||||||
|
fetch(targetUrl)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok && typeof ghInfo !== 'string') {
|
||||||
|
const fallbackRes = await fetch(ghInfo.masterUrl);
|
||||||
|
if (fallbackRes.ok) {
|
||||||
|
return fallbackRes.text();
|
||||||
|
}
|
||||||
|
throw new Error('README not found');
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
setTextPreviewContent(data);
|
||||||
|
setIsLoadingText(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setTextPreviewContent(`### GitHub Repository: ${typeof ghInfo === 'string' ? asset.url : `${ghInfo.owner}/${ghInfo.repo}`}\n\nCould not fetch README.md automatically. The repository might be private or not contain a README.md file.`);
|
||||||
|
setIsLoadingText(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, asset]);
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsMaximized(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && asset && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98, y: 15 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98, y: 15 }}
|
||||||
|
className={`relative bg-ink-0 border border-ink-250 rounded-2xl p-6 shadow-xl z-10 flex flex-col overflow-hidden gap-4 transition-all duration-300 ${
|
||||||
|
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-ink-900">{asset.title}</h3>
|
||||||
|
<p className="text-xs text-ink-500">
|
||||||
|
{asset.type === 'url' ? 'External Web Link' : `${asset.type} • ${formatBytes(asset.size)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMaximized(!isMaximized)}
|
||||||
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
title={isMaximized ? "Collapse view" : "Expand view"}
|
||||||
|
>
|
||||||
|
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0">
|
||||||
|
{asset.type === 'url' && asset.url.includes('github.com') ? (
|
||||||
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||||
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Globe className="w-4 h-4 text-ink-950" />
|
||||||
|
<span>Embedded GitHub Document</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
||||||
|
{isLoadingText ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
||||||
|
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
||||||
|
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
||||||
|
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
|
||||||
|
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||||
|
{textPreviewContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : asset.type === 'url' ? (
|
||||||
|
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
|
||||||
|
<Globe className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4>
|
||||||
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
|
||||||
|
This asset points to an external destination outside of the local CDN container.
|
||||||
|
</p>
|
||||||
|
<div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm">
|
||||||
|
{asset.url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
||||||
|
<a
|
||||||
|
href={asset.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full"
|
||||||
|
>
|
||||||
|
<span>Open Link in New Tab</span>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="bg-amber-50 border border-amber-205 text-amber-900 rounded-xl p-4 text-xs text-center font-medium max-w-sm">
|
||||||
|
Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : asset.type.includes('pdf') ? (
|
||||||
|
<div className="w-full h-full flex flex-col min-h-0">
|
||||||
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
||||||
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||||
|
<span>Interactive PDF Preview</span>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={getFullAssetUrl(asset.url)}
|
||||||
|
className="w-full flex-1 border-0 min-h-0"
|
||||||
|
title={asset.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? (
|
||||||
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||||
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
||||||
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||||
|
<span>Document Reader</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
||||||
|
{isLoadingText ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
||||||
|
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
||||||
|
<span className="text-xs text-ink-500 font-medium">Loading content...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
||||||
|
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
|
||||||
|
<p className="text-xs text-ink-500 mt-1">Plain Text / Markdown Format</p>
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||||
|
{textPreviewContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
|
||||||
|
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||||
|
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
||||||
|
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||||
|
<span>Office Document Preview</span>
|
||||||
|
</div>
|
||||||
|
{isLocalUrl(asset.url) ? (
|
||||||
|
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
|
||||||
|
<FileText className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-ink-900">Office Document Preview</h4>
|
||||||
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
|
||||||
|
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left">
|
||||||
|
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
|
||||||
|
className="w-full flex-1 border-0 min-h-0"
|
||||||
|
title={asset.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
src={getFullAssetUrl(asset.url)}
|
||||||
|
alt={asset.title}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-8 flex flex-col justify-center items-center">
|
||||||
|
<File className="w-12 h-12 text-ink-300 mb-3" />
|
||||||
|
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4>
|
||||||
|
<p className="text-xs text-ink-500 mt-1 max-w-sm">
|
||||||
|
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
Close Preview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDownload(asset)}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Download File</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Check } from 'lucide-react';
|
||||||
|
import type { Asset } from '../../../types/assets';
|
||||||
|
|
||||||
|
interface DownloadRequestsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
assets: Asset[];
|
||||||
|
onApprove: (assetId: string, requestId: string) => void;
|
||||||
|
onReject: (assetId: string, requestId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
assets,
|
||||||
|
onApprove,
|
||||||
|
onReject
|
||||||
|
}) => {
|
||||||
|
const pendingRequests = assets.flatMap(asset =>
|
||||||
|
(asset.downloadRequests || [])
|
||||||
|
.filter(req => req.status === 'PENDING')
|
||||||
|
.map(req => ({
|
||||||
|
...req,
|
||||||
|
assetTitle: asset.title,
|
||||||
|
assetId: asset.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-2xl w-full shadow-xl z-10 flex flex-col max-h-[80vh] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-ink-900">Pending Download Requests</h3>
|
||||||
|
<p className="text-xs text-ink-500 mt-0.5">Review and approve download access for protected secret assets.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto mt-4 space-y-3 pr-1 scrollbar-thin">
|
||||||
|
{pendingRequests.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" />
|
||||||
|
<p className="text-xs font-bold text-ink-900">All caught up!</p>
|
||||||
|
<p className="text-[10px] text-ink-500">There are no pending download authorization requests.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pendingRequests.map(req => (
|
||||||
|
<div key={req.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-ink-50 border border-ink-200 rounded-xl gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-ink-900">{req.user?.email}</p>
|
||||||
|
<p className="text-[10px] text-ink-500 mt-0.5 font-medium">
|
||||||
|
Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onReject(req.assetId, req.id)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-red-200 text-red-650 hover:bg-red-500/10 text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onApprove(req.assetId, req.id)}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-ink-900 text-ink-0 hover:bg-ink-800 text-xs font-bold transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
Approve Access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3.5 border-t border-ink-100 flex justify-end flex-shrink-0 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { updateAsset } from '../../../services/assets-api';
|
||||||
|
import type { Asset } from '../../../types/assets';
|
||||||
|
|
||||||
|
interface EditAssetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
asset: Asset | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
asset,
|
||||||
|
onSuccess
|
||||||
|
}) => {
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editDescription, setEditDescription] = useState('');
|
||||||
|
const [editCategory, setEditCategory] = useState('Marketing');
|
||||||
|
const [editSubcategory, setEditSubcategory] = useState('');
|
||||||
|
const [editTags, setEditTags] = useState('');
|
||||||
|
const [editGithubUrl, setEditGithubUrl] = useState('');
|
||||||
|
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
|
||||||
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (asset) {
|
||||||
|
setEditTitle(asset.title);
|
||||||
|
setEditDescription(asset.description || '');
|
||||||
|
setEditCategory(asset.categoryId || 'Marketing');
|
||||||
|
setEditSubcategory(asset.subcategory || '');
|
||||||
|
setEditTags(asset.tags.join(', '));
|
||||||
|
setEditGithubUrl(asset.githubUrl || '');
|
||||||
|
setEditIsDownloadable(asset.isDownloadable);
|
||||||
|
}
|
||||||
|
}, [asset]);
|
||||||
|
|
||||||
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
setIsSavingEdit(true);
|
||||||
|
try {
|
||||||
|
await updateAsset(asset.id, {
|
||||||
|
title: editTitle,
|
||||||
|
description: editDescription,
|
||||||
|
categoryId: editCategory,
|
||||||
|
subcategory: editSubcategory,
|
||||||
|
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
|
githubUrl: editGithubUrl,
|
||||||
|
isDownloadable: editIsDownloadable,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save asset details', err);
|
||||||
|
} finally {
|
||||||
|
setIsSavingEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && asset && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleEditSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
||||||
|
<select
|
||||||
|
value={editCategory}
|
||||||
|
onChange={(e) => setEditCategory(e.target.value)}
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
|
||||||
|
>
|
||||||
|
<option value="Marketing">Marketing</option>
|
||||||
|
<option value="Presentations">Presentations</option>
|
||||||
|
<option value="Branding">Branding</option>
|
||||||
|
<option value="Resources">Resources</option>
|
||||||
|
<option value="Technical">Technical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editSubcategory}
|
||||||
|
onChange={(e) => setEditSubcategory(e.target.value)}
|
||||||
|
placeholder="e.g. Slide Deck"
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
placeholder="Enter short description..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{asset.type !== 'url' && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="editIsDownloadable"
|
||||||
|
checked={editIsDownloadable}
|
||||||
|
onChange={(e) => setEditIsDownloadable(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
|
||||||
|
Allow Direct Download (Strict View Only if unchecked)
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-ink-500">
|
||||||
|
Toggle client authorization requirement for asset downloads.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editTags}
|
||||||
|
onChange={(e) => setEditTags(e.target.value)}
|
||||||
|
placeholder="branding, guideline, pitch"
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editGithubUrl}
|
||||||
|
onChange={(e) => setEditGithubUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/..."
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingEdit ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { updateAsset } from '../../../services/assets-api';
|
||||||
|
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
||||||
|
|
||||||
|
interface ShareAssetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
asset: Asset | null;
|
||||||
|
organizations: Organization[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
asset,
|
||||||
|
organizations,
|
||||||
|
onSuccess
|
||||||
|
}) => {
|
||||||
|
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
||||||
|
const [isSavingShare, setIsSavingShare] = useState(false);
|
||||||
|
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (asset) {
|
||||||
|
setSharesList(
|
||||||
|
asset.sharedWith?.map(s => ({
|
||||||
|
organizationId: s.organizationId,
|
||||||
|
userId: s.userId
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [asset]);
|
||||||
|
|
||||||
|
const isOrgSharedEntirely = (orgId: string) => {
|
||||||
|
return sharesList.some(s => s.organizationId === orgId && s.userId === null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserSharedSpecifically = (orgId: string, userId: string) => {
|
||||||
|
return sharesList.some(s => s.organizationId === orgId && s.userId === userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleOrg = (orgId: string) => {
|
||||||
|
const isShared = isOrgSharedEntirely(orgId);
|
||||||
|
if (isShared) {
|
||||||
|
setSharesList(prev => prev.filter(s => s.organizationId !== orgId));
|
||||||
|
} else {
|
||||||
|
setSharesList(prev => [
|
||||||
|
...prev.filter(s => s.organizationId !== orgId),
|
||||||
|
{ organizationId: orgId, userId: null }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleUser = (orgId: string, userId: string) => {
|
||||||
|
const isShared = isUserSharedSpecifically(orgId, userId);
|
||||||
|
if (isShared) {
|
||||||
|
setSharesList(prev => prev.filter(s => !(s.organizationId === orgId && s.userId === userId)));
|
||||||
|
} else {
|
||||||
|
setSharesList(prev => [
|
||||||
|
...prev.filter(s => !(s.organizationId === orgId && s.userId === null)),
|
||||||
|
{ organizationId: orgId, userId }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
setIsSavingShare(true);
|
||||||
|
try {
|
||||||
|
await updateAsset(asset.id, {
|
||||||
|
shares: sharesList
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update share permissions', err);
|
||||||
|
} finally {
|
||||||
|
setIsSavingShare(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && asset && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
|
||||||
|
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleShareSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||||
|
<p className="text-xs text-ink-600 leading-relaxed">
|
||||||
|
Select organizations or expand to specify exact users that can access this asset:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
|
||||||
|
{organizations.length === 0 ? (
|
||||||
|
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
|
||||||
|
) : (
|
||||||
|
organizations.map(org => {
|
||||||
|
const isEntireShared = isOrgSharedEntirely(org.id);
|
||||||
|
const isExpanded = expandedOrgId === org.id;
|
||||||
|
const activeUsers = org.users || [];
|
||||||
|
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={org.id} className="flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEntireShared}
|
||||||
|
onChange={() => handleToggleOrg(org.id)}
|
||||||
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-ink-900">{org.name}</span>
|
||||||
|
{specificSharedCount > 0 && !isEntireShared && (
|
||||||
|
<span className="text-[10px] text-ink-500 font-semibold">
|
||||||
|
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold"
|
||||||
|
>
|
||||||
|
<span>Users</span>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
|
||||||
|
>
|
||||||
|
{activeUsers.length === 0 ? (
|
||||||
|
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p>
|
||||||
|
) : (
|
||||||
|
activeUsers.map(userItem => {
|
||||||
|
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={isEntireShared}
|
||||||
|
checked={isEntireShared || isUserShared}
|
||||||
|
onChange={() => handleToggleUser(org.id, userItem.id)}
|
||||||
|
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
|
||||||
|
{userItem.email}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingShare}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingShare ? 'Saving...' : 'Update Shares'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,410 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
||||||
|
import { uploadAsset } from '../../../services/assets-api';
|
||||||
|
|
||||||
|
interface UploadAssetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess
|
||||||
|
}) => {
|
||||||
|
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
|
||||||
|
const [uploadUrl, setUploadUrl] = useState('');
|
||||||
|
const [uploadTitle, setUploadTitle] = useState('');
|
||||||
|
const [uploadDescription, setUploadDescription] = useState('');
|
||||||
|
const [uploadCategory, setUploadCategory] = useState('Marketing');
|
||||||
|
const [uploadSubcategory, setUploadSubcategory] = useState('');
|
||||||
|
const [uploadTags, setUploadTags] = useState('');
|
||||||
|
const [uploadGithubUrl, setUploadGithubUrl] = useState('');
|
||||||
|
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uploadFile) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setFullImagePreviewUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadFile.type.startsWith('image/')) {
|
||||||
|
const url = URL.createObjectURL(uploadFile);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setFullImagePreviewUrl(null);
|
||||||
|
}
|
||||||
|
}, [uploadFile]);
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (uploadTab === 'file' && !uploadFile) return;
|
||||||
|
if (uploadTab === 'url' && !uploadUrl) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
if (uploadTab === 'file' && uploadFile) {
|
||||||
|
formData.append('file', uploadFile);
|
||||||
|
} else {
|
||||||
|
formData.append('isUrlAsset', 'true');
|
||||||
|
formData.append('url', uploadUrl);
|
||||||
|
}
|
||||||
|
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : uploadUrl));
|
||||||
|
formData.append('description', uploadDescription);
|
||||||
|
formData.append('categoryId', uploadCategory);
|
||||||
|
formData.append('subcategory', uploadSubcategory);
|
||||||
|
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
|
||||||
|
formData.append('githubUrl', uploadGithubUrl);
|
||||||
|
formData.append('isDownloadable', String(uploadIsDownloadable));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadAsset(formData);
|
||||||
|
setUploadFile(null);
|
||||||
|
setUploadUrl('');
|
||||||
|
setUploadTitle('');
|
||||||
|
setUploadDescription('');
|
||||||
|
setUploadCategory('Marketing');
|
||||||
|
setUploadSubcategory('');
|
||||||
|
setUploadTags('');
|
||||||
|
setUploadGithubUrl('');
|
||||||
|
setUploadIsDownloadable(true);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to upload asset', err);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle upload tabs */}
|
||||||
|
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadTab('file')}
|
||||||
|
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
|
>
|
||||||
|
Secure File Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUploadTab('url')}
|
||||||
|
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||||
|
>
|
||||||
|
External Web URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUploadSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
|
||||||
|
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
||||||
|
{uploadTab === 'file' ? (
|
||||||
|
<div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}>
|
||||||
|
{!uploadFile && (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
setUploadFile(e.target.files[0]);
|
||||||
|
setUploadTitle(e.target.files[0].name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={uploadTab === 'file'}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer z-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploadFile && !previewUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setUploadFile(null);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm"
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{previewUrl && uploadFile ? (
|
||||||
|
<div className="relative z-20 py-1">
|
||||||
|
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Upload preview"
|
||||||
|
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 max-w-xs mx-auto px-2.5 py-1.5 bg-ink-0 border border-ink-200 rounded-lg shadow-sm relative z-30 mb-1.5">
|
||||||
|
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
|
||||||
|
{uploadFile?.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setFullImagePreviewUrl(previewUrl);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-950 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
|
||||||
|
title="Preview Image"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setUploadFile(null);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-650 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
|
||||||
|
title="Remove File"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-ink-500">
|
||||||
|
{formatBytes(uploadFile?.size || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : uploadFile ? (
|
||||||
|
<div className="relative z-0 py-1">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
|
||||||
|
{uploadFile?.name?.endsWith('.pdf') ? (
|
||||||
|
<FileText className="w-6 h-6 text-ink-600" />
|
||||||
|
) : (
|
||||||
|
<File className="w-6 h-6 text-ink-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
|
||||||
|
{uploadFile?.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-ink-500 mt-0.5">
|
||||||
|
{formatBytes(uploadFile?.size || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative z-0">
|
||||||
|
<UploadCloud className="w-8 h-8 text-ink-400 mx-auto mb-1.5" />
|
||||||
|
<p className="text-xs font-bold text-ink-900">
|
||||||
|
Drag & drop or click to upload file
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-ink-500 mt-0.5">PDF, ZIP, PNG, JPG up to 50MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={uploadUrl}
|
||||||
|
onChange={(e) => setUploadUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/partner-docs"
|
||||||
|
required={uploadTab === 'url'}
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadTitle}
|
||||||
|
onChange={(e) => setUploadTitle(e.target.value)}
|
||||||
|
placeholder="Enter descriptive title"
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
||||||
|
<select
|
||||||
|
value={uploadCategory}
|
||||||
|
onChange={(e) => setUploadCategory(e.target.value)}
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
|
||||||
|
>
|
||||||
|
<option value="Marketing">Marketing</option>
|
||||||
|
<option value="Presentations">Presentations</option>
|
||||||
|
<option value="Branding">Branding</option>
|
||||||
|
<option value="Resources">Resources</option>
|
||||||
|
<option value="Technical">Technical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadSubcategory}
|
||||||
|
onChange={(e) => setUploadSubcategory(e.target.value)}
|
||||||
|
placeholder="e.g. Slide Deck"
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={uploadDescription}
|
||||||
|
onChange={(e) => setUploadDescription(e.target.value)}
|
||||||
|
placeholder="Enter short description about this asset..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadTab === 'file' && (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isDownloadable"
|
||||||
|
checked={uploadIsDownloadable}
|
||||||
|
onChange={(e) => setUploadIsDownloadable(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="isDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
|
||||||
|
Allow Direct Download
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-ink-500">
|
||||||
|
If unchecked, clients must request manual download access (Strict View Only).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadTags}
|
||||||
|
onChange={(e) => setUploadTags(e.target.value)}
|
||||||
|
placeholder="branding, guideline, pitch"
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={uploadGithubUrl}
|
||||||
|
onChange={(e) => setUploadGithubUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/..."
|
||||||
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading}
|
||||||
|
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isUploading ? 'Publishing...' : 'Publish Asset'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Full Local Image Preview Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{fullImagePreviewUrl && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setFullImagePreviewUrl(null)}
|
||||||
|
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="relative max-w-[95vw] max-h-[90vh] z-10 flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setFullImagePreviewUrl(null)}
|
||||||
|
className="absolute -top-10 right-0 p-1.5 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-900 border border-ink-200 shadow-lg transition-colors z-20"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={fullImagePreviewUrl}
|
||||||
|
alt="Full preview"
|
||||||
|
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
Channel-Frontend/src/hooks/use-legal-mutation.ts
Normal file
21
Channel-Frontend/src/hooks/use-legal-mutation.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { approvePartnerAccess } from "../services/legal-api";
|
||||||
|
import { LEGAL_QUERY_KEYS } from "./use-legal-query";
|
||||||
|
|
||||||
|
export const useApprovePartnerMutation = (): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
string
|
||||||
|
> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (partnerId: string) => approvePartnerAccess(partnerId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: LEGAL_QUERY_KEYS.pending(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
19
Channel-Frontend/src/hooks/use-legal-query.ts
Normal file
19
Channel-Frontend/src/hooks/use-legal-query.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { getPendingPartners } from "../services/legal-api";
|
||||||
|
import type { PendingPartner } from "../services/legal-api";
|
||||||
|
|
||||||
|
export const LEGAL_QUERY_KEYS = {
|
||||||
|
pending: () => ["legal", "pending"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePendingPartnersQuery = (
|
||||||
|
enabled: boolean = true
|
||||||
|
): UseQueryResult<PendingPartner[]> => {
|
||||||
|
return useQuery<PendingPartner[]>({
|
||||||
|
queryKey: LEGAL_QUERY_KEYS.pending(),
|
||||||
|
queryFn: getPendingPartners,
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
||||||
|
});
|
||||||
|
};
|
||||||
22
Channel-Frontend/src/hooks/use-partners-mutation.ts
Normal file
22
Channel-Frontend/src/hooks/use-partners-mutation.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { invitePartner } from "../services/auth-api";
|
||||||
|
import type { InviteResponse } from "../services/auth-api";
|
||||||
|
import { PARTNERS_QUERY_KEYS } from "./use-partners-query";
|
||||||
|
|
||||||
|
export const useInvitePartnerMutation = (): UseMutationResult<
|
||||||
|
InviteResponse,
|
||||||
|
Error,
|
||||||
|
string
|
||||||
|
> => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<InviteResponse, Error, string>({
|
||||||
|
mutationFn: (email: string) => invitePartner(email),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
19
Channel-Frontend/src/hooks/use-partners-query.ts
Normal file
19
Channel-Frontend/src/hooks/use-partners-query.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { getPartners } from "../services/auth-api";
|
||||||
|
import type { Partner } from "../services/auth-api";
|
||||||
|
|
||||||
|
export const PARTNERS_QUERY_KEYS = {
|
||||||
|
list: () => ["partners", "list"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePartnersQuery = (
|
||||||
|
enabled: boolean = true
|
||||||
|
): UseQueryResult<Partner[]> => {
|
||||||
|
return useQuery<Partner[]>({
|
||||||
|
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||||
|
queryFn: getPartners,
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
||||||
|
});
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
|
|||||||
import type { Variants } from 'framer-motion';
|
import type { Variants } from 'framer-motion';
|
||||||
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
|
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
|
||||||
const containerVariants: Variants = {
|
const containerVariants: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@ -57,63 +58,61 @@ export const DashboardPage = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="max-w-[1400px] mx-auto w-full space-y-10 text-ink-900 animate-fade-in">
|
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
|
||||||
<motion.div variants={itemVariants} className="flex flex-col gap-3">
|
<PageHeader
|
||||||
<div className="inline-flex items-center gap-2.5 px-4 py-1.5 rounded-full bg-ink-900 border border-ink-800 w-fit shadow-sm text-ink-0">
|
title={`Welcome back, ${user?.email?.split('@')[0]}`}
|
||||||
<div className="w-2 h-2 rounded-full bg-ink-0 animate-pulse" />
|
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
|
||||||
<span className="text-[11px] font-bold tracking-widest uppercase">System Active</span>
|
badge={
|
||||||
</div>
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
|
||||||
<h1 className="text-5xl font-extrabold text-ink-900 tracking-tight mt-6">
|
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
|
||||||
Welcome back, {user?.email?.split('@')[0]}
|
<span>System Active</span>
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-xl text-ink-500 max-w-2xl leading-relaxed mt-2 font-medium">
|
}
|
||||||
You are authenticated as <span className="text-ink-900 font-extrabold uppercase tracking-wider">{user?.role}</span>. Manage your channel network, monitor compliance, and distribute assets globally.
|
/>
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-6">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
{[
|
{[
|
||||||
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
|
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
|
||||||
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
||||||
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
||||||
].map((stat, i) => (
|
].map((stat, i) => (
|
||||||
<div key={i} className="relative group overflow-hidden rounded-3xl bg-ink-0 border border-ink-200 p-8 transition-all duration-300 shadow-sm hover:shadow-md">
|
<div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
|
||||||
<div className="absolute top-0 right-0 p-6 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
|
||||||
<stat.icon className="w-24 h-24 text-ink-900" />
|
<stat.icon className="w-16 h-16 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold uppercase tracking-wider text-ink-500 mb-2">{stat.label}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
|
||||||
<div className="flex items-end gap-4 mt-4">
|
<div className="flex items-end gap-3 mt-2">
|
||||||
<h3 className="text-5xl font-extrabold text-ink-900 tracking-tighter">{stat.value}</h3>
|
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
|
||||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2.5 py-1.5 rounded-lg mb-1.5 border border-ink-200">{stat.trend}</span>
|
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Main Action Cards */}
|
{/* Main Action Cards */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-6">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||||
{CARDS.map((card, idx) => (
|
{CARDS.map((card, idx) => (
|
||||||
<Link key={idx} to={card.path} className="group relative block h-full">
|
<Link key={idx} to={card.path} className="group relative block h-full">
|
||||||
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-[2rem] p-10 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-1 flex flex-col justify-between">
|
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
|
||||||
|
|
||||||
<div className="flex justify-between items-start mb-16 relative z-10">
|
<div className="flex justify-between items-start mb-8 relative z-10">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
|
||||||
<card.icon className="w-8 h-8" />
|
<card.icon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
|
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
|
||||||
<ArrowUpRight className="w-6 h-6 text-ink-400 group-hover:text-ink-900 transition-colors" />
|
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 mt-auto">
|
<div className="relative z-10 mt-auto">
|
||||||
<div className="inline-block px-3.5 py-1.5 rounded-full bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-5 shadow-sm">
|
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
|
||||||
{card.metrics}
|
{card.metrics}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-3xl font-extrabold text-ink-900 mb-4 tracking-tight">
|
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">
|
||||||
{card.title}
|
{card.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-ink-500 leading-relaxed text-sm font-medium">
|
<p className="text-ink-500 leading-normal text-xs font-medium">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { loginUser } from '../services/auth-api';
|
|||||||
import { useAuthStore } from '../hooks/use-auth';
|
import { useAuthStore } from '../hooks/use-auth';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Hexagon, Lock, Mail, ArrowRight } from 'lucide-react';
|
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@ -17,6 +17,7 @@ type LoginFormValues = z.infer<typeof loginSchema>;
|
|||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -50,7 +51,7 @@ export const LoginPage = () => {
|
|||||||
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="w-full max-w-md relative z-10"
|
className="w-full max-w-md relative z-10"
|
||||||
@ -100,11 +101,18 @@ export const LoginPage = () => {
|
|||||||
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
|
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
|
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-12 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
|
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,12 @@ import { ShieldCheck, CheckCircle, ChevronRight, Lock, UploadCloud, FileText, Cl
|
|||||||
import { useAuthStore } from '../hooks/use-auth';
|
import { useAuthStore } from '../hooks/use-auth';
|
||||||
import { SignatureCapture } from '../features/agreements/components/SignatureCapture';
|
import { SignatureCapture } from '../features/agreements/components/SignatureCapture';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { axiosInstance } from '../services/axios';
|
import {
|
||||||
|
getActiveLegalDocument,
|
||||||
|
getMyAcceptances,
|
||||||
|
uploadSignedLegalDocument,
|
||||||
|
signLegalDocument
|
||||||
|
} from '../services/legal-api';
|
||||||
|
|
||||||
type DocumentType = 'NDA' | 'MSA';
|
type DocumentType = 'NDA' | 'MSA';
|
||||||
|
|
||||||
@ -35,14 +40,14 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDocs = async () => {
|
const fetchDocs = async () => {
|
||||||
try {
|
try {
|
||||||
const [ndaRes, msaRes] = await Promise.all([
|
const [ndaData, msaData] = await Promise.all([
|
||||||
axiosInstance.get('/legal/documents/active/NDA'),
|
getActiveLegalDocument('NDA'),
|
||||||
axiosInstance.get('/legal/documents/active/MSA')
|
getActiveLegalDocument('MSA')
|
||||||
]);
|
]);
|
||||||
setNdaContent(ndaRes.data.content);
|
setNdaContent(ndaData.content);
|
||||||
setNdaPdfUrl(ndaRes.data.pdfUrl || null);
|
setNdaPdfUrl(ndaData.pdfUrl || null);
|
||||||
setMsaContent(msaRes.data.content);
|
setMsaContent(msaData.content);
|
||||||
setMsaPdfUrl(msaRes.data.pdfUrl || null);
|
setMsaPdfUrl(msaData.pdfUrl || null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch legal documents:', err);
|
console.error('Failed to fetch legal documents:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -145,9 +150,9 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
setStep(3); // Go straight to pending screen
|
setStep(3); // Go straight to pending screen
|
||||||
} else {
|
} else {
|
||||||
// Check existing acceptances to skip steps
|
// Check existing acceptances to skip steps
|
||||||
axiosInstance.get('/legal/my-acceptances').then(res => {
|
getMyAcceptances().then(data => {
|
||||||
const hasNDA = res.data.some((a: any) => a.document.type === 'NDA');
|
const hasNDA = data.some((a: any) => a.document.type === 'NDA');
|
||||||
const hasMSA = res.data.some((a: any) => a.document.type === 'MSA');
|
const hasMSA = data.some((a: any) => a.document.type === 'MSA');
|
||||||
if (hasNDA && !hasMSA) setStep(2);
|
if (hasNDA && !hasMSA) setStep(2);
|
||||||
if (hasNDA && hasMSA) setStep(3);
|
if (hasNDA && hasMSA) setStep(3);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
@ -182,12 +187,10 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('title', `Signed ${type} - ${user?.email}`);
|
formData.append('title', `Signed ${type} - ${user?.email}`);
|
||||||
|
|
||||||
const response = await axiosInstance.post('/legal/upload', formData, {
|
const data = await uploadSignedLegalDocument(formData);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (type === 'NDA') setNdaUploadUrl(response.data.url);
|
if (type === 'NDA') setNdaUploadUrl(data.url);
|
||||||
if (type === 'MSA') setMsaUploadUrl(response.data.url);
|
if (type === 'MSA') setMsaUploadUrl(data.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to upload ${type}:`, error);
|
console.error(`Failed to upload ${type}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -201,7 +204,7 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
const signatureBase64 = type === 'NDA' ? ndaSignature : msaSignature;
|
const signatureBase64 = type === 'NDA' ? ndaSignature : msaSignature;
|
||||||
const documentUrl = type === 'NDA' ? ndaUploadUrl : msaUploadUrl;
|
const documentUrl = type === 'NDA' ? ndaUploadUrl : msaUploadUrl;
|
||||||
|
|
||||||
await axiosInstance.post('/legal/sign', {
|
await signLegalDocument({
|
||||||
documentType: type,
|
documentType: type,
|
||||||
signatureBase64,
|
signatureBase64,
|
||||||
documentUrl
|
documentUrl
|
||||||
@ -225,13 +228,13 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
<div className="flex bg-ink-100 rounded-xl p-1 mb-6 max-w-sm">
|
<div className="flex bg-ink-100 rounded-xl p-1 mb-6 max-w-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('draw')}
|
onClick={() => setMode('draw')}
|
||||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
|
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
|
||||||
>
|
>
|
||||||
Draw Signature
|
Draw Signature
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('upload')}
|
onClick={() => setMode('upload')}
|
||||||
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
|
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
|
||||||
>
|
>
|
||||||
Upload PDF
|
Upload PDF
|
||||||
</button>
|
</button>
|
||||||
@ -460,4 +463,5 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OnboardingPage;
|
export default OnboardingPage;
|
||||||
|
|||||||
@ -1,56 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
|
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
|
||||||
import { axiosInstance } from '../../services/axios';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
|
||||||
interface PendingPartner {
|
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
|
||||||
id: string;
|
import PageHeader from '../../components/ui/PageHeader';
|
||||||
email: string;
|
|
||||||
createdAt: string;
|
|
||||||
acceptances: Array<{
|
|
||||||
id: string;
|
|
||||||
signatureHash: string | null;
|
|
||||||
documentUrl: string | null;
|
|
||||||
document: {
|
|
||||||
type: string;
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApprovalsPage: React.FC = () => {
|
export const ApprovalsPage: React.FC = () => {
|
||||||
const [partners, setPartners] = useState<PendingPartner[]>([]);
|
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
||||||
const [loading, setLoading] = useState(true);
|
const approveMutation = useApprovePartnerMutation();
|
||||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const approvePartner = (partnerId: string) => {
|
||||||
fetchPending();
|
approveMutation.mutate(partnerId);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchPending = async () => {
|
|
||||||
try {
|
|
||||||
const res = await axiosInstance.get('/legal/pending');
|
|
||||||
setPartners(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch pending partners', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const approvePartner = async (partnerId: string) => {
|
if (isLoading) {
|
||||||
setProcessingId(partnerId);
|
|
||||||
try {
|
|
||||||
await axiosInstance.post(`/legal/approve/${partnerId}`);
|
|
||||||
setPartners(partners.filter(p => p.id !== partnerId));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to approve', err);
|
|
||||||
} finally {
|
|
||||||
setProcessingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
||||||
@ -59,52 +22,48 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
|
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
title="Approvals Queue"
|
||||||
<div>
|
subtitle="Review and approve partner legal documents to grant platform access."
|
||||||
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight flex items-center gap-3">
|
badge={
|
||||||
Approvals Queue
|
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
|
||||||
<span className="bg-ink-900 text-ink-0 text-xs px-2.5 py-1 rounded-full font-bold border border-ink-700 shadow-sm">
|
{partners.length} Pending
|
||||||
{partners.length} Pending
|
</span>
|
||||||
</span>
|
}
|
||||||
</h1>
|
actions={
|
||||||
<p className="text-ink-500 text-sm mt-1">
|
<div className="relative">
|
||||||
Review and approve partner legal documents to grant platform access.
|
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
</p>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
|
placeholder="Search pending partners..."
|
||||||
<div className="relative">
|
className="pl-9 pr-4 py-1.5 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64 shadow-sm"
|
||||||
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
/>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
}
|
||||||
placeholder="Search pending partners..."
|
/>
|
||||||
className="pl-9 pr-4 py-2 bg-ink-0 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 overflow-hidden shadow-sm">
|
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Partner</th>
|
<th className="px-4 py-2.5">Partner</th>
|
||||||
<th className="px-6 py-4">NDA Status</th>
|
<th className="px-4 py-2.5">NDA Status</th>
|
||||||
<th className="px-6 py-4">MSA Status</th>
|
<th className="px-4 py-2.5">MSA Status</th>
|
||||||
<th className="px-6 py-4 text-right">Actions</th>
|
<th className="px-4 py-2.5 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-ink-200">
|
<tbody className="divide-y divide-ink-200">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{partners.length === 0 ? (
|
{partners.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
<td colSpan={4} className="px-4 py-8 text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
|
||||||
<CheckCircle className="w-6 h-6 text-ink-900" />
|
<CheckCircle className="w-6 h-6 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink-900 font-bold">Queue is empty</p>
|
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
|
||||||
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
|
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -120,13 +79,13 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
|
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
|
||||||
className="hover:bg-ink-50 transition-colors group"
|
className="hover:bg-ink-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
||||||
{partner.email.charAt(0).toUpperCase()}
|
{partner.email.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-900">{partner.email}</p>
|
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
|
||||||
<p className="text-xs text-ink-500 flex items-center gap-1">
|
<p className="text-xs text-ink-500 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{new Date(partner.createdAt).toLocaleDateString()}
|
{new Date(partner.createdAt).toLocaleDateString()}
|
||||||
@ -134,11 +93,11 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
{nda ? (
|
{nda ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-1 rounded-md">
|
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||||
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||||
</span>
|
</span>
|
||||||
{nda.documentUrl && (
|
{nda.documentUrl && (
|
||||||
@ -148,15 +107,15 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-ink-400">
|
<div className="flex items-center gap-2 text-ink-400">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">Missing</span>
|
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
{msa ? (
|
{msa ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-1 rounded-md">
|
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||||
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||||
</span>
|
</span>
|
||||||
{msa.documentUrl && (
|
{msa.documentUrl && (
|
||||||
@ -166,17 +125,17 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-ink-400">
|
<div className="flex items-center gap-2 text-ink-400">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">Missing</span>
|
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => approvePartner(partner.id)}
|
onClick={() => approvePartner(partner.id)}
|
||||||
disabled={!nda || !msa || processingId === partner.id}
|
disabled={!nda || !msa || (approveMutation.isPending && approveMutation.variables === partner.id)}
|
||||||
className="px-4 py-2 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1.5 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
|
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
|
|||||||
@ -1,16 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
|
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
|
||||||
import { axiosInstance } from '../../services/axios';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { usePartnersQuery } from '../../hooks/use-partners-query';
|
||||||
interface Partner {
|
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation';
|
||||||
id: string;
|
import PageHeader from '../../components/ui/PageHeader';
|
||||||
email: string;
|
|
||||||
onboardingStatus: string;
|
|
||||||
mfaEnabled: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||||
PENDING_ONBOARDING: {
|
PENDING_ONBOARDING: {
|
||||||
@ -42,42 +36,27 @@ const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
|
|||||||
|
|
||||||
export const DirectoryPage: React.FC = () => {
|
export const DirectoryPage: React.FC = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
|
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
|
||||||
const [partners, setPartners] = useState<Partner[]>([]);
|
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
||||||
const [loadingPartners, setLoadingPartners] = useState(true);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const ITEMS_PER_PAGE = 5;
|
||||||
|
|
||||||
const fetchPartners = async () => {
|
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery();
|
||||||
setLoadingPartners(true);
|
const inviteMutation = useInvitePartnerMutation();
|
||||||
try {
|
|
||||||
const res = await axiosInstance.get('/auth/partners');
|
|
||||||
setPartners(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch partners', err);
|
|
||||||
} finally {
|
|
||||||
setLoadingPartners(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPartners();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInvite = async (e: React.FormEvent) => {
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
|
||||||
setInviteResult(null);
|
setInviteResult(null);
|
||||||
|
|
||||||
try {
|
inviteMutation.mutate(email, {
|
||||||
const res = await axiosInstance.post('/auth/invite', { email });
|
onSuccess: (data) => {
|
||||||
setInviteResult({ token: res.data.token });
|
setInviteResult({ token: data.token });
|
||||||
setEmail('');
|
setEmail('');
|
||||||
fetchPartners();
|
},
|
||||||
} catch (err: any) {
|
onError: (err: any) => {
|
||||||
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
||||||
} finally {
|
}
|
||||||
setIsSubmitting(false);
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
@ -102,47 +81,61 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalItems = partners.length;
|
||||||
|
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
|
||||||
|
const paginatedPartners = partners.slice(startIndex, endIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
|
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title="Partner Directory"
|
||||||
<div>
|
subtitle="Manage your network and invite new partners to the platform."
|
||||||
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight">
|
actions={
|
||||||
Partner Directory
|
<>
|
||||||
</h1>
|
<button
|
||||||
<p className="text-ink-500 text-sm mt-1">
|
onClick={() => {
|
||||||
Manage your network and invite new partners to the platform.
|
setInviteResult(null);
|
||||||
</p>
|
setEmail('');
|
||||||
</div>
|
setIsInviteOpen(true);
|
||||||
<button
|
}}
|
||||||
onClick={fetchPartners}
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all cursor-pointer shadow-sm"
|
||||||
className="p-2.5 rounded-xl bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-md transition-all group"
|
>
|
||||||
title="Refresh"
|
<UserPlus className="w-4 h-4" />
|
||||||
>
|
<span>Invite Partner</span>
|
||||||
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={() => { fetchPartners(); }}
|
||||||
|
className="p-1.5 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dynamic Real-time Approval Notification Banner */}
|
{/* Dynamic Real-time Approval Notification Banner */}
|
||||||
{counts.awaitingApproval > 0 && (
|
{counts.awaitingApproval > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex items-center justify-between p-5 bg-ink-900 border border-ink-800 rounded-2xl shadow-sm text-ink-0 relative overflow-hidden"
|
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
||||||
<div className="flex items-center gap-3 relative z-10">
|
<div className="flex items-center gap-3 relative z-10">
|
||||||
<div className="w-10 h-10 rounded-xl bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
||||||
<AlertCircle className="w-5 h-5 animate-bounce" />
|
<AlertCircle className="w-4 h-4 animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
|
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
|
||||||
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
|
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/approvals"
|
to="/admin/approvals"
|
||||||
className="relative z-10 px-4 py-2 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-xl transition-all shadow-sm flex items-center gap-1.5 shrink-0"
|
className="relative z-10 px-3 py-1.5 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-lg transition-all shadow-sm flex items-center gap-1.5 shrink-0"
|
||||||
>
|
>
|
||||||
Review Queue
|
Review Queue
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
@ -153,153 +146,225 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
{/* Stats Row */}
|
{/* Stats Row */}
|
||||||
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
|
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
|
||||||
{statCards.map((stat, i) => (
|
{statCards.map((stat, i) => (
|
||||||
<div key={i} className={`bg-ink-0 rounded-2xl border p-5 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
|
<div key={i} className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-500">{stat.label}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">{stat.label}</p>
|
||||||
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
|
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-3xl font-extrabold tracking-tight ${stat.accentClass ? 'text-ink-900' : 'text-ink-900'}`}>{stat.value}</p>
|
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
{/* Partner List Table (Full Width) */}
|
||||||
{/* Invite Form */}
|
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
|
||||||
<div className="lg:col-span-1">
|
<div className="overflow-x-auto">
|
||||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 p-6 shadow-sm relative overflow-hidden sticky top-8">
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
||||||
<UserPlus className="w-24 h-24 text-ink-900" />
|
<tr>
|
||||||
</div>
|
<th className="px-4 py-2.5">Partner</th>
|
||||||
|
<th className="px-4 py-2.5">Status</th>
|
||||||
|
<th className="px-4 py-2.5">MFA</th>
|
||||||
|
<th className="px-4 py-2.5">Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink-200">
|
||||||
|
{loadingPartners ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center">
|
||||||
|
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : partners.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center">
|
||||||
|
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="w-6 h-6 text-ink-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-ink-900">No partners yet</p>
|
||||||
|
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedPartners.map(partner => {
|
||||||
|
const sc = getStatusConfig(partner.onboardingStatus);
|
||||||
|
return (
|
||||||
|
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
||||||
|
{partner.email.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-ink-900 text-sm">{partner.email}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
|
||||||
|
{sc.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{partner.mfaEnabled ? (
|
||||||
|
<span className="text-xs font-bold text-ink-900">Enabled</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-bold text-ink-400">Disabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-ink-500 font-medium">
|
||||||
|
{new Date(partner.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-ink-900 mb-2 relative z-10">Invite Partner</h3>
|
{/* Pagination Controls */}
|
||||||
<p className="text-xs text-ink-500 mb-6 relative z-10">
|
{totalItems > 0 && (
|
||||||
Generate a secure invitation link for a new partner.
|
<div className="px-4 py-3 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500">
|
||||||
|
<p>
|
||||||
|
Showing <span className="text-ink-900 font-extrabold">{startIndex + 1}-{endIndex}</span> of <span className="text-ink-900 font-extrabold">{totalItems}</span> partners
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||||
|
const pageNum = idx + 1;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'bg-ink-900 text-ink-0 shadow-sm'
|
||||||
|
: 'border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
|
{/* Invite Modal Overlay */}
|
||||||
<div>
|
<AnimatePresence>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 mb-1.5 block">Email Address</label>
|
{isInviteOpen && (
|
||||||
<div className="relative">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<motion.div
|
||||||
<input
|
initial={{ opacity: 0 }}
|
||||||
type="email"
|
animate={{ opacity: 1 }}
|
||||||
value={email}
|
exit={{ opacity: 0 }}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onClick={() => setIsInviteOpen(false)}
|
||||||
placeholder="partner@company.com"
|
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm"
|
||||||
required
|
/>
|
||||||
className="w-full pl-9 pr-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all"
|
|
||||||
/>
|
<motion.div
|
||||||
</div>
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
|
||||||
|
className="bg-ink-0 border border-ink-200 rounded-xl shadow-xl p-5 max-w-md w-full relative z-10 overflow-hidden text-ink-900"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
|
||||||
|
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||||
|
<UserPlus className="w-24 h-24 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">Invite Partner</h3>
|
||||||
type="submit"
|
<p className="text-xs text-ink-500 mb-6 font-medium">
|
||||||
disabled={isSubmitting || !email}
|
Generate a secure invitation link for a new partner.
|
||||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50"
|
</p>
|
||||||
>
|
|
||||||
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
{!inviteResult?.token ? (
|
||||||
{inviteResult && (
|
<form onSubmit={handleInvite} className="space-y-4">
|
||||||
<motion.div
|
<div>
|
||||||
initial={{ opacity: 0, height: 0 }}
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Email Address</label>
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
<div className="relative">
|
||||||
className="mt-6 overflow-hidden"
|
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
>
|
<input
|
||||||
{inviteResult.error ? (
|
type="email"
|
||||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3">
|
value={email}
|
||||||
<AlertCircle className="w-5 h-5 text-red-650 shrink-0 mt-0.5" />
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="partner@company.com"
|
||||||
|
required
|
||||||
|
className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inviteResult?.error && (
|
||||||
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" />
|
||||||
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
|
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="p-4 bg-ink-100 border border-ink-300 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-ink-900" />
|
|
||||||
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
|
|
||||||
<div className="p-2 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900">
|
|
||||||
{window.location.origin}/invite?token={inviteResult.token}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Partner List */}
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<div className="lg:col-span-2">
|
<button
|
||||||
<div className="bg-ink-0 rounded-2xl border border-ink-200 shadow-sm overflow-hidden">
|
type="button"
|
||||||
<div className="overflow-x-auto">
|
onClick={() => setIsInviteOpen(false)}
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
className="flex-1 py-2.5 rounded-lg border border-ink-200 text-ink-700 font-bold text-sm hover:bg-ink-50 transition-all cursor-pointer"
|
||||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
>
|
||||||
<tr>
|
Cancel
|
||||||
<th className="px-6 py-4">Partner</th>
|
</button>
|
||||||
<th className="px-6 py-4">Status</th>
|
<button
|
||||||
<th className="px-6 py-4">MFA</th>
|
type="submit"
|
||||||
<th className="px-6 py-4">Joined</th>
|
disabled={inviteMutation.isPending || !email}
|
||||||
</tr>
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 cursor-pointer"
|
||||||
</thead>
|
>
|
||||||
<tbody className="divide-y divide-ink-200">
|
{inviteMutation.isPending ? 'Generating...' : 'Generate'}
|
||||||
{loadingPartners ? (
|
<ChevronRight className="w-4 h-4" />
|
||||||
<tr>
|
</button>
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
</div>
|
||||||
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
|
</form>
|
||||||
</td>
|
) : (
|
||||||
</tr>
|
<div className="space-y-6">
|
||||||
) : partners.length === 0 ? (
|
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
|
||||||
<tr>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
|
||||||
<Users className="w-6 h-6 text-ink-400" />
|
</div>
|
||||||
</div>
|
<p className="text-xs text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
|
||||||
<p className="text-sm font-bold text-ink-900">No partners yet</p>
|
<div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all">
|
||||||
<p className="text-xs text-ink-500 mt-1">Use the invite form to add your first partner.</p>
|
{window.location.origin}/invite?token={inviteResult.token}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
) : (
|
|
||||||
partners.map(partner => {
|
<button
|
||||||
const sc = getStatusConfig(partner.onboardingStatus);
|
type="button"
|
||||||
return (
|
onClick={() => {
|
||||||
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
|
setIsInviteOpen(false);
|
||||||
<td className="px-6 py-4">
|
setEmail('');
|
||||||
<div className="flex items-center gap-3">
|
setInviteResult(null);
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
|
}}
|
||||||
{partner.email.charAt(0).toUpperCase()}
|
className="w-full py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all cursor-pointer"
|
||||||
</div>
|
>
|
||||||
<span className="font-bold text-ink-900">{partner.email}</span>
|
Done
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-6 py-4">
|
)}
|
||||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
|
</motion.div>
|
||||||
{sc.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{partner.mfaEnabled ? (
|
|
||||||
<span className="text-xs font-bold text-ink-900">Enabled</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-bold text-ink-400">Disabled</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs text-ink-500 font-medium">
|
|
||||||
{new Date(partner.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { axiosInstance } from '../../services/axios';
|
import { axiosInstance } from '../../services/axios';
|
||||||
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
|
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
|
||||||
|
import PageHeader from '../../components/ui/PageHeader';
|
||||||
|
|
||||||
interface LegalDoc {
|
interface LegalDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@ -125,60 +126,60 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1400px] mx-auto w-full p-4 md:p-8 animate-fade-in text-ink-900">
|
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
<PageHeader
|
||||||
<div>
|
title="Legal Agreements"
|
||||||
<div className="flex items-center gap-2 mb-2">
|
subtitle="Configure active documents required during partner onboarding."
|
||||||
<Shield className="w-5 h-5 text-ink-400" />
|
badge={
|
||||||
<span className="text-[10px] uppercase font-bold text-ink-500 tracking-wider">Compliance Panel</span>
|
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-ink-950" />
|
||||||
|
<span>Compliance Panel</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-extrabold text-ink-900 tracking-tight">Legal Agreements</h1>
|
}
|
||||||
<p className="text-sm font-medium text-ink-500 mt-1">Configure active documents required during partner onboarding.</p>
|
actions={
|
||||||
</div>
|
<div className="inline-flex p-1 rounded-lg bg-ink-100 border border-ink-200 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('NDA')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||||
|
>
|
||||||
|
Non-Disclosure (NDA)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('MSA')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
||||||
|
>
|
||||||
|
Master Services (MSA)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tab switchers */}
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="inline-flex p-1 rounded-xl bg-ink-100 border border-ink-200">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('NDA')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
|
||||||
>
|
|
||||||
Non-Disclosure (NDA)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('MSA')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
|
|
||||||
>
|
|
||||||
Master Services (MSA)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* Left Side: Active Status Details */}
|
{/* Left Side: Active Status Details */}
|
||||||
<div className="lg:col-span-1 flex flex-col gap-6">
|
<div className="lg:col-span-1 flex flex-col gap-6">
|
||||||
<div className="p-6 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl">
|
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
|
||||||
<h2 className="text-lg font-bold text-ink-900 mb-4">Active Configuration</h2>
|
<h2 className="text-base font-bold text-ink-900 mb-4">Active Configuration</h2>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<RefreshCw className="w-8 h-8 text-ink-400 animate-spin mb-3" />
|
<RefreshCw className="w-6 h-6 text-ink-400 animate-spin mb-3" />
|
||||||
<span className="text-xs font-bold text-ink-400">Loading document state...</span>
|
<span className="text-xs font-bold text-ink-400">Loading document state...</span>
|
||||||
</div>
|
</div>
|
||||||
) : currentDoc ? (
|
) : currentDoc ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-3 rounded-xl bg-ink-50 border border-ink-100">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-ink-50 border border-ink-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider">Version</p>
|
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider">Version</p>
|
||||||
<p className="text-lg font-extrabold text-ink-900">v{currentDoc.version}</p>
|
<p className="text-base font-extrabold text-ink-900">v{currentDoc.version}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
|
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
|
||||||
{currentDoc.pdfUrl ? (
|
{currentDoc.pdfUrl ? (
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-ink-900">
|
<div className="flex items-center gap-2 text-xs font-semibold text-ink-900">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
@ -194,12 +195,12 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
|
|
||||||
{currentDoc.pdfUrl && (
|
{currentDoc.pdfUrl && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
|
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
|
||||||
<a
|
<a
|
||||||
href={`${fileHost}${currentDoc.pdfUrl}`}
|
href={`${fileHost}${currentDoc.pdfUrl}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between p-3.5 rounded-xl border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
className="flex items-center justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
|
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
|
||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
@ -208,8 +209,8 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-4 border-t border-ink-100">
|
<div className="pt-4 border-t border-ink-100">
|
||||||
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
|
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
|
||||||
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-3 rounded-lg border border-ink-100">
|
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-2.5 rounded-lg border border-ink-100">
|
||||||
{currentDoc.content}
|
{currentDoc.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -226,39 +227,39 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Right Side: Publish New Revision form */}
|
{/* Right Side: Publish New Revision form */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<form onSubmit={handleFormSubmit} className="p-6 md:p-8 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl flex flex-col gap-6">
|
<form onSubmit={handleFormSubmit} className="p-4 md:p-5 bg-ink-0 border border-ink-200 shadow-sm rounded-xl flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-ink-900">Publish New Revision</h2>
|
<h2 className="text-base font-bold text-ink-900">Publish New Revision</h2>
|
||||||
<p className="text-xs text-ink-500 mt-1">
|
<p className="text-xs text-ink-500 mt-1">
|
||||||
Saving a new template automatically deactivates the current active version.
|
Saving a new template automatically deactivates the current active version.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`p-4 rounded-xl flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
|
<div className={`p-3 rounded-lg flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
|
||||||
{message.type === 'success' && <CheckCircle className="w-5 h-5 shrink-0" />}
|
{message.type === 'success' && <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||||
<p className="text-xs font-semibold">{message.text}</p>
|
<p className="text-xs font-semibold">{message.text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Version String</label>
|
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. 1.1"
|
placeholder="e.g. 1.1"
|
||||||
value={version}
|
value={version}
|
||||||
onChange={(e) => setVersion(e.target.value)}
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 shadow-sm font-semibold"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-3">
|
||||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">PDF Template (Optional)</label>
|
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">PDF Template (Optional)</label>
|
||||||
<div
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
|
className="flex items-center gap-3 p-2 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg bg-ink-0 border border-ink-200 flex items-center justify-center text-ink-500">
|
<div className="w-8 h-8 rounded-lg bg-ink-0 border border-ink-200 flex items-center justify-center text-ink-500">
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
@ -281,13 +282,13 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Text Content Fallback</label>
|
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Text Content Fallback</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={8}
|
rows={6}
|
||||||
placeholder="Insert standard text content here..."
|
placeholder="Insert standard text content here..."
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed"
|
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed shadow-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-ink-500 font-medium mt-1.5">
|
<p className="text-[10px] text-ink-500 font-medium mt-1.5">
|
||||||
@ -295,11 +296,11 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
|
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || uploadingPdf}
|
disabled={submitting || uploadingPdf}
|
||||||
className="px-5 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm"
|
className="px-4 py-2 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
|
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
|||||||
54
Channel-Frontend/src/services/assets-api.ts
Normal file
54
Channel-Frontend/src/services/assets-api.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { axiosInstance } from './axios';
|
||||||
|
import type { Asset, Organization } from '../types/assets';
|
||||||
|
|
||||||
|
export const getAssets = async (): Promise<Asset[]> => {
|
||||||
|
const response = await axiosInstance.get<Asset[]>('/assets');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrganizations = async (): Promise<Organization[]> => {
|
||||||
|
const response = await axiosInstance.get<Organization[]>('/organizations');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadAsset = async (formData: FormData): Promise<void> => {
|
||||||
|
await axiosInstance.post('/assets/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAsset = async (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
tags?: string[];
|
||||||
|
githubUrl?: string;
|
||||||
|
isDownloadable?: boolean;
|
||||||
|
shares?: Array<{ organizationId: string; userId: string | null }>;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await axiosInstance.patch(`/assets/${id}`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAsset = async (id: string): Promise<void> => {
|
||||||
|
await axiosInstance.delete(`/assets/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestDownload = async (id: string): Promise<void> => {
|
||||||
|
await axiosInstance.post(`/assets/${id}/request-download`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approveDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
|
||||||
|
await axiosInstance.post(`/assets/${assetId}/approve-download/${requestId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rejectDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
|
||||||
|
await axiosInstance.post(`/assets/${assetId}/reject-download/${requestId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAssetFile = async (id: string): Promise<void> => {
|
||||||
|
await axiosInstance.post(`/assets/${id}/download`);
|
||||||
|
};
|
||||||
@ -5,8 +5,22 @@ export const AUTH_API_ROUTES = {
|
|||||||
login: "/auth/login",
|
login: "/auth/login",
|
||||||
refresh: "/auth/refresh",
|
refresh: "/auth/refresh",
|
||||||
logout: "/auth/logout",
|
logout: "/auth/logout",
|
||||||
|
partners: "/auth/partners",
|
||||||
|
invite: "/auth/invite",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Partner {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
onboardingStatus: string;
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
|
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
|
||||||
const response = await axiosInstance.post<AuthResponse>(
|
const response = await axiosInstance.post<AuthResponse>(
|
||||||
AUTH_API_ROUTES.login,
|
AUTH_API_ROUTES.login,
|
||||||
@ -21,3 +35,18 @@ export const refreshAuthToken = async (): Promise<AuthResponse> => {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPartners = async (): Promise<Partner[]> => {
|
||||||
|
const response = await axiosInstance.get<Partner[]>(
|
||||||
|
AUTH_API_ROUTES.partners
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invitePartner = async (email: string): Promise<InviteResponse> => {
|
||||||
|
const response = await axiosInstance.post<InviteResponse>(
|
||||||
|
AUTH_API_ROUTES.invite,
|
||||||
|
{ email }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
76
Channel-Frontend/src/services/legal-api.ts
Normal file
76
Channel-Frontend/src/services/legal-api.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { axiosInstance } from "./axios";
|
||||||
|
|
||||||
|
export interface PendingPartner {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
acceptances: Array<{
|
||||||
|
id: string;
|
||||||
|
signatureHash: string | null;
|
||||||
|
documentUrl: string | null;
|
||||||
|
document: {
|
||||||
|
type: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalDocument {
|
||||||
|
id: string;
|
||||||
|
type: 'NDA' | 'MSA';
|
||||||
|
content: string;
|
||||||
|
pdfUrl?: string | null;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalAcceptance {
|
||||||
|
id: string;
|
||||||
|
document: {
|
||||||
|
id: string;
|
||||||
|
type: 'NDA' | 'MSA';
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEGAL_API_ROUTES = {
|
||||||
|
pending: "/legal/pending",
|
||||||
|
approve: (partnerId: string) => `/legal/approve/${partnerId}`,
|
||||||
|
activeDoc: (type: 'NDA' | 'MSA') => `/legal/documents/active/${type}`,
|
||||||
|
myAcceptances: "/legal/my-acceptances",
|
||||||
|
upload: "/legal/upload",
|
||||||
|
sign: "/legal/sign",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPendingPartners = async (): Promise<PendingPartner[]> => {
|
||||||
|
const response = await axiosInstance.get<PendingPartner[]>(LEGAL_API_ROUTES.pending);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approvePartnerAccess = async (partnerId: string): Promise<void> => {
|
||||||
|
await axiosInstance.post<void>(LEGAL_API_ROUTES.approve(partnerId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveLegalDocument = async (type: 'NDA' | 'MSA'): Promise<LegalDocument> => {
|
||||||
|
const response = await axiosInstance.get<LegalDocument>(LEGAL_API_ROUTES.activeDoc(type));
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyAcceptances = async (): Promise<LegalAcceptance[]> => {
|
||||||
|
const response = await axiosInstance.get<LegalAcceptance[]>(LEGAL_API_ROUTES.myAcceptances);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadSignedLegalDocument = async (formData: FormData): Promise<{ url: string }> => {
|
||||||
|
const response = await axiosInstance.post<{ url: string }>(LEGAL_API_ROUTES.upload, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signLegalDocument = async (payload: {
|
||||||
|
documentType: 'NDA' | 'MSA';
|
||||||
|
signatureBase64: string | null;
|
||||||
|
documentUrl: string | null;
|
||||||
|
}): Promise<void> => {
|
||||||
|
await axiosInstance.post(LEGAL_API_ROUTES.sign, payload);
|
||||||
|
};
|
||||||
66
Channel-Frontend/src/types/assets.ts
Normal file
66
Channel-Frontend/src/types/assets.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export interface SharedWithOrg {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string | null;
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadRequest {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
asset?: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
version: number;
|
||||||
|
uploadedBy: string;
|
||||||
|
description: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
subcategory: string | null;
|
||||||
|
tags: string[];
|
||||||
|
downloadsCount: number;
|
||||||
|
githubUrl: string | null;
|
||||||
|
status: string;
|
||||||
|
isDownloadable: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
sharedWith?: SharedWithOrg[];
|
||||||
|
downloadRequests?: DownloadRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
users?: OrgUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareItem {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user