Add flock member notes and audit tabs
This commit is contained in:
+472
-125
@@ -175,12 +175,43 @@ type IntegrationTokenSummary = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type FlockNote = {
|
||||
id: string;
|
||||
workspaceId: number;
|
||||
birdId: string | null;
|
||||
birdName: string | null;
|
||||
body: string;
|
||||
createdByUserId: string | null;
|
||||
createdByName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AuditLogEntry = {
|
||||
id: string;
|
||||
workspaceId: number;
|
||||
userId: string | null;
|
||||
actorName: string | null;
|
||||
actorEmail: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
entityName: string | null;
|
||||
details: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type IntegrationTokenFormState = {
|
||||
name: string;
|
||||
scope: IntegrationTokenScope;
|
||||
expiresInDays: string;
|
||||
};
|
||||
|
||||
type FlockNoteFormState = {
|
||||
birdId: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type BirdFormState = {
|
||||
name: string;
|
||||
tagId: string;
|
||||
@@ -331,6 +362,7 @@ type WeightDropAlert = {
|
||||
};
|
||||
|
||||
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
|
||||
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
|
||||
type DismissedAlertMap = Record<string, boolean>;
|
||||
|
||||
type PhotoCropState = {
|
||||
@@ -661,6 +693,11 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
||||
expiresInDays: '',
|
||||
};
|
||||
|
||||
const emptyFlockNoteForm: FlockNoteFormState = {
|
||||
birdId: '',
|
||||
body: '',
|
||||
};
|
||||
|
||||
const defaultAuthProviders: AuthProvider[] = [
|
||||
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
||||
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
||||
@@ -788,6 +825,12 @@ const formatDateTime = (value: string | null) => {
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatAuditAction = (value: string) =>
|
||||
value
|
||||
.split('.')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' '))
|
||||
.join(' ');
|
||||
|
||||
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
||||
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
||||
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
||||
@@ -1428,11 +1471,14 @@ function App() {
|
||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||
const [flockNotes, setFlockNotes] = useState<FlockNote[]>([]);
|
||||
const [auditLogEntries, setAuditLogEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
const [selectedBirdTab, setSelectedBirdTab] = useState<BirdDetailTab>('info');
|
||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||
const [birdEditorOpen, setBirdEditorOpen] = useState(false);
|
||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||
@@ -1448,6 +1494,7 @@ function App() {
|
||||
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
|
||||
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
||||
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
||||
const [flockNoteForm, setFlockNoteForm] = useState<FlockNoteFormState>(emptyFlockNoteForm);
|
||||
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
|
||||
const [birdImportFileName, setBirdImportFileName] = useState('');
|
||||
@@ -1466,6 +1513,9 @@ function App() {
|
||||
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
||||
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
|
||||
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
||||
const [savingFlockNote, setSavingFlockNote] = useState(false);
|
||||
const [deletingFlockNoteId, setDeletingFlockNoteId] = useState('');
|
||||
const [auditLogLoading, setAuditLogLoading] = useState(false);
|
||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||
@@ -1523,15 +1573,35 @@ function App() {
|
||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||
[birds, selectedBirdId],
|
||||
);
|
||||
const editingBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||
[birds, editingBirdId],
|
||||
);
|
||||
const editingBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||
[birds, editingBirdId],
|
||||
);
|
||||
const selectedBirdNotes = useMemo(
|
||||
() => (selectedBird ? flockNotes.filter((note) => note.birdId === selectedBird.id) : []),
|
||||
[flockNotes, selectedBird],
|
||||
);
|
||||
const selectedBirdAuditLogEntries = useMemo(
|
||||
() =>
|
||||
selectedBird
|
||||
? auditLogEntries.filter(
|
||||
(entry) =>
|
||||
entry.entityId === selectedBird.id ||
|
||||
entry.details.birdId === selectedBird.id ||
|
||||
(entry.entityType === 'bird' && entry.entityName === selectedBird.name),
|
||||
)
|
||||
: [],
|
||||
[auditLogEntries, selectedBird],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedAlerts(readDismissedAlerts());
|
||||
}, [workspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedBirdTab('info');
|
||||
}, [selectedBirdId]);
|
||||
|
||||
const overviewWindowStartDate = useMemo(() => {
|
||||
const startDate = new Date();
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
@@ -1968,8 +2038,10 @@ function App() {
|
||||
setAuthSession(null);
|
||||
setWorkspace(null);
|
||||
setActiveMembership(null);
|
||||
setWorkspaceMembers([]);
|
||||
setIntegrationTokens([]);
|
||||
setWorkspaceMembers([]);
|
||||
setIntegrationTokens([]);
|
||||
setFlockNotes([]);
|
||||
setAuditLogEntries([]);
|
||||
setAdminSummary(null);
|
||||
setAdminRescueWorkspaces([]);
|
||||
setBirds([]);
|
||||
@@ -1984,7 +2056,8 @@ function App() {
|
||||
setEditingBirdId('');
|
||||
setWorkspaceForm(emptyWorkspaceForm);
|
||||
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
|
||||
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
||||
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
||||
setFlockNoteForm(emptyFlockNoteForm);
|
||||
setNewIntegrationTokenSecret('');
|
||||
setAuthNotice(null);
|
||||
setBillingNotice(null);
|
||||
@@ -2152,11 +2225,12 @@ function App() {
|
||||
const loadWorkspaceData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([
|
||||
apiFetch('/birds', authToken),
|
||||
apiFetch('/workspace/members', authToken),
|
||||
apiFetch('/integration-tokens', authToken),
|
||||
]);
|
||||
const [birdsResponse, membersResponse, integrationTokensResponse, notesResponse] = await Promise.all([
|
||||
apiFetch('/birds', authToken),
|
||||
apiFetch('/workspace/members', authToken),
|
||||
apiFetch('/integration-tokens', authToken),
|
||||
apiFetch('/notes', authToken),
|
||||
]);
|
||||
|
||||
if (!birdsResponse.ok) {
|
||||
if (birdsResponse.status === 401) {
|
||||
@@ -2186,13 +2260,20 @@ function App() {
|
||||
setWorkspaceMembers([]);
|
||||
}
|
||||
|
||||
if (integrationTokensResponse.ok) {
|
||||
const integrationTokensData =
|
||||
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
|
||||
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
|
||||
} else {
|
||||
setIntegrationTokens([]);
|
||||
}
|
||||
if (integrationTokensResponse.ok) {
|
||||
const integrationTokensData =
|
||||
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
|
||||
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
|
||||
} else {
|
||||
setIntegrationTokens([]);
|
||||
}
|
||||
|
||||
if (notesResponse.ok) {
|
||||
const notesData = (await readJsonSafely<{ notes?: FlockNote[] }>(notesResponse)) ?? {};
|
||||
setFlockNotes(notesData.notes ?? []);
|
||||
} else {
|
||||
setFlockNotes([]);
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
|
||||
} finally {
|
||||
@@ -2201,9 +2282,35 @@ function App() {
|
||||
};
|
||||
|
||||
void loadWorkspaceData();
|
||||
}, [authToken, workspace?.id]);
|
||||
}, [authToken, workspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!authToken || selectedBirdTab !== 'audit' || !selectedBird || !['owner', 'assistant'].includes(activeMembership?.role ?? '')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAuditLog = async () => {
|
||||
try {
|
||||
setAuditLogLoading(true);
|
||||
const response = await apiFetch('/audit-log', authToken);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to load audit log.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ entries?: AuditLogEntry[] }>(response)) ?? {};
|
||||
setAuditLogEntries(data.entries ?? []);
|
||||
} catch (auditError) {
|
||||
setError(auditError instanceof Error ? auditError.message : 'Unable to load audit log.');
|
||||
} finally {
|
||||
setAuditLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAuditLog();
|
||||
}, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
|
||||
return;
|
||||
}
|
||||
@@ -2579,7 +2686,7 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeIntegrationToken = async (tokenId: string) => {
|
||||
const handleRevokeIntegrationToken = async (tokenId: string) => {
|
||||
if (!authToken) {
|
||||
return;
|
||||
}
|
||||
@@ -2602,9 +2709,71 @@ function App() {
|
||||
} finally {
|
||||
setRevokingIntegrationTokenId('');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
||||
const handleFlockNoteSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setSavingFlockNote(true);
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/notes', authToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
birdId: selectedBirdTab === 'notes' && selectedBird ? selectedBird.id : flockNoteForm.birdId || null,
|
||||
body: flockNoteForm.body.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to save note.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ note?: FlockNote }>(response)) ?? {};
|
||||
|
||||
if (!data.note) {
|
||||
throw new Error('Unable to save note.');
|
||||
}
|
||||
|
||||
setFlockNotes((current) => [data.note!, ...current]);
|
||||
setFlockNoteForm(emptyFlockNoteForm);
|
||||
} catch (noteError) {
|
||||
setError(noteError instanceof Error ? noteError.message : 'Unable to save note.');
|
||||
} finally {
|
||||
setSavingFlockNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFlockNote = async (noteId: string) => {
|
||||
if (!authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setDeletingFlockNoteId(noteId);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/notes/${noteId}`, authToken, { method: 'DELETE' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to delete note.'));
|
||||
}
|
||||
|
||||
setFlockNotes((current) => current.filter((note) => note.id !== noteId));
|
||||
} catch (noteError) {
|
||||
setError(noteError instanceof Error ? noteError.message : 'Unable to delete note.');
|
||||
} finally {
|
||||
setDeletingFlockNoteId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
||||
if (!authToken) {
|
||||
return;
|
||||
}
|
||||
@@ -4321,12 +4490,12 @@ function App() {
|
||||
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
||||
Overview
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
|
||||
Flock
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
||||
Settings
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
|
||||
Flock
|
||||
</button>
|
||||
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
||||
Settings
|
||||
</button>
|
||||
{authSession.isAdmin ? (
|
||||
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
|
||||
Admin
|
||||
@@ -5258,14 +5427,80 @@ function App() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedBird && !birdEditorOpen ? (
|
||||
<section className="panel flock-member-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Flock member</p>
|
||||
<h2>{selectedBird.name}</h2>
|
||||
</div>
|
||||
<div className="member-header-actions">
|
||||
{selectedBird && !birdEditorOpen ? (
|
||||
<section className="panel flock-member-panel bird-detail-panel">
|
||||
<div className="bird-detail-tabs" role="tablist" aria-label={`${selectedBird.name} detail sections`}>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'info' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('info')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'info'}
|
||||
aria-label="Info"
|
||||
title="Info"
|
||||
>
|
||||
<svg className="info-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M11 17h2v-6h-2v6Zm1-14C6.48 3 2 7.48 2 13s4.48 10 10 10 10-4.48 10-10S17.52 3 12 3Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Zm-1-12h2V7h-2v2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'weight' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('weight')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'weight'}
|
||||
aria-label="Weight"
|
||||
title="Weight"
|
||||
>
|
||||
<svg className="weight-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||
<path d="M240-200h480l-57-400H297l-57 400Zm240-480q17 0 28.5-11.5T520-720q0-17-11.5-28.5T480-760q-17 0-28.5 11.5T440-720q0 17 11.5 28.5T480-680Zm113 0h70q30 0 52 20t27 49l57 400q5 36-18.5 63.5T720-120H240q-37 0-60.5-27.5T161-211l57-400q5-29 27-49t52-20h70q-3-10-5-19.5t-2-20.5q0-50 35-85t85-35q50 0 85 35t35 85q0 11-2 20.5t-5 19.5ZM240-200h480-480Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'vet' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('vet')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'vet'}
|
||||
aria-label="Vet"
|
||||
title="Vet"
|
||||
>
|
||||
<svg className="vet-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M20 6h-4V4c0-1.11-.89-2-2-2h-4c-1.11 0-2 .89-2 2v2H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2ZM10 4h4v2h-4V4Zm6 11h-3v3h-2v-3H8v-2h3v-3h2v3h3v2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'notes' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('notes')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'notes'}
|
||||
aria-label="Notes"
|
||||
title="Notes"
|
||||
>
|
||||
<svg className="note-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('audit')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'audit'}
|
||||
aria-label="Audit log"
|
||||
title="Audit log"
|
||||
>
|
||||
<svg className="audit-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||
<path d="M480-120q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-480h80q0 117 81.5 198.5T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-96 88h105v80H120v-240h80v94q47-64 120.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Flock member</p>
|
||||
</div>
|
||||
<div className="member-header-actions">
|
||||
{selectedBirdWeightRangeAlert || selectedBirdWeightDropAlerts.length || selectedBirdHasVetVisitAlert ? (
|
||||
<div className="bird-alert-stack" aria-label={`Critical alerts for ${selectedBird.name}`}>
|
||||
{selectedBirdWeightRangeAlert ? (
|
||||
@@ -5315,25 +5550,29 @@ function App() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<section className="profile-hero">
|
||||
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
||||
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
|
||||
<button className="qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="profile-copy">
|
||||
<>
|
||||
<section className="profile-hero">
|
||||
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
||||
<div className="profile-actions">
|
||||
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
|
||||
<button className="profile-icon-button qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`} title="QR code">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button className="profile-icon-button" onClick={() => startEditBird(selectedBird)} type="button" aria-label={`Edit details for ${selectedBird.name}`} title="Edit details">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M4 20h4l10.5-10.5-4-4L4 16v4Z" />
|
||||
<path d="m13.5 6.5 4 4" />
|
||||
<path d="M15 5l1.5-1.5a2.1 2.1 0 0 1 3 3L18 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="profile-copy">
|
||||
<h3 className="profile-title">
|
||||
<span>{selectedBird.name}</span>
|
||||
<span
|
||||
@@ -5345,54 +5584,72 @@ function App() {
|
||||
</h3>
|
||||
<p className="muted">
|
||||
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||
</p>
|
||||
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
||||
</div>
|
||||
</section>
|
||||
</p>
|
||||
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Hatch Day</span>
|
||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Gotcha day</span>
|
||||
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Favorite snack</span>
|
||||
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Motivators</span>
|
||||
{parseBirdProfileList(selectedBird.motivators).length ? (
|
||||
<ul className="detail-item-list">
|
||||
{parseBirdProfileList(selectedBird.motivators).map((motivator, index) => (
|
||||
<li key={`${motivator}-${index}`}>{motivator}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<strong>Not recorded</strong>
|
||||
)}
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Demotivators</span>
|
||||
{parseBirdProfileList(selectedBird.demotivators).length ? (
|
||||
<ul className="detail-item-list">
|
||||
{parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => (
|
||||
<li key={`${demotivator}-${index}`}>{demotivator}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<strong>Not recorded</strong>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
<div className="bird-detail-tab-panel">
|
||||
{selectedBirdTab === 'info' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Hatch Day</span>
|
||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Gotcha day</span>
|
||||
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Favorite snack</span>
|
||||
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Motivators</span>
|
||||
{parseBirdProfileList(selectedBird.motivators).length ? (
|
||||
<ul className="detail-item-list">
|
||||
{parseBirdProfileList(selectedBird.motivators).map((motivator, index) => (
|
||||
<li key={`${motivator}-${index}`}>{motivator}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<strong>Not recorded</strong>
|
||||
)}
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Demotivators</span>
|
||||
{parseBirdProfileList(selectedBird.demotivators).length ? (
|
||||
<ul className="detail-item-list">
|
||||
{parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => (
|
||||
<li key={`${demotivator}-${index}`}>{demotivator}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<strong>Not recorded</strong>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="flock-member-sections">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
{medications.length ? (
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Medication</p>
|
||||
<h2>Medication schedule</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'weight' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Weight</p>
|
||||
<h2>Trend and log</h2>
|
||||
</div>
|
||||
@@ -5578,24 +5835,16 @@ function App() {
|
||||
<button className="primary-button" type="submit">
|
||||
Save weight
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{medications.length ? (
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Medication</p>
|
||||
<h2>Medication schedule</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
{selectedBirdTab === 'vet' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Vet visits</p>
|
||||
<h2>Care history and notes</h2>
|
||||
</div>
|
||||
@@ -5680,19 +5929,117 @@ function App() {
|
||||
<small>Add the first visit above to start this care history.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'notes' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Notes</p>
|
||||
<h2>{selectedBird.name} notes</h2>
|
||||
</div>
|
||||
<p className="muted">{selectedBirdNotes.length} total</p>
|
||||
</div>
|
||||
<form className="form-panel care-entry-form" onSubmit={handleFlockNoteSubmit}>
|
||||
<label className="wide-field">
|
||||
Note
|
||||
<textarea
|
||||
rows={4}
|
||||
value={flockNoteForm.body}
|
||||
onChange={(event) => setFlockNoteForm({ ...flockNoteForm, body: event.target.value })}
|
||||
maxLength={5000}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={savingFlockNote}>
|
||||
{savingFlockNote ? 'Saving...' : 'Save note'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="recent-list note-list">
|
||||
{selectedBirdNotes.length ? (
|
||||
selectedBirdNotes.map((note) => (
|
||||
<article key={note.id} className="note-card">
|
||||
<div>
|
||||
<span>Updated {formatDateTime(note.updatedAt)}</span>
|
||||
</div>
|
||||
<p>{note.body}</p>
|
||||
<div className="button-row">
|
||||
<small>{note.createdByName ? `By ${note.createdByName}` : 'Author unavailable'}</small>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => handleDeleteFlockNote(note.id)}
|
||||
disabled={deletingFlockNoteId === note.id}
|
||||
>
|
||||
{deletingFlockNoteId === note.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="empty-card">
|
||||
<strong>No notes yet</strong>
|
||||
<small>Add the first note for {selectedBird.name} above.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'audit' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Audit log</p>
|
||||
<h2>{selectedBird.name} activity</h2>
|
||||
</div>
|
||||
<p className="muted">{auditLogLoading ? 'Loading...' : `${selectedBirdAuditLogEntries.length} recent events`}</p>
|
||||
</div>
|
||||
{['owner', 'assistant'].includes(activeMembership?.role ?? '') ? (
|
||||
<div className="recent-list audit-log-list">
|
||||
{selectedBirdAuditLogEntries.length ? (
|
||||
selectedBirdAuditLogEntries.map((entry) => (
|
||||
<article key={entry.id} className="audit-log-card">
|
||||
<div>
|
||||
<strong>{formatAuditAction(entry.action)}</strong>
|
||||
<span>{formatDateTime(entry.createdAt)}</span>
|
||||
</div>
|
||||
<small>
|
||||
{entry.actorName || entry.actorEmail || 'Unknown user'}
|
||||
{entry.entityName ? ` • ${entry.entityName}` : ` • ${entry.entityType}`}
|
||||
</small>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="empty-card">
|
||||
<strong>No audit events yet</strong>
|
||||
<small>New activity for {selectedBird.name} will appear here.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">Only owners and assistants can view the audit log.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{activePage === 'settings' ? (
|
||||
<section className="forms-grid settings-grid">
|
||||
<section className="forms-grid settings-grid">
|
||||
<div className="settings-column settings-column-left">
|
||||
<article className="panel form-panel settings-card-flock-profile">
|
||||
<div className="panel-header">
|
||||
|
||||
Reference in New Issue
Block a user