Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New documents screen #1

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/AI_coding_standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Prefer radix components over other libraries
- [Table](../server/src/components/ui/Table.tsx)
- [TextArea](../server/src/components/ui/TextArea.tsx)

## Server Communication

We use server actions that are located in the `/server/src/lib/actions` folder.

# Database
server migrations are stored in the `/server/migrations` folder.
seeds are stored in the `/server/seeds` folder.
Expand Down
173 changes: 173 additions & 0 deletions server/src/app/msp/documents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"use client";

import { useState, useEffect, KeyboardEvent } from 'react';
import { IDocument } from '../../../interfaces/document.interface';
import Documents from '../../../components/documents/Documents';
import { Card } from '../../../components/ui/Card';
import { Input } from '../../../components/ui/Input';
import CustomSelect from '../../../components/ui/CustomSelect';
import { SelectOption } from '../../../components/ui/Select';
import { getAllDocuments } from '../../../lib/actions/document-actions/documentActions';
import { toast } from 'react-hot-toast';

export default function DocumentsPage() {
const [documents, setDocuments] = useState<IDocument[]>([]);
const [isLoading, setIsLoading] = useState(false);

const [filterInputs, setFilterInputs] = useState({
type: 'all',
entityType: '',
searchTerm: ''
});

const documentTypes: SelectOption[] = [
{ value: 'all', label: 'All Document Types' },
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image', label: 'Images' },
{ value: 'text', label: 'Documents' },
{ value: 'application', label: 'Other' }
];

const entityTypes: SelectOption[] = [
{ value: 'ticket', label: 'Tickets' },
{ value: 'client', label: 'Clients' },
{ value: 'contact', label: 'Contacts' },
{ value: 'project', label: 'Projects' }
];

const handleSearch = async () => {
try {
setIsLoading(true);
// Only include type in filters if it's not 'all'
const searchFilters = {
...filterInputs,
type: filterInputs.type === 'all' ? '' : filterInputs.type
};
const docs = await getAllDocuments(searchFilters);
setDocuments(docs);
} catch (error) {
console.error('Error fetching documents:', error);
toast.error('Failed to fetch documents');
} finally {
setIsLoading(false);
}
};

// Run initial search on component mount
useEffect(() => {
handleSearch();
}, []); // Empty dependency array means this runs once on mount

const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};

const handleDocumentUpdate = async () => {
try {
setIsLoading(true);
const searchFilters = {
...filterInputs,
type: filterInputs.type === 'all' ? '' : filterInputs.type
};
const updatedDocs = await getAllDocuments(searchFilters);
setDocuments(updatedDocs);
} catch (error) {
console.error('Error refreshing documents:', error);
toast.error('Failed to refresh documents');
} finally {
setIsLoading(false);
}
};

const handleClearFilters = () => {
setFilterInputs({
type: 'all',
entityType: '',
searchTerm: ''
});
handleSearch();
};

return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Documents</h1>
</div>

<div className="flex gap-6">
{/* Left Column - Filters */}
<div className="w-80">
<Card className="p-4 sticky top-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search Documents
</label>
<Input
placeholder="Search by document name..."
value={filterInputs.searchTerm}
onChange={(e) => setFilterInputs({ ...filterInputs, searchTerm: e.target.value })}
onKeyPress={handleKeyPress}
/>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Document Type
</label>
<CustomSelect
options={documentTypes}
value={filterInputs.type}
onValueChange={(value: string) => {
setFilterInputs({ ...filterInputs, type: value });
handleSearch();
}}
/>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Entity Type
</label>
<CustomSelect
options={entityTypes}
value={filterInputs.entityType}
onValueChange={(value: string) => {
setFilterInputs({ ...filterInputs, entityType: value });
handleSearch();
}}
placeholder="All Entities"
/>
</div>

<div className="pt-4">
<button
onClick={handleClearFilters}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Clear Filters
</button>
</div>
</div>
</Card>
</div>

{/* Right Column - Documents */}
<div className="flex-1">
<Card className="p-4">
<Documents
documents={documents}
gridColumns={3}
userId="current-user-id"
filters={filterInputs}
isLoading={isLoading}
onDocumentCreated={handleDocumentUpdate}
/>
</Card>
</div>
</div>
</div>
);
}
85 changes: 34 additions & 51 deletions server/src/components/documents/Documents.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { useState, useEffect } from 'react';
import { useState } from 'react';
import { IDocument, IDocumentUploadResponse } from '../../interfaces/document.interface';
import DocumentStorageCard from './DocumentStorageCard';
import DocumentUpload from './DocumentUpload';
import DocumentsPagination from './DocumentsPagination';
import { getDocumentByCompanyId, deleteDocument } from '../../lib/actions/document-actions/documentActions';
import { getAllDocuments, deleteDocument } from '../../lib/actions/document-actions/documentActions';
import { toast } from 'react-hot-toast';

interface DocumentsProps {
Expand All @@ -14,40 +14,31 @@ interface DocumentsProps {
userId: string;
companyId?: string;
onDocumentCreated?: (document: IDocument) => void;
filters?: {
type?: string;
entityType?: string;
uploadedBy?: string;
searchTerm?: string;
};
isLoading?: boolean;
}

const Documents = ({ documents: initialDocuments, gridColumns, userId, companyId, onDocumentCreated }: DocumentsProps): JSX.Element => {
const [documents, setDocuments] = useState<IDocument[]>(initialDocuments);
const [searchTerm, setSearchTerm] = useState('');
const Documents = ({
documents,
gridColumns,
userId,
companyId,
onDocumentCreated,
filters,
isLoading = false
}: DocumentsProps): JSX.Element => {
const [showUpload, setShowUpload] = useState(false);

// Fetch latest documents whenever component mounts or companyId changes
useEffect(() => {
const fetchLatestDocuments = async () => {
if (companyId) {
try {
const latestDocuments = await getDocumentByCompanyId(companyId);
setDocuments(latestDocuments);
} catch (error) {
console.error('Error fetching documents:', error);
toast.error('Failed to fetch documents');
}
}
};

fetchLatestDocuments();
}, [companyId]);

// Set grid columns based on the number of columns
const gridColumnsClass = gridColumns === 4 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';

// Set filter based on search term
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>): void => {
setSearchTerm(e.target.value);
};

// Handle file upload completion
const handleUploadComplete = (fileData: IDocumentUploadResponse) => {
const handleUploadComplete = async (fileData: IDocumentUploadResponse) => {
setShowUpload(false);
const newDocument: IDocument = {
document_id: '', // This will be set by the server
Expand All @@ -65,49 +56,34 @@ const Documents = ({ documents: initialDocuments, gridColumns, userId, companyId
file_size: fileData.file_size
};

// Update local state with the new document
setDocuments(prevDocuments => [...prevDocuments, newDocument]);

// Call the parent callback if provided
if (onDocumentCreated) {
onDocumentCreated(newDocument);
}

// Parent will handle refreshing the documents list
};

// Handle document deletion
const handleDelete = async (document: IDocument) => {
try {
if (document.document_id) {
await deleteDocument(document.document_id, userId);
// Update local state to remove the deleted document
setDocuments(prevDocuments =>
prevDocuments.filter(doc => doc.document_id !== document.document_id)
);
toast.success('Document deleted successfully');
// Trigger a refresh in the parent component
if (onDocumentCreated) {
onDocumentCreated(document); // Reuse this callback to trigger refresh
}
}
} catch (error) {
console.error('Error deleting document:', error);
toast.error('Failed to delete document. Please try again.');
}
};

// Filter documents based on document name search term
const filteredDocuments = documents.filter(doc =>
doc.document_name.toLowerCase().includes(searchTerm.toLowerCase())
);

return (
<div>
<div className="flex justify-between items-center mb-3 space-x-4 flex-wrap">
{/* Search */}
<input
type="text"
placeholder="Search"
className="px-4 py-1 border border-gray-300 rounded-md w-48"
value={searchTerm}
onChange={handleSearch}
/>

<div className="flex justify-between items-center mb-3">
{/* New document button */}
<button
className="bg-[#6941C6] text-white px-4 py-1 rounded-md whitespace-nowrap"
Expand All @@ -128,9 +104,16 @@ const Documents = ({ documents: initialDocuments, gridColumns, userId, companyId
</div>
)}

{/* Loading State */}
{isLoading && (
<div className="flex justify-center items-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#6941C6]"></div>
</div>
)}

{/* Documents */}
<div className={`grid ${gridColumnsClass} gap-2 items-start`}>
{filteredDocuments.map((document: IDocument): JSX.Element => (
{!isLoading && documents.map((document: IDocument): JSX.Element => (
<DocumentStorageCard
key={document.document_id || document.file_id}
document={document}
Expand Down
40 changes: 40 additions & 0 deletions server/src/components/ui/DateRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { Label } from './Label';
import { Input } from './Input';

interface DateRange {
from: string;
to: string;
}

interface DateRangePickerProps {
label?: string;
value: DateRange;
onChange: (range: DateRange) => void;
}

export const DateRangePicker: React.FC<DateRangePickerProps> = ({
label,
value,
onChange
}) => {
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<div className="flex gap-2">
<Input
type="date"
value={value.from}
onChange={(e) => onChange({ ...value, from: e.target.value })}
placeholder="From"
/>
<Input
type="date"
value={value.to}
onChange={(e) => onChange({ ...value, to: e.target.value })}
placeholder="To"
/>
</div>
</div>
);
};
Loading
Loading