@@ -141,7 +141,9 @@ export default function Admin() {
141141 const assignSender = useMutation ( api . sourceAdmin . assignSender ) ;
142142 const assignSourceOrg = useMutation ( api . sourceAdmin . assignSourceOrganization ) ;
143143 const createOrg = useMutation ( api . sourceAdmin . createOrganization ) ;
144+ const updateOrg = useMutation ( api . sourceAdmin . updateOrganization ) ;
144145 const ignoreSender = useMutation ( api . sourceAdmin . ignoreSender ) ;
146+ const unignoreSource = useMutation ( api . sourceAdmin . unignoreSource ) ;
145147 const updateListservStatus = useMutation ( api . listservAdmin . updateListservStatus ) ;
146148 const updateJoinStrategy = useMutation ( api . listservAdmin . updateJoinStrategy ) ;
147149 const clearConfirmation = useMutation ( api . listservAdmin . clearConfirmation ) ;
@@ -268,22 +270,18 @@ export default function Admin() {
268270 onRejectCandidate = { ( id ) =>
269271 act ( "Candidate rejected." , ( ) => rejectCandidate ( { token, candidateId : id } ) )
270272 }
271- // Known source (listservs row exists) — assign to existing org
272273 onAssignSource = { ( listservId , orgId ) =>
273274 act ( "Source assigned." , ( ) => assignSourceOrg ( { token, listservId, organizationId : orgId } ) )
274275 }
275- // Known source — create new org and assign
276276 onCreateAndAssignSource = { ( listservId , name , type ) =>
277277 act ( `${ name } created and assigned.` , async ( ) => {
278278 const orgId = await createOrg ( { token, name, type } ) ;
279279 await assignSourceOrg ( { token, listservId, organizationId : orgId } ) ;
280280 } )
281281 }
282- // Inbox-only sender — assign to existing org (creates source row)
283282 onAssignInboxSenderToOrg = { ( senderEmail , orgId ) =>
284283 act ( "Source assigned." , ( ) => assignSender ( { token, senderEmail, organizationId : orgId } ) )
285284 }
286- // Inbox-only sender — create new org and assign
287285 onCreateAndAssignInboxSender = { ( senderEmail , name , type , sourceName , sourceType ) =>
288286 act ( `${ name } created and assigned.` , async ( ) => {
289287 const orgId = await createOrg ( { token, name, type } ) ;
@@ -293,9 +291,15 @@ export default function Admin() {
293291 onIgnoreSender = { ( senderEmail ) =>
294292 act ( "Sender ignored." , ( ) => ignoreSender ( { token, senderEmail } ) )
295293 }
294+ onUnignoreSource = { ( listservId ) =>
295+ act ( "Source reactivated." , ( ) => unignoreSource ( { token, listservId } ) )
296+ }
296297 onCreateOrg = { ( name , type ) =>
297298 act ( `${ name } created.` , ( ) => createOrg ( { token, name, type } ) )
298299 }
300+ onUpdateOrg = { ( orgId , name , type ) =>
301+ act ( "Organization updated." , ( ) => updateOrg ( { token, organizationId : orgId , name, type, status : "active" , tags : [ ] } ) )
302+ }
299303 />
300304 ) }
301305
@@ -492,7 +496,9 @@ function SourcesTab({
492496 onAssignInboxSenderToOrg,
493497 onCreateAndAssignInboxSender,
494498 onIgnoreSender,
499+ onUnignoreSource,
495500 onCreateOrg,
501+ onUpdateOrg,
496502} : {
497503 candidates : Candidate [ ] ;
498504 listservs : Listserv [ ] ;
@@ -505,14 +511,17 @@ function SourcesTab({
505511 onAssignInboxSenderToOrg : ( senderEmail : string , orgId : Id < "organizations" > ) => void ;
506512 onCreateAndAssignInboxSender : ( senderEmail : string , name : string , type : OrgType , sourceName : string , sourceType : NonNullable < Listserv [ "sourceType" ] > ) => void ;
507513 onIgnoreSender : ( senderEmail : string ) => void ;
514+ onUnignoreSource : ( listservId : Id < "listservs" > ) => void ;
508515 onCreateOrg : ( name : string , type : OrgType ) => void ;
516+ onUpdateOrg : ( orgId : Id < "organizations" > , name : string , type : OrgType ) => void ;
509517} ) {
510- // Known listservs rows that have no org yet and aren't paused/ignored
518+ // Partition listservs into three buckets
511519 const unassignedSources = listservs . filter ( ( s ) => ! s . organizationId && s . status !== "paused" ) ;
520+ const assignedSources = listservs . filter ( ( s ) => ! ! s . organizationId ) ;
521+ const ignoredSources = listservs . filter ( ( s ) => s . status === "paused" ) ;
512522 const pendingCandidates = candidates . filter ( ( c ) => c . status === "candidate" ) ;
513523
514- // Build a unified list: known sources + inbox-only senders, sorted by
515- // message recency / count so the most active surface first.
524+ // Unified needs-org list: unassigned known sources + inbox-only senders
516525 type UnifiedItem =
517526 | { kind : "known" ; source : Listserv ; suggestion : ReturnType < typeof suggestFromEmail > }
518527 | { kind : "inbox" ; sender : UnassignedSender } ;
@@ -525,7 +534,6 @@ function SourcesTab({
525534 } ) ) ,
526535 ...unassigned . map ( ( sender ) => ( { kind : "inbox" as const , sender } ) ) ,
527536 ] ;
528- // Stable sort: inbox senders with more messages first, then by recency
529537 unifiedItems . sort ( ( a , b ) => {
530538 const countA = a . kind === "inbox" ? a . sender . count : 0 ;
531539 const countB = b . kind === "inbox" ? b . sender . count : 0 ;
@@ -538,7 +546,7 @@ function SourcesTab({
538546
539547 return (
540548 < div className = "grid gap-6" >
541- { totalAction === 0 && (
549+ { totalAction === 0 && unifiedItems . length === 0 && (
542550 < div className = "rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-[length:var(--font-size-body2)] text-green-800" >
543551 All sources are assigned and ready. Messages will be parsed when you run the parser.
544552 </ div >
@@ -615,22 +623,65 @@ function SourcesTab({
615623 </ Card >
616624 ) }
617625
618- { /* Organizations overview */ }
626+ { /* Assigned sources — always visible so assignments can be changed */ }
627+ { assignedSources . length > 0 && (
628+ < details className = "rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4" open = { unifiedItems . length === 0 } >
629+ < summary className = "cursor-pointer select-none font-semibold text-[length:var(--font-size-body2)]" >
630+ { assignedSources . length } assigned source{ assignedSources . length !== 1 ? "s" : "" }
631+ </ summary >
632+ < div className = "mt-3 grid gap-2" >
633+ { assignedSources . map ( ( src ) => {
634+ const org = organizations . find ( ( o ) => o . _id === src . organizationId ) ;
635+ return (
636+ < AssignedSourceRow
637+ key = { src . _id }
638+ source = { src }
639+ orgName = { org ?. name ?? "Unknown org" }
640+ organizations = { organizations }
641+ onReassign = { ( orgId ) => onAssignSource ( src . _id , orgId ) }
642+ onIgnore = { ( ) => onIgnoreSender ( src . listEmail ) }
643+ />
644+ ) ;
645+ } ) }
646+ </ div >
647+ </ details >
648+ ) }
649+
650+ { /* Ignored / paused sources */ }
651+ { ignoredSources . length > 0 && (
652+ < details className = "rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4" >
653+ < summary className = "cursor-pointer select-none font-semibold text-[length:var(--font-size-body2)] text-[color:var(--color-text-muted)]" >
654+ { ignoredSources . length } ignored source{ ignoredSources . length !== 1 ? "s" : "" }
655+ </ summary >
656+ < div className = "mt-3 grid gap-2" >
657+ { ignoredSources . map ( ( src ) => (
658+ < IgnoredSourceRow
659+ key = { src . _id }
660+ source = { src }
661+ organizations = { organizations }
662+ onReactivate = { ( ) => onUnignoreSource ( src . _id ) }
663+ onAssign = { ( orgId ) => { onUnignoreSource ( src . _id ) ; onAssignSource ( src . _id , orgId ) ; } }
664+ />
665+ ) ) }
666+ </ div >
667+ </ details >
668+ ) }
669+
670+ { /* Organizations — view, edit, create */ }
619671 < Card >
620672 < CardHeader
621673 title = { `${ organizations . length } organization${ organizations . length !== 1 ? "s" : "" } ` }
622- subtitle = "Create new organizations here if suggestions don't match ."
674+ subtitle = "Edit name or type, or create new organizations ."
623675 />
624676 { organizations . length > 0 && (
625677 < div className = "mt-4 grid gap-2" >
626678 { organizations . map ( ( org ) => (
627- < div key = { org . _id } className = "flex items-center gap-3 rounded-lg bg-[var(--color-neutral-100)] px-3 py-2 text-[length:var(--font-size-body2)]" >
628- < span className = "font-semibold" > { org . name } </ span >
629- < Tag > { org . type } </ Tag >
630- < span className = "ml-auto text-[color:var(--color-text-muted)]" >
631- { listservs . filter ( ( s ) => s . organizationId === org . _id ) . length } source{ listservs . filter ( ( s ) => s . organizationId === org . _id ) . length !== 1 ? "s" : "" }
632- </ span >
633- </ div >
679+ < OrgRow
680+ key = { org . _id }
681+ org = { org }
682+ sourceCount = { listservs . filter ( ( s ) => s . organizationId === org . _id ) . length }
683+ onUpdate = { ( name , type ) => onUpdateOrg ( org . _id , name , type ) }
684+ />
634685 ) ) }
635686 </ div >
636687 ) }
@@ -640,6 +691,155 @@ function SourcesTab({
640691 ) ;
641692}
642693
694+ function AssignedSourceRow ( {
695+ source,
696+ orgName,
697+ organizations,
698+ onReassign,
699+ onIgnore,
700+ } : {
701+ source : Listserv ;
702+ orgName : string ;
703+ organizations : Organization [ ] ;
704+ onReassign : ( orgId : Id < "organizations" > ) => void ;
705+ onIgnore : ( ) => void ;
706+ } ) {
707+ const [ reassigning , setReassigning ] = useState ( false ) ;
708+ return (
709+ < div className = "rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden" >
710+ < div className = "flex flex-wrap items-center gap-3 px-4 py-3" >
711+ < div className = "flex-1 min-w-0" >
712+ < div className = "font-semibold truncate" > { source . name } </ div >
713+ < div className = "text-[length:var(--font-size-body3)] text-[color:var(--color-text-muted)]" >
714+ { source . listEmail } · < span className = "text-[color:var(--color-neutral-700)]" > { orgName } </ span >
715+ </ div >
716+ </ div >
717+ { ! reassigning && (
718+ < div className = "flex gap-2 shrink-0" >
719+ < Btn onClick = { ( ) => setReassigning ( true ) } > Reassign</ Btn >
720+ < Btn danger onClick = { onIgnore } > Ignore</ Btn >
721+ </ div >
722+ ) }
723+ </ div >
724+ { reassigning && (
725+ < div className = "border-t border-[var(--color-border)] bg-[var(--color-neutral-100)] px-4 py-3 flex flex-wrap gap-3 items-end" >
726+ < label className = "flex flex-col gap-1 flex-1 min-w-[180px] text-[length:var(--font-size-body2)] font-semibold" >
727+ Reassign to
728+ < select
729+ defaultValue = { source . organizationId ?? "" }
730+ onChange = { ( e ) => { if ( e . target . value ) { onReassign ( e . target . value as Id < "organizations" > ) ; setReassigning ( false ) ; } } }
731+ className = { input ( ) }
732+ >
733+ < option value = "" > Choose…</ option >
734+ { organizations . map ( ( org ) => (
735+ < option key = { org . _id } value = { org . _id } > { org . name } </ option >
736+ ) ) }
737+ </ select >
738+ </ label >
739+ < Btn onClick = { ( ) => setReassigning ( false ) } > Cancel</ Btn >
740+ </ div >
741+ ) }
742+ </ div >
743+ ) ;
744+ }
745+
746+ function IgnoredSourceRow ( {
747+ source,
748+ organizations,
749+ onReactivate,
750+ onAssign,
751+ } : {
752+ source : Listserv ;
753+ organizations : Organization [ ] ;
754+ onReactivate : ( ) => void ;
755+ onAssign : ( orgId : Id < "organizations" > ) => void ;
756+ } ) {
757+ const [ assigning , setAssigning ] = useState ( false ) ;
758+ return (
759+ < div className = "rounded-xl border border-[var(--color-border)] bg-[var(--color-neutral-100)] overflow-hidden" >
760+ < div className = "flex flex-wrap items-center gap-3 px-4 py-3" >
761+ < div className = "flex-1 min-w-0" >
762+ < div className = "font-semibold truncate text-[color:var(--color-text-muted)]" > { source . name } </ div >
763+ < div className = "text-[length:var(--font-size-body3)] text-[color:var(--color-text-muted)]" > { source . listEmail } </ div >
764+ </ div >
765+ { ! assigning && (
766+ < div className = "flex gap-2 shrink-0" >
767+ < Btn primary onClick = { onReactivate } > Reactivate</ Btn >
768+ < Btn onClick = { ( ) => setAssigning ( true ) } > Assign org</ Btn >
769+ </ div >
770+ ) }
771+ </ div >
772+ { assigning && (
773+ < div className = "border-t border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 flex flex-wrap gap-3 items-end" >
774+ < label className = "flex flex-col gap-1 flex-1 min-w-[180px] text-[length:var(--font-size-body2)] font-semibold" >
775+ Assign to org
776+ < select
777+ defaultValue = ""
778+ onChange = { ( e ) => { if ( e . target . value ) { onAssign ( e . target . value as Id < "organizations" > ) ; setAssigning ( false ) ; } } }
779+ className = { input ( ) }
780+ >
781+ < option value = "" > Choose…</ option >
782+ { organizations . map ( ( org ) => (
783+ < option key = { org . _id } value = { org . _id } > { org . name } </ option >
784+ ) ) }
785+ </ select >
786+ </ label >
787+ < Btn onClick = { ( ) => setAssigning ( false ) } > Cancel</ Btn >
788+ </ div >
789+ ) }
790+ </ div >
791+ ) ;
792+ }
793+
794+ function OrgRow ( {
795+ org,
796+ sourceCount,
797+ onUpdate,
798+ } : {
799+ org : Organization ;
800+ sourceCount : number ;
801+ onUpdate : ( name : string , type : OrgType ) => void ;
802+ } ) {
803+ const [ editing , setEditing ] = useState ( false ) ;
804+ const [ name , setName ] = useState ( org . name ) ;
805+ const [ type , setType ] = useState < OrgType > ( org . type ) ;
806+
807+ // Reset draft if org prop changes (e.g. after save)
808+ const savedName = org . name ;
809+ const savedType = org . type ;
810+
811+ return (
812+ < div className = "rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden" >
813+ < div className = "flex items-center gap-3 px-4 py-3" >
814+ { editing ? (
815+ < >
816+ < input
817+ value = { name }
818+ onChange = { ( e ) => setName ( e . target . value ) }
819+ className = { `${ input ( ) } flex-1` }
820+ autoFocus
821+ />
822+ < select value = { type } onChange = { ( e ) => setType ( e . target . value as OrgType ) } className = { `${ input ( ) } w-36` } >
823+ { ORG_TYPES . map ( ( t ) => < option key = { t } value = { t } > { t } </ option > ) }
824+ </ select >
825+ < Btn primary onClick = { ( ) => { onUpdate ( name . trim ( ) || savedName , type ) ; setEditing ( false ) ; } } > Save</ Btn >
826+ < Btn onClick = { ( ) => { setName ( savedName ) ; setType ( savedType ) ; setEditing ( false ) ; } } > Cancel</ Btn >
827+ </ >
828+ ) : (
829+ < >
830+ < span className = "flex-1 font-semibold" > { org . name } </ span >
831+ < Tag > { org . type } </ Tag >
832+ < span className = "text-[color:var(--color-text-muted)] text-[length:var(--font-size-body3)]" >
833+ { sourceCount } source{ sourceCount !== 1 ? "s" : "" }
834+ </ span >
835+ < Btn onClick = { ( ) => setEditing ( true ) } > Edit</ Btn >
836+ </ >
837+ ) }
838+ </ div >
839+ </ div >
840+ ) ;
841+ }
842+
643843// Unified row for both known sources (listservs row exists, no org) and
644844// inbox-only senders (no listservs row yet). isNewSource distinguishes them visually.
645845function UnassignedRow ( {
0 commit comments