import { useState } from 'react' import { AlertTriangle, CheckCircle2, Mail, Send, Trash2, Users2 } from 'lucide-react' import { Link } from 'react-router-dom' import { Panel, Badge } from '../components/ui' import { Empty, Labeled, Note, Select, SettingRow, Toggle, btnGhost, btnPrimary, fieldCls } from '../components/form' import { useOrgSettings, useSaveSettings, useSetMailPassword, useClearMailPassword, useTestMail, } from '../lib/settingsQueries' import { SENSITIVITY_CHOICES, VISIBILITY_CHOICES, SCOPE_CHOICES, type OrgSettings, } from '../lib/settings' // THE ORGANIZATION SETTINGS PANELS. // // Owner only. Every panel is mounted behind a capability check in Settings.tsx, because the server refuses // these for anyone else and offering a control that fails is worse than not offering it. // // THE SETTINGS SAVE ON CHANGE, not behind a Save button. There is no draft to lose and no ambiguity about // whether what is on screen is what is in effect. Mail is the one exception, because its fields are typed // rather than chosen and a half typed host would be saved on every keystroke. // Choices carry a hint the settings vocabulary defines, which the shared Select does not render, so the // selected hint is shown beneath the row instead. That keeps the explanation attached to the ANSWER rather // than hidden inside a list somebody has to open to read. function hintFor(choices: { value: T; hint: string }[], value: T): string { return choices.find((c) => c.value === value)?.hint ?? '' } export function WhoSeesWhatPanel() { const q = useOrgSettings() const save = useSaveSettings() const s = q.data?.settings if (!s) return null const set = (patch: Partial) => save.mutate(patch) return (

These narrow what people see on records they can already open. They never grant more than a person's role allows, and the server decides, so nothing here can be worked around in a browser.

{/* The two rows govern DIFFERENT things and the labels now say which. Rates cover the bill rate, the pay rate and their difference, because anybody holding both can subtract them. The margin setting covers the wrap and everything computed from it, which is the firm's own cost structure and appears nowhere else. Before the wrap existed these were the same disclosure wearing two names. */} set({ margin_visibility: v })} /> set({ work_visibility: v })} /> set({ show_activity_presence: v })} /> set({ claim_required: v })} />

Managers and the owner see their team's activity at every setting. A manager who cannot see a collision is the one person who could have fixed it.

) } export function RecordScopePanel() { const q = useOrgSettings() const save = useSaveSettings() const s = q.data?.settings if (!s) return null const narrowed = s.staff_record_scope !== 'organization' return (

This decides which records exist for a person at all, in every list, search and report. It is stronger than the settings above, which only hide fields on records somebody can already open.

c.value !== 'own')} onChange={(v) => save.mutate({ manager_record_scope: v as 'team' | 'organization' })} /> save.mutate({ unowned_records_visible: v })} /> {narrowed && (
Scope is narrowed, so people will see fewer records than the bench holds and searches will say so rather than looking empty. Put people on teams under{' '} accounts and access , or team scope resolves to each person alone.
)}

The owner and administrators always see everything. Somebody has to, and it is the people accountable for the account.

) } // MailPanel has a real sequence to it: configure, store a password, prove it works, then switch delivery on. // The server enforces that order, so the panel shows where you are in it rather than presenting four // controls and letting somebody discover the order by failing. export function MailPanel() { const q = useOrgSettings() const save = useSaveSettings() const setPw = useSetMailPassword() const clearPw = useClearMailPassword() const test = useTestMail() const [pw, setPwValue] = useState('') const [draft, setDraft] = useState>({}) const s = q.data?.settings if (!s) return null const canStoreSecret = q.data?.secret_storage_available ?? false const v = (k: K): OrgSettings[K] => k in draft ? (draft[k] as OrgSettings[K]) : s[k] const verified = !!s.smtp_verified_at const dirty = Object.keys(draft).length > 0 return (

Messages go through your own mail server, so they arrive from your address and pass your own SPF and DKIM rather than a shared sender nobody recognises. Until this is set up, inviting a teammate returns a link for you to send by hand.

{!canStoreSecret && (
This server cannot store a password yet, so nothing here will save. The organization secret key is not configured.
)}
setDraft({ ...draft, smtp_host: e.target.value })} className={fieldCls} /> setDraft({ ...draft, smtp_port: Number(e.target.value) })} className={fieldCls} /> setDraft({ ...draft, smtp_username: e.target.value })} className={fieldCls} /> setDraft({ ...draft, email_from_address: e.target.value })} className={fieldCls} /> setDraft({ ...draft, email_from_name: e.target.value })} className={fieldCls} /> setDraft({ ...draft, email_reply_to: e.target.value })} className={fieldCls} />
{dirty && (
{/* Changing the connection clears the proof that it worked and switches delivery off. Saying so BEFORE the click is the difference between a deliberate step and a surprise. */} {verified && ( Saving these switches sending off until you test the connection again. )}
)} {save.isError && (
{(save.error as Error).message}
)}
setPwValue(e.target.value)} placeholder={s.smtp_password_set ? 'a password is [credential removed], type to replace it' : 'not set'} className={fieldCls} /> {s.smtp_password_set && ( )}
{verified ? ( Verified ) : ( Not verified )}
Send invitations and messages save.mutate({ email_delivery_enabled: val })} />
{test.isSuccess && ( Sent to {test.data?.to}. If it does not arrive within a few minutes, check the spam folder before changing anything here. )} {test.isError && {(test.error as Error).message}} {s.smtp_last_error && !test.isSuccess && !test.isError && ( The last attempt failed: {s.smtp_last_error} )} {!verified && s.smtp_host && (

Sending stays off until a test message actually leaves. That way a typo surfaces here rather than as invitations quietly going nowhere.

)}
) } // THE WRAP RATE, which is the setting that decides whether this product can report a margin at all. // // Until a firm enters one, every money figure in the product is a SPREAD, bill minus pay, and the screens say // so. That is not a limitation to apologise for, it is the honest position: without knowing what employing // somebody costs, the difference between two rates is all that can be computed, and calling it profit would // be a guess dressed as a number. export function WrapRatePanel() { const q = useOrgSettings() const save = useSaveSettings() const [draft, setDraft] = useState(null) const s = q.data?.settings if (!s) return null const stored = s.default_burden_pct ?? '' const shown = draft ?? stored const dirty = draft !== null && draft !== stored // Entered as a fraction and echoed as a percentage, because a firm says "our wrap is thirty five percent" // and stores 0.35. Showing both removes the one ambiguity that would otherwise be entered wrong. const asPercent = (() => { const n = Number(shown) if (!shown || Number.isNaN(n)) return '' return `${(n * 100).toFixed(2).replace(/\.?0+$/, '')} percent` })() return (

Payroll taxes, workers compensation, unemployment insurance, health cover, paid leave, retirement, general and administrative, and overhead. Enter it as a fraction, so a 35 percent wrap is 0.35.

setDraft(e.target.value)} className={`${fieldCls} max-w-[10rem]`} /> {dirty && ( )} {dirty && ( )}
{save.isError && (
{(save.error as Error).message}
)}
{stored === '' ? ( No wrap is set, so the product reports SPREADS and no margins. A spread is bill minus pay before any employment cost, and on typical numbers it is close to three times the money actually kept. Nothing here is guessed on your behalf. ) : ( Margins are reported using this wrap unless a placement carries its own. It is one figure for the whole firm, which is right for most lines; a client contract or a labour category with a different wrap is set on the record itself. )}
) } // THE AUTOMATED DECISION RULE, which is the one setting on this screen with a legal deadline attached. export function AedtNoticePanel() { const q = useOrgSettings() const save = useSaveSettings() const s = q.data?.settings if (!s) return null return (

This product scores people with an automated employment decision tool. New York City requires the person to be told at least ten business days beforehand and to be told what is assessed. The EU treats hiring as high risk from August 2026. Illinois and Colorado have their own rules. The common shape is the same everywhere: tell them first, and be able to prove you did.

save.mutate({ aedt_notice_required: v })} /> {!s.aedt_notice_required && (
This is off, so people can be scored with no notice recorded. It ships off on purpose: switching it on before your notices are in place would refuse your whole bench. Recording a notice works either way, on every candidate record.
)}

missionMATCH never sends the notice for you. Delivering it is yours; what this holds is the evidence that you did, per candidate, with the date and what they were told.

) } export function InviteDefaultsPanel() { const q = useOrgSettings() const save = useSaveSettings() const s = q.data?.settings if (!s) return null const HOURS = [ { value: '24', label: '1 day' }, { value: '72', label: '3 days' }, { value: '168', label: '7 days' }, { value: '336', label: '14 days' }, ] const CLAIM_HOURS = [ { value: '8', label: '8 hours' }, { value: '24', label: '1 day' }, { value: '72', label: '3 days' }, { value: '168', label: '7 days' }, ] const ROLES = [ { value: 'viewer', label: 'Viewer' }, { value: 'member', label: 'Member' }, { value: 'recruiter', label: 'Recruiter' }, { value: 'manager', label: 'Manager' }, { value: 'admin', label: 'Administrator' }, ] return ( save.mutate({ default_invite_role: v })} />