Added admin mode, read only status for inactive accounts, and resuce verification
This commit is contained in:
+406
-54
@@ -5,7 +5,9 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
|
||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||
type WorkspaceType = 'standard' | 'rescue';
|
||||
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
|
||||
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||
type BirdGender = 'unknown' | 'male' | 'female';
|
||||
|
||||
@@ -50,6 +52,8 @@ type Workspace = {
|
||||
workspaceType: WorkspaceType;
|
||||
billingEmail: string | null;
|
||||
billingPlan: BillingPlan;
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
rescueVerificationStatus: RescueVerificationStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -89,9 +93,26 @@ type AuthSessionPayload = {
|
||||
activeWorkspace: Workspace;
|
||||
activeMembership: WorkspaceMember;
|
||||
workspaces: WorkspaceSummary[];
|
||||
isAdmin: boolean;
|
||||
providers: AuthProvider[];
|
||||
};
|
||||
|
||||
type AdminSummary = {
|
||||
totalBirds: number;
|
||||
totalUsers: number;
|
||||
totalWorkspaces: number;
|
||||
rescueWorkspaces: number;
|
||||
pendingRescues: number;
|
||||
dailyUsers: number;
|
||||
};
|
||||
|
||||
type AdminRescueWorkspace = {
|
||||
workspace: Workspace;
|
||||
ownerEmail: string | null;
|
||||
birdCount: number;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
type IntegrationTokenSummary = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -201,7 +222,7 @@ type PhotoDragState = {
|
||||
startOffsetY: number;
|
||||
};
|
||||
|
||||
type AppPage = 'overview' | 'flock' | 'settings';
|
||||
type AppPage = 'overview' | 'flock' | 'settings' | 'admin';
|
||||
type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace' | 'flock-member' | 'transfer';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||
@@ -229,7 +250,7 @@ const emptyWorkspaceForm: WorkspaceFormState = {
|
||||
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'staff',
|
||||
role: 'caregiver',
|
||||
};
|
||||
|
||||
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
|
||||
@@ -476,18 +497,18 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => {
|
||||
|
||||
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
||||
if (billingPlan === 'rescue_free') {
|
||||
return 'No billing is applied to rescue workspaces.';
|
||||
return 'No billing is applied to rescue flocks.';
|
||||
}
|
||||
|
||||
if (billingPlan === 'household_basic') {
|
||||
return 'Permits up to 4 birds in the workspace.';
|
||||
return 'Permits up to 4 birds in the flock.';
|
||||
}
|
||||
|
||||
if (billingPlan === 'household_plus') {
|
||||
return 'Permits 5 to 10 birds in the workspace.';
|
||||
return 'Permits 5 to 10 birds in the flock.';
|
||||
}
|
||||
|
||||
return 'Permits 11 or more birds in the workspace.';
|
||||
return 'Permits 11 or more birds in the flock.';
|
||||
};
|
||||
|
||||
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
||||
@@ -506,6 +527,51 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatSubscriptionStatus = (status: SubscriptionStatus) => {
|
||||
if (status === 'trialing') {
|
||||
return 'Trialing';
|
||||
}
|
||||
if (status === 'past_due') {
|
||||
return 'Past due';
|
||||
}
|
||||
if (status === 'canceled') {
|
||||
return 'Canceled';
|
||||
}
|
||||
if (status === 'unpaid') {
|
||||
return 'Unpaid';
|
||||
}
|
||||
if (status === 'none') {
|
||||
return 'No subscription';
|
||||
}
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
||||
if (status === 'approved') {
|
||||
return 'Approved';
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return 'Rejected';
|
||||
}
|
||||
if (status === 'not_required') {
|
||||
return 'Not required';
|
||||
}
|
||||
return 'Pending verification';
|
||||
};
|
||||
|
||||
const formatWorkspaceRole = (role: WorkspaceRole) => {
|
||||
if (role === 'owner') {
|
||||
return 'Owner';
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
return 'Assistant';
|
||||
}
|
||||
if (role === 'caregiver') {
|
||||
return 'Caregiver';
|
||||
}
|
||||
return 'Viewer';
|
||||
};
|
||||
|
||||
const readFileAsDataUrl = async (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -747,6 +813,8 @@ function App() {
|
||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||
@@ -771,6 +839,7 @@ function App() {
|
||||
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
||||
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
|
||||
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
|
||||
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
|
||||
@@ -795,6 +864,8 @@ function App() {
|
||||
notes: '',
|
||||
});
|
||||
const [deletingBird, setDeletingBird] = useState(false);
|
||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||
|
||||
@@ -1056,6 +1127,8 @@ function App() {
|
||||
setActiveMembership(null);
|
||||
setWorkspaceMembers([]);
|
||||
setIntegrationTokens([]);
|
||||
setAdminSummary(null);
|
||||
setAdminRescueWorkspaces([]);
|
||||
setBirds([]);
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
@@ -1192,6 +1265,35 @@ function App() {
|
||||
void loadWorkspaceData();
|
||||
}, [authToken, workspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAdminDashboard = async () => {
|
||||
try {
|
||||
const [summaryResponse, rescuesResponse] = await Promise.all([
|
||||
apiFetch('/admin/summary', authToken),
|
||||
apiFetch('/admin/rescue-workspaces', authToken),
|
||||
]);
|
||||
|
||||
if (!summaryResponse.ok || !rescuesResponse.ok) {
|
||||
throw new Error('Unable to load admin dashboard.');
|
||||
}
|
||||
|
||||
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
||||
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
||||
|
||||
setAdminSummary(summaryData.summary ?? null);
|
||||
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
||||
} catch (adminError) {
|
||||
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
||||
}
|
||||
};
|
||||
|
||||
void loadAdminDashboard();
|
||||
}, [activePage, authSession?.isAdmin, authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBird?.id) {
|
||||
setWeights([]);
|
||||
@@ -1215,6 +1317,8 @@ function App() {
|
||||
|
||||
setWeights(weightsData.weights ?? []);
|
||||
setVetVisits(visitsData.vetVisits ?? []);
|
||||
setEditingVetVisitId('');
|
||||
setDeletingVetVisitId('');
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
|
||||
}
|
||||
@@ -1355,13 +1459,13 @@ function App() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.'));
|
||||
throw new Error(await readErrorMessage(response, 'Unable to switch flocks.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||
|
||||
if (!data.session) {
|
||||
throw new Error('Unable to switch workspaces.');
|
||||
throw new Error('Unable to switch flocks.');
|
||||
}
|
||||
|
||||
const nextToken = data.token || authToken;
|
||||
@@ -1373,7 +1477,7 @@ function App() {
|
||||
setVetVisits([]);
|
||||
setActivePage('overview');
|
||||
} catch (switchError) {
|
||||
setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.');
|
||||
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
|
||||
} finally {
|
||||
setSwitchingWorkspaceId(null);
|
||||
}
|
||||
@@ -1447,6 +1551,53 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
||||
if (!authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setUpdatingRescueWorkspaceId(workspaceId);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rescueVerificationStatus }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
||||
|
||||
if (!data.workspace) {
|
||||
throw new Error('Unable to update rescue verification status.');
|
||||
}
|
||||
|
||||
setAdminRescueWorkspaces((current) =>
|
||||
current.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry)),
|
||||
);
|
||||
setAdminSummary((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
pendingRescues: adminRescueWorkspaces.filter((entry) =>
|
||||
entry.workspace.id === workspaceId
|
||||
? rescueVerificationStatus === 'pending'
|
||||
: entry.workspace.rescueVerificationStatus === 'pending',
|
||||
).length,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (adminError) {
|
||||
setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.');
|
||||
} finally {
|
||||
setUpdatingRescueWorkspaceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1470,17 +1621,17 @@ function App() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to create workspace.'));
|
||||
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
|
||||
}
|
||||
|
||||
const workspaceResponse = await apiFetch('/auth/session', authToken);
|
||||
if (!workspaceResponse.ok) {
|
||||
throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.'));
|
||||
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
|
||||
if (!data.session) {
|
||||
throw new Error('Unable to refresh your workspace list.');
|
||||
throw new Error('Unable to refresh your flock list.');
|
||||
}
|
||||
|
||||
const nextToken = data.token || authToken;
|
||||
@@ -1491,7 +1642,7 @@ function App() {
|
||||
billingEmail: data.session.user.email,
|
||||
});
|
||||
} catch (workspaceError) {
|
||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.');
|
||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
|
||||
} finally {
|
||||
setCreatingWorkspace(false);
|
||||
}
|
||||
@@ -1832,22 +1983,29 @@ function App() {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(vetVisitForm),
|
||||
});
|
||||
const isEditingVetVisit = Boolean(editingVetVisitId);
|
||||
const response = await apiFetch(
|
||||
isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
|
||||
authToken,
|
||||
{
|
||||
method: isEditingVetVisit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(vetVisitForm),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
|
||||
throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`));
|
||||
}
|
||||
|
||||
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
|
||||
if (!data?.vetVisit) {
|
||||
throw new Error('Unable to save vet visit.');
|
||||
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
|
||||
}
|
||||
setVetVisits((current) =>
|
||||
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
|
||||
(isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort(
|
||||
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
|
||||
),
|
||||
);
|
||||
setVetVisitForm({
|
||||
visitedOn: new Date().toISOString().slice(0, 10),
|
||||
@@ -1855,11 +2013,61 @@ function App() {
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
setEditingVetVisitId('');
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditVetVisit = (visit: VetVisit) => {
|
||||
setEditingVetVisitId(visit.id);
|
||||
setVetVisitForm({
|
||||
visitedOn: visit.visitedOn,
|
||||
clinicName: visit.clinicName,
|
||||
reason: visit.reason,
|
||||
notes: visit.notes ?? '',
|
||||
});
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCancelVetVisitEdit = () => {
|
||||
setEditingVetVisitId('');
|
||||
setVetVisitForm({
|
||||
visitedOn: new Date().toISOString().slice(0, 10),
|
||||
clinicName: '',
|
||||
reason: '',
|
||||
notes: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteVetVisit = async (visitId: string) => {
|
||||
if (!selectedBird || deletingVetVisitId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingVetVisitId(visitId);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
|
||||
}
|
||||
|
||||
setVetVisits((current) => current.filter((visit) => visit.id !== visitId));
|
||||
if (editingVetVisitId === visitId) {
|
||||
handleCancelVetVisitEdit();
|
||||
}
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.');
|
||||
} finally {
|
||||
setDeletingVetVisitId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBird = async () => {
|
||||
if (!selectedBird || deletingBird) {
|
||||
return;
|
||||
@@ -1895,6 +2103,8 @@ function App() {
|
||||
setSelectedBirdId('');
|
||||
setWeights([]);
|
||||
setVetVisits([]);
|
||||
setEditingVetVisitId('');
|
||||
setDeletingVetVisitId('');
|
||||
|
||||
if (editingBirdId === selectedBird.id) {
|
||||
setEditingBirdId('');
|
||||
@@ -1967,13 +2177,13 @@ function App() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.'));
|
||||
throw new Error(await readErrorMessage(response, 'Unable to save flock settings.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
||||
|
||||
if (!data.workspace) {
|
||||
throw new Error('Unable to save workspace settings.');
|
||||
throw new Error('Unable to save flock settings.');
|
||||
}
|
||||
|
||||
const savedWorkspace = data.workspace;
|
||||
@@ -1997,7 +2207,7 @@ function App() {
|
||||
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
||||
});
|
||||
} catch (workspaceError) {
|
||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.');
|
||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
|
||||
} finally {
|
||||
setSavingWorkspace(false);
|
||||
}
|
||||
@@ -2074,7 +2284,7 @@ function App() {
|
||||
<div>
|
||||
<p className="eyebrow">FlockPal</p>
|
||||
<h1>Loading your flock spaces...</h1>
|
||||
<p className="muted">Checking your sign-in and workspace access.</p>
|
||||
<p className="muted">Checking your sign-in and flock access.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -2204,6 +2414,11 @@ function App() {
|
||||
<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
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showWorkspaceSwitcher ? (
|
||||
@@ -2219,7 +2434,7 @@ function App() {
|
||||
>
|
||||
<span>{entry.workspace.name}</span>
|
||||
<small>
|
||||
{formatBillingPlanName(entry.workspace.billingPlan)} • {entry.membership.role}
|
||||
{formatBillingPlanName(entry.workspace.billingPlan)} • {formatWorkspaceRole(entry.membership.role)}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
@@ -2365,6 +2580,102 @@ function App() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activePage === 'admin' && authSession.isAdmin ? (
|
||||
<section className="stack-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Admin</p>
|
||||
<h2>Platform pulse</h2>
|
||||
</div>
|
||||
<p className="muted">Operational counts for the full FlockPal platform.</p>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<article className="summary-card">
|
||||
<span>Total birds</span>
|
||||
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<span>Daily users</span>
|
||||
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<span>Total users</span>
|
||||
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<span>Flocks</span>
|
||||
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<span>Rescue flocks</span>
|
||||
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<span>Pending rescues</span>
|
||||
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Verification</p>
|
||||
<h2>Rescue flocks</h2>
|
||||
</div>
|
||||
<p className="muted">Pending rescues are read-only until approved.</p>
|
||||
</div>
|
||||
<div className="recent-list">
|
||||
{adminRescueWorkspaces.length ? (
|
||||
adminRescueWorkspaces.map((entry) => (
|
||||
<article key={entry.workspace.id} className="vet-visit-card">
|
||||
<strong>{entry.workspace.name}</strong>
|
||||
<span>
|
||||
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members
|
||||
</span>
|
||||
<small>
|
||||
Owner {entry.ownerEmail ?? 'unknown'} • Billing {entry.workspace.billingEmail ?? 'not set'}
|
||||
</small>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
|
||||
type="button"
|
||||
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
|
||||
type="button"
|
||||
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
|
||||
>
|
||||
Mark pending
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
|
||||
type="button"
|
||||
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="vet-visit-card empty-card">
|
||||
<strong>No rescue flocks yet</strong>
|
||||
<small>New rescue flocks will appear here for verification review.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activePage === 'flock' ? (
|
||||
<section className={showFlockDetailColumn ? 'dashboard-grid' : 'stack-grid'}>
|
||||
<aside className="panel bird-list-panel">
|
||||
@@ -2723,9 +3034,16 @@ function App() {
|
||||
placeholder="Exam notes, medications, follow-ups, or restrictions"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Save vet visit
|
||||
</button>
|
||||
<div className="button-row wide-field">
|
||||
<button className="primary-button" type="submit">
|
||||
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
|
||||
</button>
|
||||
{editingVetVisitId ? (
|
||||
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
|
||||
Cancel edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="recent-list">
|
||||
@@ -2737,6 +3055,21 @@ function App() {
|
||||
{formatDate(visit.visitedOn)} • {visit.clinicName}
|
||||
</span>
|
||||
<small>{visit.notes || 'No notes recorded.'}</small>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
|
||||
Edit
|
||||
</button>
|
||||
{editingVetVisitId === visit.id ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleDeleteVetVisit(visit.id)}
|
||||
type="button"
|
||||
disabled={deletingVetVisitId === visit.id}
|
||||
>
|
||||
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
@@ -2761,21 +3094,21 @@ function App() {
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Workspace</p>
|
||||
<h2>Workspace profile and billing</h2>
|
||||
<p className="eyebrow">Flock</p>
|
||||
<h2>Flock profile and billing</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
|
||||
participating in a rescue workspace without mixing billing or bird ownership.
|
||||
Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
|
||||
participating in a rescue flock without mixing billing or bird ownership.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
||||
<label>
|
||||
Workspace name
|
||||
Flock name
|
||||
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Workspace type
|
||||
Flock type
|
||||
<select
|
||||
value={workspaceForm.workspaceType}
|
||||
onChange={(event) =>
|
||||
@@ -2789,6 +3122,15 @@ function App() {
|
||||
<option value="rescue">Rescue</option>
|
||||
</select>
|
||||
</label>
|
||||
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
|
||||
<article className="summary-card summary-alert-card">
|
||||
<strong>Approval required before edits continue</strong>
|
||||
<span>
|
||||
Changing this household flock to a rescue flock will make it read-only until FlockPal approves the rescue verification.
|
||||
Monitor the email address used to sign up for any follow-up details needed to approve rescue status.
|
||||
</span>
|
||||
</article>
|
||||
) : null}
|
||||
{workspaceForm.workspaceType === 'standard' ? (
|
||||
<>
|
||||
<label>
|
||||
@@ -2815,7 +3157,7 @@ function App() {
|
||||
) : (
|
||||
<article className="summary-card">
|
||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||
<span>Rescue workspaces stay free while still supporting shared team access.</span>
|
||||
<span>Rescue flocks stay free while still supporting shared team access.</span>
|
||||
</article>
|
||||
)}
|
||||
<label>
|
||||
@@ -2828,7 +3170,7 @@ function App() {
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
||||
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'}
|
||||
{savingWorkspace ? 'Saving flock...' : 'Save flock settings'}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -2843,8 +3185,18 @@ function App() {
|
||||
<div className="summary-grid">
|
||||
<article className="summary-card">
|
||||
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
||||
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span>
|
||||
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
||||
<span>Flock write access will follow subscription health once billing is connected.</span>
|
||||
</article>
|
||||
{workspace?.workspaceType === 'rescue' ? (
|
||||
<article className="summary-card">
|
||||
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
|
||||
<span>Rescue flocks are read-only until an admin approves their verification.</span>
|
||||
</article>
|
||||
) : null}
|
||||
<article className="summary-card">
|
||||
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
|
||||
<span>Billing contact for invoices, receipts, and account notices.</span>
|
||||
@@ -2858,7 +3210,7 @@ function App() {
|
||||
<span>
|
||||
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
|
||||
? 'Current bird count against your paid plan allowance.'
|
||||
: 'Current flock count in this workspace.'}
|
||||
: 'Current bird count in this flock.'}
|
||||
</span>
|
||||
</article>
|
||||
<article className="summary-card">
|
||||
@@ -2872,7 +3224,7 @@ function App() {
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Collaborators</p>
|
||||
<h2>Shared workspace access</h2>
|
||||
<h2>Shared flock access</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -2888,7 +3240,7 @@ function App() {
|
||||
{expandedSettingsSection === 'collaborators' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support
|
||||
Invite other people to help manage this flock. Rescue flocks support teams, and household flocks can also support
|
||||
co-caregivers without changing who owns the billing.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
|
||||
@@ -2921,8 +3273,8 @@ function App() {
|
||||
}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="assistant">Assistant</option>
|
||||
<option value="caregiver">Caregiver</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -2937,7 +3289,7 @@ function App() {
|
||||
<article key={member.id} className="vet-visit-card">
|
||||
<strong>{member.name}</strong>
|
||||
<span>
|
||||
{member.role} • {member.email || member.inviteEmail}
|
||||
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
|
||||
</span>
|
||||
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
||||
<button
|
||||
@@ -2953,7 +3305,7 @@ function App() {
|
||||
) : (
|
||||
<article className="vet-visit-card empty-card">
|
||||
<strong>No collaborators yet</strong>
|
||||
<small>Add the people who should be able to help care for birds in this workspace.</small>
|
||||
<small>Add the people who should be able to help care for birds in this flock.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
@@ -2981,7 +3333,7 @@ function App() {
|
||||
{expandedSettingsSection === 'integration-tokens' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Create a workspace-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
|
||||
Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
|
||||
<label>
|
||||
@@ -3056,7 +3408,7 @@ function App() {
|
||||
) : (
|
||||
<article className="vet-visit-card empty-card">
|
||||
<strong>No integration tokens yet</strong>
|
||||
<small>Create one for n8n, scripts, or other personal automations tied to this workspace.</small>
|
||||
<small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
@@ -3067,7 +3419,7 @@ function App() {
|
||||
<article className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">New workspace</p>
|
||||
<p className="eyebrow">New flock</p>
|
||||
<h2>Add another flock space</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -3084,12 +3436,12 @@ function App() {
|
||||
{expandedSettingsSection === 'new-workspace' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
|
||||
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each flock stays separate
|
||||
for access and billing.
|
||||
</p>
|
||||
<form className="form-panel" onSubmit={handleCreateWorkspace}>
|
||||
<label>
|
||||
Workspace name
|
||||
Flock name
|
||||
<input
|
||||
value={workspaceCreateForm.name}
|
||||
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
|
||||
@@ -3097,7 +3449,7 @@ function App() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Workspace type
|
||||
Flock type
|
||||
<select
|
||||
value={workspaceCreateForm.workspaceType}
|
||||
onChange={(event) =>
|
||||
@@ -3137,7 +3489,7 @@ function App() {
|
||||
) : (
|
||||
<article className="summary-card">
|
||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||
<span>No billing is applied to rescue workspaces.</span>
|
||||
<span>No billing is applied to rescue flocks.</span>
|
||||
</article>
|
||||
)}
|
||||
<label>
|
||||
@@ -3150,7 +3502,7 @@ function App() {
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
|
||||
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
|
||||
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user