correspondents
This commit is contained in:
+630
-6
@@ -208,6 +208,7 @@ const computeStackAngle = (docId, index) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
|
||||||
|
|
||||||
const PreviewStack = ({
|
const PreviewStack = ({
|
||||||
items = [],
|
items = [],
|
||||||
@@ -305,6 +306,9 @@ const DetailPanel = ({
|
|||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
|
correspondents = [],
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedDocuments.length;
|
const selectedCount = selectedDocuments.length;
|
||||||
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
|
||||||
@@ -471,6 +475,26 @@ const DetailPanel = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [stackPreviews, selectedDocuments]);
|
}, [stackPreviews, selectedDocuments]);
|
||||||
|
|
||||||
|
const availableCorrespondents = useMemo(
|
||||||
|
() => (Array.isArray(correspondents) ? correspondents : []),
|
||||||
|
[correspondents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const correspondentOptions = useMemo(() => {
|
||||||
|
const seen = new Set();
|
||||||
|
return availableCorrespondents
|
||||||
|
.map((entry) => (entry?.name || '').trim())
|
||||||
|
.filter((name) => {
|
||||||
|
if (!name) return false;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (seen.has(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(lower);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [availableCorrespondents]);
|
||||||
|
|
||||||
const renderSingle = () => {
|
const renderSingle = () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
@@ -489,6 +513,20 @@ const DetailPanel = ({
|
|||||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||||
const metadata =
|
const metadata =
|
||||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||||
|
const docCorrespondents = Array.isArray(singleDoc.correspondents)
|
||||||
|
? singleDoc.correspondents
|
||||||
|
: [];
|
||||||
|
const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
|
||||||
|
const roleA = (a.role || '').toLowerCase();
|
||||||
|
const roleB = (b.role || '').toLowerCase();
|
||||||
|
const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
|
||||||
|
const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
|
||||||
|
if (indexA !== indexB) {
|
||||||
|
return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
|
||||||
|
(indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
|
||||||
|
}
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -642,6 +680,80 @@ const DetailPanel = ({
|
|||||||
</datalist>
|
</datalist>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Correspondents</dt>
|
||||||
|
<div className="correspondent-list">
|
||||||
|
{sortedCorrespondents.length ? (
|
||||||
|
sortedCorrespondents.map((entry) => {
|
||||||
|
const roleLabel = entry.role
|
||||||
|
? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
|
||||||
|
: 'Other';
|
||||||
|
return (
|
||||||
|
<span key={`${entry.id}:${entry.role}`} className="correspondent-pill">
|
||||||
|
<span className="correspondent-pill__label">
|
||||||
|
<strong>{roleLabel}</strong>
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() =>
|
||||||
|
onCorrespondentRemove?.({
|
||||||
|
documentId: singleDoc.id,
|
||||||
|
correspondentId: entry.id,
|
||||||
|
role: entry.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={`Remove ${entry.name} as ${roleLabel}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="meta">No correspondents yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="correspondent-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const nameInput = form.elements.correspondent;
|
||||||
|
const roleSelect = form.elements.role;
|
||||||
|
const value = nameInput.value.trim();
|
||||||
|
const role = roleSelect.value;
|
||||||
|
if (!value) return;
|
||||||
|
onCorrespondentAdd?.({
|
||||||
|
document: singleDoc,
|
||||||
|
name: value,
|
||||||
|
role,
|
||||||
|
input: nameInput,
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="correspondent"
|
||||||
|
placeholder="Add or create correspondent"
|
||||||
|
list="correspondent-catalog"
|
||||||
|
/>
|
||||||
|
<select name="role" defaultValue={CORRESPONDENT_ROLES[0]}>
|
||||||
|
{CORRESPONDENT_ROLES.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
<datalist id="correspondent-catalog">
|
||||||
|
{correspondentOptions.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<div>
|
<div>
|
||||||
<dt>Metadata</dt>
|
<dt>Metadata</dt>
|
||||||
@@ -842,7 +954,7 @@ const PreviewWorkspace = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
|
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [draftLabel, setDraftLabel] = useState('');
|
const [draftLabel, setDraftLabel] = useState('');
|
||||||
const [draftColor, setDraftColor] = useState('');
|
const [draftColor, setDraftColor] = useState('');
|
||||||
@@ -1091,7 +1203,244 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function CorrespondentsPanel({
|
||||||
|
correspondents = [],
|
||||||
|
onRefresh,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onNotify,
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [draftName, setDraftName] = useState('');
|
||||||
|
const [createName, setCreateName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
|
||||||
|
const startEdit = useCallback((correspondent) => {
|
||||||
|
setEditingId(correspondent.id);
|
||||||
|
setDraftName(correspondent.name || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEdit = useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDraftName('');
|
||||||
|
setSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
const trimmed = draftName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onUpdate(editingId, { name: trimmed });
|
||||||
|
cancelEdit();
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (correspondent) => {
|
||||||
|
if (!correspondent?.id) return;
|
||||||
|
setDeletingId(correspondent.id);
|
||||||
|
try {
|
||||||
|
await onDelete(correspondent.id);
|
||||||
|
if (editingId === correspondent.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDelete, editingId, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = createName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
onNotify?.('Correspondent name cannot be empty.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await onCreate({ name: trimmed });
|
||||||
|
setCreateName('');
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createName, onCreate, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, cancelEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderUsage = useCallback((usage) => {
|
||||||
|
if (!usage) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
const total = typeof usage.total === 'number' ? usage.total : 0;
|
||||||
|
const entries = usage.by_role ? Object.entries(usage.by_role) : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
return total.toString();
|
||||||
|
}
|
||||||
|
const roleSummary = entries
|
||||||
|
.map(([role, count]) => `${role}: ${count}`)
|
||||||
|
.join(', ');
|
||||||
|
return `${total} (${roleSummary})`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="correspondents-panel column">
|
||||||
|
<div className="column-header">
|
||||||
|
<div className="column-header__titles">
|
||||||
|
<h2>Correspondents</h2>
|
||||||
|
<div className="column-subtitle">{correspondents.length} total</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions correspondents-actions">
|
||||||
|
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New correspondent name"
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creating || !createName.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={saving || creating || Boolean(deletingId)}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="column-body tags-panel__body">
|
||||||
|
{correspondents.length === 0 ? (
|
||||||
|
<div className="empty-state">No correspondents created yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col" className="numeric">
|
||||||
|
Usage
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="actions">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{correspondents.map((correspondent) => {
|
||||||
|
const isEditing = editingId === correspondent.id;
|
||||||
|
return (
|
||||||
|
<tr key={correspondent.id} className={isEditing ? 'editing' : ''}>
|
||||||
|
<td className="tags-table__label">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="tags-table__label-input"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(event) => setDraftName(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={saving}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{correspondent.name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="numeric">{renderUsage(correspondent.usage)}</td>
|
||||||
|
<td className="actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="tags-table__edit-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tags-table__row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => startEdit(correspondent)}
|
||||||
|
disabled={deletingId === correspondent.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDelete(correspondent)}
|
||||||
|
disabled={deletingId === correspondent.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
|
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
|
||||||
<main className={className}>
|
<main className={className}>
|
||||||
@@ -1175,6 +1524,7 @@ const AppLayout = () => {
|
|||||||
}, [appDispatch]);
|
}, [appDispatch]);
|
||||||
const [searchResults, setSearchResults] = useState(null);
|
const [searchResults, setSearchResults] = useState(null);
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
|
const [correspondents, setCorrespondents] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
||||||
@@ -1282,6 +1632,7 @@ const AppLayout = () => {
|
|||||||
setDraggedFolderId(null);
|
setDraggedFolderId(null);
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
|
setCorrespondents([]);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||||
@@ -1318,6 +1669,16 @@ const AppLayout = () => {
|
|||||||
return map;
|
return map;
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
|
const correspondentLookupByName = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
correspondents.forEach((correspondent) => {
|
||||||
|
if (correspondent?.name) {
|
||||||
|
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [correspondents]);
|
||||||
|
|
||||||
const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => {
|
const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => {
|
||||||
const nextSet = new Set(nextSelection);
|
const nextSet = new Set(nextSelection);
|
||||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||||
@@ -2010,6 +2371,15 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [api, notifyApiError]);
|
}, [api, notifyApiError]);
|
||||||
|
|
||||||
|
const refreshCorrespondents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/correspondents');
|
||||||
|
setCorrespondents(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, 'Unable to load correspondents.');
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError]);
|
||||||
|
|
||||||
const handleTagUpdate = useCallback(
|
const handleTagUpdate = useCallback(
|
||||||
async (tagId, changes) => {
|
async (tagId, changes) => {
|
||||||
if (!tagId) {
|
if (!tagId) {
|
||||||
@@ -2062,6 +2432,213 @@ const AppLayout = () => {
|
|||||||
[api, refreshTags, notifyApiError],
|
[api, refreshTags, notifyApiError],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentUpdate = useCallback(
|
||||||
|
async (correspondentId, changes) => {
|
||||||
|
if (!correspondentId) {
|
||||||
|
throw new Error('Missing correspondent identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (typeof changes.name === 'string') {
|
||||||
|
const trimmed = changes.name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Correspondent name cannot be empty.');
|
||||||
|
}
|
||||||
|
payload.name = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/correspondents/${correspondentId}`, payload);
|
||||||
|
await refreshCorrespondents();
|
||||||
|
setStatusMessage('Correspondent updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, refreshCorrespondents, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentCreate = useCallback(
|
||||||
|
async ({ name }) => {
|
||||||
|
const trimmed = (name || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Correspondent name is required.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/correspondents', { name: trimmed });
|
||||||
|
await refreshCorrespondents();
|
||||||
|
setStatusMessage('Correspondent created.', 'success');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to create correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, refreshCorrespondents, notifyApiError, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCorrespondentDelete = useCallback(
|
||||||
|
async (correspondentId) => {
|
||||||
|
if (!correspondentId) {
|
||||||
|
throw new Error('Missing correspondent identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripFromDoc = (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
|
||||||
|
if (next.length === doc.correspondents.length) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, correspondents: next };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/correspondents/${correspondentId}`);
|
||||||
|
await refreshCorrespondents();
|
||||||
|
|
||||||
|
setDocuments((prev) => prev.map((doc) => stripFromDoc(doc)));
|
||||||
|
setSearchResults((prev) =>
|
||||||
|
Array.isArray(prev) ? prev.map((doc) => stripFromDoc(doc)) : prev,
|
||||||
|
);
|
||||||
|
setFolderContents((prev) => {
|
||||||
|
if (!prev.size) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map();
|
||||||
|
prev.forEach((contents, key) => {
|
||||||
|
if (!Array.isArray(contents?.documents)) {
|
||||||
|
next.set(key, contents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updatedDocs = contents.documents.map((doc) => stripFromDoc(doc));
|
||||||
|
const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]);
|
||||||
|
if (mutated) {
|
||||||
|
changed = true;
|
||||||
|
next.set(key, { ...contents, documents: updatedDocs });
|
||||||
|
} else {
|
||||||
|
next.set(key, contents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMessage('Correspondent deleted.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to delete correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
refreshCorrespondents,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
setDocuments,
|
||||||
|
setSearchResults,
|
||||||
|
setFolderContents,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleDocumentCorrespondentAttach({ documentId, correspondentId, role }) {
|
||||||
|
if (!documentId || !correspondentId || !role) {
|
||||||
|
throw new Error('Missing document, correspondent or role.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(`/documents/${documentId}/correspondents`, {
|
||||||
|
assignments: [{ correspondent_id: correspondentId, role }],
|
||||||
|
replace: false,
|
||||||
|
});
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
setStatusMessage('Correspondent assigned.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCorrespondentRemove({ documentId, correspondentId, role }) {
|
||||||
|
if (!documentId || !correspondentId || !role) {
|
||||||
|
throw new Error('Missing document, correspondent or role.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, {
|
||||||
|
params: { role },
|
||||||
|
});
|
||||||
|
await refreshCurrentFolder();
|
||||||
|
setStatusMessage('Correspondent removed.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCorrespondentAdd = useCallback(
|
||||||
|
async ({ document, name, role, input }) => {
|
||||||
|
if (!document?.id) {
|
||||||
|
throw new Error('Missing document for correspondent assignment.');
|
||||||
|
}
|
||||||
|
const trimmed = (name || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatusMessage('Correspondent name is required.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedRole = (role || '').trim().toLowerCase();
|
||||||
|
if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
|
||||||
|
setStatusMessage('Select a valid correspondent role.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||||
|
if (!target) {
|
||||||
|
try {
|
||||||
|
target = await handleCorrespondentCreate({ name: trimmed });
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target?.id) {
|
||||||
|
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleDocumentCorrespondentAttach({
|
||||||
|
documentId: document.id,
|
||||||
|
correspondentId: target.id,
|
||||||
|
role: normalizedRole,
|
||||||
|
});
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
correspondentLookupByName,
|
||||||
|
setStatusMessage,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTagDelete = useCallback(
|
const handleTagDelete = useCallback(
|
||||||
async (tagId) => {
|
async (tagId) => {
|
||||||
if (!tagId) {
|
if (!tagId) {
|
||||||
@@ -2197,7 +2774,7 @@ const AppLayout = () => {
|
|||||||
const initializeAfterLogin = useCallback(async () => {
|
const initializeAfterLogin = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await refreshTags();
|
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||||||
await loadFolder(initialFolder, { showLoading: false });
|
await loadFolder(initialFolder, { showLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2206,7 +2783,7 @@ const AppLayout = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [refreshTags, routeFolderId, loadFolder, notifyApiError]);
|
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -4265,6 +4842,8 @@ const AppLayout = () => {
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onShowTags: () => navigate('/tags'),
|
onShowTags: () => navigate('/tags'),
|
||||||
tags,
|
tags,
|
||||||
|
onShowCorrespondents: () => navigate('/correspondents'),
|
||||||
|
correspondents,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveThumbnailUrlForDoc = useCallback(
|
const resolveThumbnailUrlForDoc = useCallback(
|
||||||
@@ -4341,6 +4920,9 @@ const AppLayout = () => {
|
|||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd: handleCorrespondentAdd,
|
||||||
|
onCorrespondentRemove: handleCorrespondentRemove,
|
||||||
};
|
};
|
||||||
|
|
||||||
const skeuoWorkspaceProps = useMemo(
|
const skeuoWorkspaceProps = useMemo(
|
||||||
@@ -4394,6 +4976,14 @@ const AppLayout = () => {
|
|||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleTagDelete,
|
handleTagDelete,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
|
correspondents,
|
||||||
|
refreshCorrespondents,
|
||||||
|
handleCorrespondentUpdate,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleCorrespondentDelete,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
handleCorrespondentAdd,
|
||||||
previewActive,
|
previewActive,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
@@ -4421,6 +5011,14 @@ const AppLayout = () => {
|
|||||||
handleTagCreate,
|
handleTagCreate,
|
||||||
handleTagDelete,
|
handleTagDelete,
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
|
correspondents,
|
||||||
|
refreshCorrespondents,
|
||||||
|
handleCorrespondentUpdate,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleCorrespondentDelete,
|
||||||
|
handleDocumentCorrespondentAttach,
|
||||||
|
handleCorrespondentRemove,
|
||||||
|
handleCorrespondentAdd,
|
||||||
previewActive,
|
previewActive,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
@@ -4657,7 +5255,7 @@ const LoginRoute = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagsRoute = () => {
|
function TagsRoute() {
|
||||||
const {
|
const {
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
tags,
|
tags,
|
||||||
@@ -4677,7 +5275,32 @@ const TagsRoute = () => {
|
|||||||
/>
|
/>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function CorrespondentsRoute() {
|
||||||
|
const {
|
||||||
|
sidebarProps,
|
||||||
|
correspondents,
|
||||||
|
refreshCorrespondents,
|
||||||
|
handleCorrespondentCreate,
|
||||||
|
handleCorrespondentUpdate,
|
||||||
|
handleCorrespondentDelete,
|
||||||
|
setStatusMessage,
|
||||||
|
} = useAppShell();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout sidebarProps={sidebarProps} className="tags-main">
|
||||||
|
<CorrespondentsPanel
|
||||||
|
correspondents={correspondents}
|
||||||
|
onRefresh={refreshCorrespondents}
|
||||||
|
onCreate={handleCorrespondentCreate}
|
||||||
|
onUpdate={handleCorrespondentUpdate}
|
||||||
|
onDelete={handleCorrespondentDelete}
|
||||||
|
onNotify={setStatusMessage}
|
||||||
|
/>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const AppRouter = () => (
|
const AppRouter = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -4692,6 +5315,7 @@ const AppRouter = () => (
|
|||||||
element={<DocumentsRoute />}
|
element={<DocumentsRoute />}
|
||||||
/>
|
/>
|
||||||
<Route path="/tags" element={<TagsRoute />} />
|
<Route path="/tags" element={<TagsRoute />} />
|
||||||
|
<Route path="/correspondents" element={<CorrespondentsRoute />} />
|
||||||
<Route path="*" element={<Navigate to="/folders" replace />} />
|
<Route path="*" element={<Navigate to="/folders" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons';
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
FolderIcon,
|
||||||
|
TagIcon,
|
||||||
|
TrashIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
const FolderNode = ({
|
const FolderNode = ({
|
||||||
node,
|
node,
|
||||||
@@ -106,10 +112,15 @@ const Sidebar = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onShowTags,
|
onShowTags,
|
||||||
tags = [],
|
tags = [],
|
||||||
|
onShowCorrespondents,
|
||||||
|
correspondents = [],
|
||||||
}) => {
|
}) => {
|
||||||
const handleShowTags = onShowTags || (() => {});
|
const handleShowTags = onShowTags || (() => {});
|
||||||
const tagsRouteMatch = useMatch('/tags');
|
const tagsRouteMatch = useMatch('/tags');
|
||||||
const isTagsRoute = Boolean(tagsRouteMatch);
|
const isTagsRoute = Boolean(tagsRouteMatch);
|
||||||
|
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
||||||
|
const correspondentsRouteMatch = useMatch('/correspondents');
|
||||||
|
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -183,6 +194,27 @@ const Sidebar = ({
|
|||||||
<span>All tags</span>
|
<span>All tags</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Correspondents</h3>
|
||||||
|
<span className="meta">{correspondents.length}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||||
|
onClick={handleShowCorrespondents}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleShowCorrespondents();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CorrespondentIcon className="sidebar-item__icon" />
|
||||||
|
<span>All correspondents</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -430,6 +430,74 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.correspondents-panel .header-actions {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondents-actions__form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondents-actions__form input {
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__label strong {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__remove {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-form input,
|
||||||
|
.correspondent-form select {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-workspace__message {
|
.preview-workspace__message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconFolderFilled,
|
IconFolderFilled,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTagFilled,
|
IconTagFilled,
|
||||||
|
IconUserFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||||
|
<IconUserFilled
|
||||||
|
className={composeClassName('icon icon--fill', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<TablerDownload
|
<TablerDownload
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -69,5 +79,6 @@ export default {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user