# Company Group Management Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add group-level management to `client_management.cgi` so admins can perform bulk operations (grant access, toggle email, ungroup, export) across all accounts in a company group.

**Architecture:** New view modes within the existing `client_management.cgi` file — `?view=groups` for a searchable groups list, and `?group_id=N` for the group management page. Six new AJAX endpoints handle data retrieval and bulk mutations. All bulk actions log to `employer_history` with `$USER` as the contact.

**Tech Stack:** Perl CGI, DBI/MySQL, JSON, JavaScript (vanilla), Tailwind CSS via adminUI.pm

---

### Task 1: Add Action Dispatch + Groups List Endpoint

**Files:**
- Modify: `admin/client_management.cgi` (action dispatch block at lines 32-48, add new sub at end of Perl section before `printMain`)

**Step 1: Add new actions to the dispatch block**

At line 48, BEFORE the `else { printMain(); }` line, add these new dispatch entries:

```perl
elsif ($action eq 'get_groups')          { getGroups(); }
elsif ($action eq 'get_group_detail')    { getGroupDetail(); }
elsif ($action eq 'grant_group_access')  { grantGroupAccess(); }
elsif ($action eq 'toggle_group_email')  { toggleGroupEmail(); }
elsif ($action eq 'ungroup_all')         { ungroupAll(); }
elsif ($action eq 'export_group_csv')    { exportGroupCsv(); }
```

Also modify the `else` fallthrough to check for view/group_id params:

```perl
else {
    if ($q->param('view') && $q->param('view') eq 'groups') { printGroupsListView(); }
    elsif ($q->param('group_id')) { printGroupView(); }
    else { printMain(); }
}
```

**Step 2: Add the `getGroups` AJAX endpoint**

Add this sub after the existing `_approveOneJob` sub (after line 989), before `printMain`:

```perl
######################################################################
#### AJAX: get_groups (searchable list of all company groups)
######################################################################
sub getGroups {
    print $q->header('application/json');

    my $keyword = $q->param('keyword') || '';
    my $sort    = $q->param('sort') || 'name';
    my $dir     = $q->param('dir') || 'ASC';
    my $page    = int($q->param('page') || 0);
    my $per_page = 50;
    my $offset   = $page * $per_page;

    # Whitelist sort
    my %valid_sorts = map { $_ => 1 } qw(name member_count);
    $sort = 'name' unless $valid_sorts{$sort};
    $dir = ($dir eq 'DESC') ? 'DESC' : 'ASC';

    my @where = ("1=1");
    my @vals;

    if ($keyword) {
        push @where, "c.name LIKE ?";
        push @vals, "%$keyword%";
    }

    my $where_clause = join(' AND ', @where);

    my ($total) = $dbh->selectrow_array(qq~
        SELECT COUNT(DISTINCT c.id)
        FROM company c
        JOIN employer_data e ON e.company_id = c.id
        WHERE $where_clause
    ~, undef, @vals);

    my $order_col = $sort eq 'member_count' ? 'member_count' : 'c.name';

    my $sth = $dbh->prepare(qq~
        SELECT c.id, c.name,
               COUNT(e.employer_id) as member_count,
               CASE WHEN EXISTS (
                   SELECT 1 FROM paid_services ps
                   JOIN employer_data ed ON ps.employer_id = ed.employer_id
                   WHERE ed.company_id = c.id
                     AND ps.product_id IN (6,7)
                     AND ps.expire_date >= CURDATE()
               ) THEN 1 ELSE 0 END as has_active_services
        FROM company c
        JOIN employer_data e ON e.company_id = c.id
        WHERE $where_clause
        GROUP BY c.id, c.name
        ORDER BY $order_col $dir
        LIMIT ?, ?
    ~);
    $sth->execute(@vals, $offset, $per_page);

    my @groups;
    while (my $row = $sth->fetchrow_hashref()) {
        push @groups, $row;
    }

    print encode_json({
        success  => JSON::true,
        groups   => \@groups,
        total    => $total + 0,
        page     => $page + 0,
        per_page => $per_page + 0,
    });
    exit;
}
```

**Step 3: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 4: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 2: Add Group Detail + Reference Invoice Detection Endpoint

**Files:**
- Modify: `admin/client_management.cgi` (add sub after `getGroups`)

**Step 1: Add the `getGroupDetail` endpoint**

```perl
######################################################################
#### AJAX: get_group_detail (group info + members + reference invoice)
######################################################################
sub getGroupDetail {
    print $q->header('application/json');

    my $group_id = int($q->param('group_id') || 0);
    unless ($group_id) {
        print encode_json({ success => JSON::false, message => 'Missing group_id' });
        exit;
    }

    # Group info
    my ($group_name) = $dbh->selectrow_array(
        "SELECT name FROM company WHERE id = ?", undef, $group_id
    );
    unless ($group_name) {
        print encode_json({ success => JSON::false, message => 'Group not found' });
        exit;
    }

    # Member roster: invoiced first, then by last_name, first_name
    my $mem_sth = $dbh->prepare(qq~
        SELECT e.employer_id, e.first_name, e.last_name, e.email,
               e.usage_type, e.subscribed, e.company_name,
               CASE WHEN EXISTS (
                   SELECT 1 FROM invoices inv WHERE inv.employer_id = e.employer_id
               ) THEN 1 ELSE 0 END as has_invoice,
               GROUP_CONCAT(
                   DISTINCT CASE
                       WHEN ps.product_id = 7 AND ps.expire_date >= CURDATE() THEN 'Jobs'
                       WHEN ps.product_id = 6 AND ps.expire_date >= CURDATE() THEN 'Resumes'
                   END
                   ORDER BY ps.product_id SEPARATOR ', '
               ) as active_services
        FROM employer_data e
        LEFT JOIN paid_services ps ON ps.employer_id = e.employer_id
            AND ps.product_id IN (6,7)
            AND ps.expire_date >= CURDATE()
        WHERE e.company_id = ?
        GROUP BY e.employer_id
        ORDER BY has_invoice DESC, e.last_name ASC, e.first_name ASC
    ~);
    $mem_sth->execute($group_id);

    my @members;
    while (my $row = $mem_sth->fetchrow_hashref()) {
        push @members, $row;
    }

    # Reference invoice detection:
    # Walk invoices newest-first for group members, find first with active paid_services
    my $ref_sth = $dbh->prepare(qq~
        SELECT i.invoice_id, i.employer_id, i.invoice_creation_date, i.total_price,
               ed.first_name, ed.last_name
        FROM invoices i
        JOIN employer_data ed ON i.employer_id = ed.employer_id
        WHERE ed.company_id = ?
        ORDER BY i.invoice_creation_date DESC
    ~);
    $ref_sth->execute($group_id);

    my $reference = undef;
    while (my $inv = $ref_sth->fetchrow_hashref()) {
        # Check if this invoice has active services (product_id 6 or 7)
        my $svc_sth = $dbh->prepare(qq~
            SELECT product_id, start_date,
                   DATE_FORMAT(expire_date, '%Y-%m-%d') as expire_date,
                   associated_invoice
            FROM paid_services
            WHERE employer_id = ?
              AND associated_invoice = ?
              AND product_id IN (6,7)
              AND expire_date >= CURDATE()
        ~);
        $svc_sth->execute($inv->{employer_id}, $inv->{invoice_id});

        my @active_svcs;
        while (my $s = $svc_sth->fetchrow_hashref()) {
            push @active_svcs, $s;
        }

        if (@active_svcs) {
            $reference = {
                invoice_id   => $inv->{invoice_id},
                employer_id  => $inv->{employer_id},
                holder_name  => "$inv->{first_name} $inv->{last_name}",
                invoice_date => $inv->{invoice_creation_date},
                services     => \@active_svcs,
            };
            last;
        }
    }

    print encode_json({
        success      => JSON::true,
        group_id     => $group_id + 0,
        group_name   => $group_name,
        member_count => scalar(@members) + 0,
        members      => \@members,
        reference    => $reference,
    });
    exit;
}
```

**Step 2: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 3: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 3: Add Grant Access Endpoint

**Files:**
- Modify: `admin/client_management.cgi` (add sub after `getGroupDetail`)

**Step 1: Add the `grantGroupAccess` endpoint**

```perl
######################################################################
#### AJAX: grant_group_access (clone services from reference invoice)
######################################################################
sub grantGroupAccess {
    print $q->header('application/json');

    my $group_id   = int($q->param('group_id') || 0);
    my $invoice_id = int($q->param('invoice_id') || 0);
    my $holder_id  = int($q->param('holder_id') || 0);

    unless ($group_id && $invoice_id && $holder_id) {
        print encode_json({ success => JSON::false, message => 'Missing parameters' });
        exit;
    }

    # Get reference services from the invoice holder
    my $ref_sth = $dbh->prepare(qq~
        SELECT product_id, start_date, expire_date, associated_invoice
        FROM paid_services
        WHERE employer_id = ?
          AND associated_invoice = ?
          AND product_id IN (6,7)
          AND expire_date >= CURDATE()
    ~);
    $ref_sth->execute($holder_id, $invoice_id);

    my @ref_services;
    while (my $s = $ref_sth->fetchrow_hashref()) {
        push @ref_services, $s;
    }

    unless (@ref_services) {
        print encode_json({ success => JSON::false, message => 'No active services found on reference invoice' });
        exit;
    }

    # Get all group members EXCEPT the invoice holder
    my $mem_sth = $dbh->prepare(
        "SELECT employer_id FROM employer_data WHERE company_id = ? AND employer_id <> ?"
    );
    $mem_sth->execute($group_id, $holder_id);

    my @member_ids;
    while (my ($eid) = $mem_sth->fetchrow_array()) {
        push @member_ids, $eid;
    }

    my $updated = 0;
    my $inserted = 0;

    # Prepare statements
    my $check_sth = $dbh->prepare(qq~
        SELECT service_id, associated_invoice
        FROM paid_services
        WHERE employer_id = ? AND product_id = ? AND associated_invoice = ?
        LIMIT 1
    ~);
    my $update_sth = $dbh->prepare(qq~
        UPDATE paid_services
        SET start_date = ?, expire_date = ?, pay_date = CURDATE()
        WHERE service_id = ?
    ~);
    my $insert_sth = $dbh->prepare(qq~
        INSERT INTO paid_services
            (employer_id, product_id, pay_date, start_date, expire_date,
             quantity, number_used, associated_invoice)
        VALUES (?, ?, CURDATE(), ?, ?, 1, 0, ?)
    ~);

    # Get group name for log messages
    my ($group_name) = $dbh->selectrow_array(
        "SELECT name FROM company WHERE id = ?", undef, $group_id
    );

    foreach my $eid (@member_ids) {
        foreach my $svc (@ref_services) {
            # Check if member already has this product_id with same invoice
            $check_sth->execute($eid, $svc->{product_id}, $invoice_id);
            my ($existing_id, $existing_invoice) = $check_sth->fetchrow_array();

            if ($existing_id) {
                # Same invoice — update dates
                $update_sth->execute($svc->{start_date}, $svc->{expire_date}, $existing_id);
                $updated++;
            } else {
                # Different invoice or no existing — insert new
                $insert_sth->execute(
                    $eid, $svc->{product_id},
                    $svc->{start_date}, $svc->{expire_date},
                    $invoice_id
                );
                $inserted++;
            }
        }

        # Build service description for log
        my @svc_names = map {
            ($_->{product_id} == 7 ? 'Unlimited Jobs' : 'Unlimited Resumes')
            . ' until ' . $_->{expire_date}
        } @ref_services;
        my $svc_desc = join(', ', @svc_names);

        &logEvent($eid, $USER, "Bulk: Grant Access",
            "Granted via group '$group_name' (Invoice #$invoice_id): $svc_desc", $dbh);
    }

    print encode_json({
        success  => JSON::true,
        updated  => $updated + 0,
        inserted => $inserted + 0,
        total    => scalar(@member_ids) + 0,
    });
    exit;
}
```

**Step 2: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 3: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 4: Add Toggle Email + Ungroup All + Export Endpoints

**Files:**
- Modify: `admin/client_management.cgi` (add subs after `grantGroupAccess`)

**Step 1: Add the `toggleGroupEmail` endpoint**

```perl
######################################################################
#### AJAX: toggle_group_email (bulk set subscribed on/off)
######################################################################
sub toggleGroupEmail {
    print $q->header('application/json');

    my $group_id = int($q->param('group_id') || 0);
    my $set_to   = $q->param('set_to') || '';  # 'on' or 'off'

    unless ($group_id && ($set_to eq 'on' || $set_to eq 'off')) {
        print encode_json({ success => JSON::false, message => 'Missing parameters' });
        exit;
    }

    my $value = ($set_to eq 'on') ? 'checked' : '';

    my ($group_name) = $dbh->selectrow_array(
        "SELECT name FROM company WHERE id = ?", undef, $group_id
    );

    # Get all member ids for logging
    my $mem_sth = $dbh->prepare(
        "SELECT employer_id FROM employer_data WHERE company_id = ?"
    );
    $mem_sth->execute($group_id);

    my @member_ids;
    while (my ($eid) = $mem_sth->fetchrow_array()) {
        push @member_ids, $eid;
    }

    # Bulk update
    $dbh->do(
        "UPDATE employer_data SET subscribed = ? WHERE company_id = ?",
        undef, $value, $group_id
    );

    # Log on each account
    my $action_word = ($set_to eq 'on') ? 'ON' : 'OFF';
    foreach my $eid (@member_ids) {
        &logEvent($eid, $USER, "Bulk: Email Subscriptions $action_word",
            "Email subscriptions turned $action_word (group: $group_name)", $dbh);
    }

    print encode_json({
        success => JSON::true,
        count   => scalar(@member_ids) + 0,
        set_to  => $set_to,
    });
    exit;
}
```

**Step 2: Add the `ungroupAll` endpoint**

```perl
######################################################################
#### AJAX: ungroup_all (remove all members from group)
######################################################################
sub ungroupAll {
    print $q->header('application/json');

    my $group_id = int($q->param('group_id') || 0);
    unless ($group_id) {
        print encode_json({ success => JSON::false, message => 'Missing group_id' });
        exit;
    }

    my ($group_name) = $dbh->selectrow_array(
        "SELECT name FROM company WHERE id = ?", undef, $group_id
    );
    unless ($group_name) {
        print encode_json({ success => JSON::false, message => 'Group not found' });
        exit;
    }

    # Get all member ids for logging
    my $mem_sth = $dbh->prepare(
        "SELECT employer_id FROM employer_data WHERE company_id = ?"
    );
    $mem_sth->execute($group_id);

    my @member_ids;
    while (my ($eid) = $mem_sth->fetchrow_array()) {
        push @member_ids, $eid;
    }

    # Null out company_id for all members
    $dbh->do(
        "UPDATE employer_data SET company_id = NULL WHERE company_id = ?",
        undef, $group_id
    );

    # Log on each account
    foreach my $eid (@member_ids) {
        &logEvent($eid, $USER, "Bulk: Ungrouped",
            "Removed from group '$group_name' (ID: $group_id)", $dbh);
    }

    print encode_json({
        success    => JSON::true,
        count      => scalar(@member_ids) + 0,
        group_name => $group_name,
    });
    exit;
}
```

**Step 3: Add the `exportGroupCsv` endpoint**

```perl
######################################################################
#### AJAX: export_group_csv (download CSV of group members)
######################################################################
sub exportGroupCsv {
    my $group_id = int($q->param('group_id') || 0);
    unless ($group_id) {
        print $q->header('text/plain');
        print "Missing group_id";
        exit;
    }

    my ($group_name) = $dbh->selectrow_array(
        "SELECT name FROM company WHERE id = ?", undef, $group_id
    );
    $group_name ||= 'unknown';

    # Slugify group name for filename
    my $slug = lc($group_name);
    $slug =~ s/[^a-z0-9]+/_/g;
    $slug =~ s/^_|_$//g;

    print $q->header(
        -type       => 'text/csv',
        -attachment  => "${slug}_members.csv",
    );

    my $sth = $dbh->prepare(qq~
        SELECT last_name, first_name, email
        FROM employer_data
        WHERE company_id = ?
        ORDER BY last_name ASC, first_name ASC
    ~);
    $sth->execute($group_id);

    # CSV header
    print "Last Name,First Name,Email\n";

    while (my ($ln, $fn, $em) = $sth->fetchrow_array()) {
        # Escape CSV fields (double-quote if contains comma or quote)
        $ln = _csv_escape($ln || '');
        $fn = _csv_escape($fn || '');
        $em = _csv_escape($em || '');
        print "$ln,$fn,$em\n";
    }
    exit;
}

sub _csv_escape {
    my $val = shift;
    if ($val =~ /[,"\n]/) {
        $val =~ s/"/""/g;
        return qq~"$val"~;
    }
    return $val;
}
```

**Step 4: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 5: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 5: Add Groups List View (HTML + JS)

**Files:**
- Modify: `admin/client_management.cgi` (add `printGroupsListView` sub before `printMain`)

**Step 1: Add the `printGroupsListView` sub**

This renders a full HTML page with a searchable table of all company groups. Add this sub BEFORE `printMain`:

```perl
######################################################################
#### Groups List View (?view=groups)
######################################################################
sub printGroupsListView {
    print $q->header;

    printAdminHeader(
        title     => 'Company Groups',
        subtitle  => 'Manage accounts grouped by company',
        extra_css => getExtraCSS(),
    );
    printAdminNav(active => 'client_management.cgi');

    print q~
<div class="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-4" style="min-height:70vh">

    <!-- Breadcrumb -->
    <div class="flex items-center gap-2 mb-4 text-sm">
        <a href="client_management.cgi" class="text-hbcu-700 hover:text-hbcu-900 flex items-center gap-1">
            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
            Back to Client Search
        </a>
        <span class="text-gray-400">/</span>
        <span class="text-gray-600 font-medium">Company Groups</span>
    </div>

    <!-- Search bar -->
    <div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-4 px-5 py-4">
        <div class="flex items-center gap-3">
            <div class="relative flex-1 max-w-md">
                <svg class="absolute left-3.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg>
                <input type="text" id="g-keyword" placeholder="Search groups..." class="w-full pl-10 pr-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:bg-white focus:ring-2 focus:ring-hbcu-500/20 focus:border-hbcu-500 transition" oninput="debounceGroupSearch()">
            </div>
            <span id="g-count" class="text-xs text-gray-500 font-medium"></span>
        </div>
    </div>

    <!-- Groups table -->
    <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
        <table class="w-full text-sm">
            <thead>
                <tr class="text-[11px] uppercase tracking-wide text-gray-500 border-b border-gray-200 bg-gray-50">
                    <th class="text-left px-5 py-3 font-semibold cursor-pointer hover:text-gray-800" onclick="sortGroups('name')">Group Name</th>
                    <th class="text-center px-4 py-3 font-semibold cursor-pointer hover:text-gray-800" onclick="sortGroups('member_count')">Members</th>
                    <th class="text-center px-4 py-3 font-semibold">Active Services</th>
                </tr>
            </thead>
            <tbody id="groups-tbody">
                <tr><td colspan="3" class="text-center py-12 text-gray-400">Loading groups...</td></tr>
            </tbody>
        </table>
        <div id="g-pagination" class="flex items-center justify-between px-5 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500 hidden">
            <span id="g-page-info"></span>
            <div id="g-page-controls" class="flex gap-1.5"></div>
        </div>
    </div>
</div>

<script>
var gPage = 0, gTotal = 0, gSort = 'name', gDir = 'ASC', gTimer = null;

document.addEventListener('DOMContentLoaded', function() { loadGroups(); });

function debounceGroupSearch() {
    clearTimeout(gTimer);
    gTimer = setTimeout(function() { gPage = 0; loadGroups(); }, 400);
}

function sortGroups(field) {
    if (gSort === field) { gDir = (gDir === 'ASC') ? 'DESC' : 'ASC'; }
    else { gSort = field; gDir = 'ASC'; }
    gPage = 0;
    loadGroups();
}

function loadGroups() {
    var keyword = document.getElementById('g-keyword').value;
    var params = 'action=get_groups&keyword=' + encodeURIComponent(keyword)
        + '&sort=' + gSort + '&dir=' + gDir + '&page=' + gPage;

    document.getElementById('groups-tbody').innerHTML = '<tr><td colspan="3" class="text-center py-12 text-gray-400"><div class="animate-spin rounded-full h-6 w-6 border-2 border-gray-200 border-t-hbcu-700 mx-auto"></div></td></tr>';

    fetch('client_management.cgi?' + params)
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (!data.success) return;
            gTotal = data.total;
            document.getElementById('g-count').textContent = gTotal + ' group' + (gTotal !== 1 ? 's' : '');
            renderGroupsTable(data.groups);
            renderGroupsPagination(data.total, data.page, data.per_page);
        });
}

function renderGroupsTable(groups) {
    var el = document.getElementById('groups-tbody');
    if (!groups || groups.length === 0) {
        el.innerHTML = '<tr><td colspan="3" class="text-center py-12 text-gray-400">No groups found</td></tr>';
        return;
    }
    var html = '';
    groups.forEach(function(g) {
        html += '<tr class="border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition" onclick="window.location.href=\'client_management.cgi?group_id=' + g.id + '\'">';
        html += '<td class="px-5 py-3"><span class="font-medium text-gray-900">' + esc(g.name) + '</span></td>';
        html += '<td class="px-4 py-3 text-center"><span class="font-semibold text-gray-700">' + g.member_count + '</span></td>';
        html += '<td class="px-4 py-3 text-center">';
        if (g.has_active_services) {
            html += '<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-100 text-green-800">Active</span>';
        } else {
            html += '<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-gray-100 text-gray-500">None</span>';
        }
        html += '</td></tr>';
    });
    el.innerHTML = html;
}

function renderGroupsPagination(total, page, limit) {
    var pag = document.getElementById('g-pagination');
    var info = document.getElementById('g-page-info');
    var controls = document.getElementById('g-page-controls');
    if (total === 0) { pag.classList.add('hidden'); return; }
    pag.classList.remove('hidden');
    var start = page * limit + 1, end = Math.min((page + 1) * limit, total);
    info.textContent = start + '-' + end + ' of ' + total;
    var pages = Math.ceil(total / limit), html = '';
    if (page > 0) html += '<button onclick="event.stopPropagation();gPage=' + (page-1) + ';loadGroups()" class="px-3 py-1 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 font-medium">&larr; Prev</button>';
    if (page < pages - 1) html += '<button onclick="event.stopPropagation();gPage=' + (page+1) + ';loadGroups()" class="px-3 py-1 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 font-medium">Next &rarr;</button>';
    controls.innerHTML = html;
}

function esc(text) {
    if (!text) return '';
    var div = document.createElement('div');
    div.appendChild(document.createTextNode(text));
    return div.innerHTML;
}
</script>
~;

    printAdminFooter();
}
```

**Step 2: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 3: Test in browser**

Navigate to: `https://hbcuconnect.com/admin/client_management.cgi?view=groups`
Expected: Table of groups loads, search filters, pagination works

**Step 4: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 6: Add Group Management View (HTML + JS)

**Files:**
- Modify: `admin/client_management.cgi` (add `printGroupView` sub after `printGroupsListView`)

**Step 1: Add the `printGroupView` sub**

This is the main group management page (`?group_id=N`). It shows the header with group info + reference invoice, bulk action buttons, and the member roster.

```perl
######################################################################
#### Group Management View (?group_id=N)
######################################################################
sub printGroupView {
    my $group_id = int($q->param('group_id') || 0);

    print $q->header;

    printAdminHeader(
        title     => 'Group Management',
        subtitle  => 'Manage all accounts in a company group',
        extra_css => getExtraCSS(),
    );
    printAdminNav(active => 'client_management.cgi');

    print qq~
<div id="group-app" class="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-4" style="min-height:70vh">

    <!-- Breadcrumb -->
    <div class="flex items-center gap-2 mb-4 text-sm">
        <a href="client_management.cgi" class="text-hbcu-700 hover:text-hbcu-900 flex items-center gap-1">
            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
            Back to Search
        </a>
        <span class="text-gray-400">/</span>
        <a href="client_management.cgi?view=groups" class="text-hbcu-700 hover:text-hbcu-900">Groups</a>
        <span class="text-gray-400">/</span>
        <span id="bc-group-name" class="text-gray-600 font-medium">Loading...</span>
    </div>

    <!-- Group Header -->
    <div id="group-header" class="bg-white rounded-xl shadow-sm border border-gray-200 mb-4 px-6 py-5">
        <div class="text-center py-8 text-gray-400">
            <div class="animate-spin rounded-full h-8 w-8 border-2 border-gray-200 border-t-hbcu-700 mx-auto"></div>
            <span class="text-xs mt-3 block">Loading group...</span>
        </div>
    </div>

    <!-- Member Roster -->
    <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
        <div class="flex items-center justify-between px-5 py-3 bg-gradient-to-r from-gray-50 to-white border-b border-gray-200">
            <span id="roster-count" class="text-xs font-semibold text-gray-500 tracking-wide uppercase">Members</span>
        </div>
        <div id="roster-table-wrap">
            <div class="text-center py-12 text-gray-400 text-sm">Loading members...</div>
        </div>
    </div>
</div>

<!-- Toast Container -->
<div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>

<script>
var groupId = $group_id;
var groupData = null;

document.addEventListener('DOMContentLoaded', function() { loadGroupDetail(); });

function loadGroupDetail() {
    fetch('client_management.cgi?action=get_group_detail&group_id=' + groupId)
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (!data.success) {
                document.getElementById('group-header').innerHTML = '<div class="text-center py-8 text-red-500">' + esc(data.message) + '</div>';
                return;
            }
            groupData = data;
            document.getElementById('bc-group-name').textContent = data.group_name;
            renderGroupHeader(data);
            renderRoster(data.members);
        })
        .catch(function() {
            document.getElementById('group-header').innerHTML = '<div class="text-center py-8 text-red-500">Error loading group</div>';
        });
}

function renderGroupHeader(data) {
    var html = '';

    // Title row
    html += '<div class="flex items-start justify-between">';
    html += '<div>';
    html += '<h2 class="text-xl font-bold text-gray-900">' + esc(data.group_name) + '</h2>';
    html += '<p class="text-sm text-gray-500 mt-0.5">' + data.member_count + ' member' + (data.member_count !== 1 ? 's' : '') + '</p>';
    html += '</div>';
    html += '</div>';

    // Reference invoice info
    html += '<div class="mt-3 pt-3 border-t border-gray-100">';
    if (data.reference) {
        var ref = data.reference;
        html += '<div class="flex items-center gap-2 text-sm">';
        html += '<span class="text-gray-500">Reference Invoice:</span>';
        html += '<a href="invoice.cgi?invoice_id=' + ref.invoice_id + '" target="_blank" class="text-hbcu-700 hover:underline font-medium">#' + ref.invoice_id + '</a>';
        html += '<span class="text-gray-400">&middot;</span>';
        html += '<span class="text-gray-700">' + esc(ref.holder_name) + '</span>';
        html += '</div>';
        html += '<div class="flex flex-wrap gap-2 mt-2">';
        ref.services.forEach(function(s) {
            var label = s.product_id == 7 ? 'Unlimited Jobs' : 'Unlimited Resumes';
            html += '<span class="px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">' + label + ' until ' + formatShortDate(s.expire_date) + '</span>';
        });
        html += '</div>';
    } else {
        html += '<div class="flex items-center gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">';
        html += '<svg class="w-4 h-4 text-amber-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>';
        html += '<span class="text-sm text-amber-700">No active services found &#8212; Grant Access is disabled</span>';
        html += '</div>';
    }
    html += '</div>';

    // Bulk action buttons
    html += '<div class="mt-4 pt-4 border-t border-gray-100 flex flex-wrap items-center gap-3">';

    // Grant Access
    if (data.reference) {
        html += '<button onclick="doGrantAccess()" class="px-4 py-2 text-sm bg-hbcu-700 text-white rounded-lg hover:bg-hbcu-800 font-semibold shadow-sm transition flex items-center gap-2">';
        html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>';
        html += 'Grant Access</button>';
    } else {
        html += '<button disabled class="px-4 py-2 text-sm bg-gray-300 text-gray-500 rounded-lg font-semibold cursor-not-allowed flex items-center gap-2" title="No active services found">';
        html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>';
        html += 'Grant Access</button>';
    }

    // Toggle Email
    html += '<div class="flex items-center border border-gray-200 rounded-lg overflow-hidden">';
    html += '<button onclick="doToggleEmail(\'on\')" class="px-3 py-2 text-sm text-gray-700 hover:bg-green-50 hover:text-green-700 font-medium transition flex items-center gap-1.5">';
    html += '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>';
    html += 'Email On</button>';
    html += '<div class="w-px bg-gray-200 self-stretch"></div>';
    html += '<button onclick="doToggleEmail(\'off\')" class="px-3 py-2 text-sm text-gray-700 hover:bg-red-50 hover:text-red-700 font-medium transition flex items-center gap-1.5">';
    html += '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>';
    html += 'Email Off</button>';
    html += '</div>';

    // Ungroup All (danger)
    html += '<button onclick="doUngroupAll()" class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold shadow-sm transition flex items-center gap-2 ml-auto">';
    html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7a4 4 0 11-8 0 4 4 0 018 0zM9 14a6 6 0 00-6 6v1h12v-1a6 6 0 00-6-6zM21 12h-6"/></svg>';
    html += 'Ungroup All</button>';

    // Export
    html += '<a href="client_management.cgi?action=export_group_csv&group_id=' + groupId + '" class="px-4 py-2 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition flex items-center gap-2">';
    html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>';
    html += 'Export CSV</a>';

    html += '</div>';

    document.getElementById('group-header').innerHTML = html;
}

function renderRoster(members) {
    var countEl = document.getElementById('roster-count');
    countEl.textContent = members.length + ' Member' + (members.length !== 1 ? 's' : '');

    if (!members || members.length === 0) {
        document.getElementById('roster-table-wrap').innerHTML = '<div class="text-center py-12 text-gray-400 text-sm">No members in this group</div>';
        return;
    }

    var html = '<table class="w-full text-sm">';
    html += '<thead><tr class="text-[11px] uppercase tracking-wide text-gray-500 border-b border-gray-200 bg-gray-50">';
    html += '<th class="text-left px-5 py-2.5 font-semibold">Name</th>';
    html += '<th class="text-left px-4 py-2.5 font-semibold">Email</th>';
    html += '<th class="text-left px-4 py-2.5 font-semibold">Type</th>';
    html += '<th class="text-center px-4 py-2.5 font-semibold">Services</th>';
    html += '<th class="text-center px-4 py-2.5 font-semibold">Subscribed</th>';
    html += '</tr></thead><tbody>';

    members.forEach(function(m) {
        var isInvoiced = m.has_invoice == 1;
        var rowClass = isInvoiced ? 'bg-blue-50/40 border-l-4 border-l-blue-400' : 'border-l-4 border-l-transparent';
        var typeColor = {'Employer':'bg-blue-100 text-blue-800','Lead':'bg-yellow-100 text-yellow-800','Recruiter':'bg-purple-100 text-purple-800','Partner':'bg-teal-100 text-teal-800'}[m.usage_type] || 'bg-gray-100 text-gray-800';

        html += '<tr class="border-b border-gray-100 ' + rowClass + '">';
        html += '<td class="px-5 py-2.5">';
        if (isInvoiced) html += '<span class="text-blue-500 mr-1" title="Has invoices">&#9733;</span>';
        html += '<span class="font-medium text-gray-900">' + esc(m.last_name || '') + ', ' + esc(m.first_name || '') + '</span>';
        html += '</td>';
        html += '<td class="px-4 py-2.5 text-gray-600">' + esc(m.email || '') + '</td>';
        html += '<td class="px-4 py-2.5"><span class="px-2 py-0.5 rounded-full text-[10px] font-semibold ' + typeColor + '">' + esc(m.usage_type || '?') + '</span></td>';
        html += '<td class="px-4 py-2.5 text-center text-xs">' + (m.active_services ? '<span class="text-green-700 font-medium">' + esc(m.active_services) + '</span>' : '<span class="text-gray-300">&#8212;</span>') + '</td>';
        html += '<td class="px-4 py-2.5 text-center">';
        if (m.subscribed) {
            html += '<span class="text-green-600 font-bold">&#10003;</span>';
        } else {
            html += '<span class="text-gray-300">&#10005;</span>';
        }
        html += '</td></tr>';
    });

    html += '</tbody></table>';
    document.getElementById('roster-table-wrap').innerHTML = html;
}

//////////////////////////////////////////////////////////////////////
// Bulk Action Handlers
//////////////////////////////////////////////////////////////////////
function doGrantAccess() {
    if (!groupData || !groupData.reference) return;
    var ref = groupData.reference;
    var svcLabels = ref.services.map(function(s) {
        return (s.product_id == 7 ? 'Unlimited Jobs' : 'Unlimited Resumes') + ' until ' + formatShortDate(s.expire_date);
    }).join(' + ');
    var otherCount = groupData.member_count - 1;
    if (!confirm('Grant ' + svcLabels + ' to ' + otherCount + ' member' + (otherCount !== 1 ? 's' : '') + '?\\n\\nReference: Invoice #' + ref.invoice_id + ' (' + ref.holder_name + ')\\n\\nExisting services from the same invoice will be updated. New rows will be created for members with different or no existing services.')) return;

    var formData = new FormData();
    formData.append('action', 'grant_group_access');
    formData.append('group_id', groupId);
    formData.append('invoice_id', ref.invoice_id);
    formData.append('holder_id', ref.employer_id);

    fetch('client_management.cgi', { method: 'POST', body: formData })
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.success) {
                showToast('Granted access to ' + data.total + ' members (' + data.inserted + ' new, ' + data.updated + ' updated)', 'success');
                loadGroupDetail();
            } else {
                showToast(data.message || 'Grant failed', 'error');
            }
        })
        .catch(function() { showToast('Network error', 'error'); });
}

function doToggleEmail(setTo) {
    var label = setTo === 'on' ? 'ON' : 'OFF';
    if (!confirm('Set email subscriptions to ' + label + ' for all ' + groupData.member_count + ' group members?')) return;

    var formData = new FormData();
    formData.append('action', 'toggle_group_email');
    formData.append('group_id', groupId);
    formData.append('set_to', setTo);

    fetch('client_management.cgi', { method: 'POST', body: formData })
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.success) {
                showToast('Email subscriptions turned ' + label + ' for ' + data.count + ' members', 'success');
                loadGroupDetail();
            } else {
                showToast(data.message || 'Update failed', 'error');
            }
        })
        .catch(function() { showToast('Network error', 'error'); });
}

function doUngroupAll() {
    if (!confirm('REMOVE all ' + groupData.member_count + ' accounts from the "' + groupData.group_name + '" group?\\n\\nThis cannot be undone.')) return;
    if (!confirm('Are you sure? This will ungroup ALL ' + groupData.member_count + ' accounts.')) return;

    var formData = new FormData();
    formData.append('action', 'ungroup_all');
    formData.append('group_id', groupId);

    fetch('client_management.cgi', { method: 'POST', body: formData })
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.success) {
                showToast('Ungrouped ' + data.count + ' accounts from ' + data.group_name, 'success');
                setTimeout(function() {
                    window.location.href = 'client_management.cgi?view=groups';
                }, 1500);
            } else {
                showToast(data.message || 'Ungroup failed', 'error');
            }
        })
        .catch(function() { showToast('Network error', 'error'); });
}

//////////////////////////////////////////////////////////////////////
// Shared Helpers (reused from main CMS view)
//////////////////////////////////////////////////////////////////////
function showToast(message, type) {
    var container = document.getElementById('toast-container');
    var toast = document.createElement('div');
    var bgClass = type === 'success' ? 'bg-green-600' : (type === 'error' ? 'bg-red-600' : 'bg-gray-700');
    var icon = type === 'success' ? '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>' :
               '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
    toast.className = 'flex items-center gap-2 px-4 py-3 rounded-xl shadow-xl text-white text-sm font-medium ' + bgClass + ' transform translate-x-full transition-transform';
    toast.innerHTML = icon + '<span>' + esc(message) + '</span>';
    container.appendChild(toast);
    requestAnimationFrame(function() { toast.style.transform = 'translateX(0)'; });
    setTimeout(function() {
        toast.style.transform = 'translateX(120%)';
        setTimeout(function() { toast.remove(); }, 300);
    }, 3000);
}

function esc(text) {
    if (!text) return '';
    var div = document.createElement('div');
    div.appendChild(document.createTextNode(text));
    return div.innerHTML;
}

function formatShortDate(dateStr) {
    if (!dateStr) return '';
    var d = new Date(dateStr.replace(/-/g, '/'));
    if (isNaN(d.getTime())) return dateStr;
    return (d.getMonth()+1) + '/' + d.getDate() + '/' + d.getFullYear();
}
</script>
~;

    printAdminFooter();
}
```

**Step 2: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 3: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 7: Make Group Name Clickable in Search Results + Add Groups Link

**Files:**
- Modify: `admin/client_management.cgi` (JS `renderResults` function around line 1356-1361, and search bar around line 1024)

**Step 1: Make group name clickable in search result cards**

In the `renderResults` function, find the middle column section (around line 1356-1361):

```javascript
// MIDDLE COLUMN (30%): Group name (from company table)
html += '<div class="w-[30%] min-w-0 text-center">';
if (c.group_name) {
    html += '<div class="text-xs text-gray-600 truncate mt-0.5" title="' + esc(c.group_name) + '">' + esc(c.group_name) + '</div>';
}
html += '</div>';
```

Replace with:

```javascript
// MIDDLE COLUMN (30%): Group name (clickable link to group management)
html += '<div class="w-[30%] min-w-0 text-center">';
if (c.group_name) {
    html += '<a href="client_management.cgi?group_id=' + (c.company_id || '') + '" onclick="event.stopPropagation()" class="text-xs text-hbcu-700 hover:text-hbcu-900 hover:underline truncate block mt-0.5" title="Manage ' + esc(c.group_name) + ' group">' + esc(c.group_name) + '</a>';
}
html += '</div>';
```

Note: The `company_id` is not currently in the search query results. Add it to the search SQL SELECT clause (around line 174):

In the main search SQL, add `a.company_id` to the SELECT list.

**Step 2: Add "Browse Groups" link to search bar**

In the search bar area (around line 1024, after the "More Filters" button), add a "Groups" link before the toggle:

```html
<a href="client_management.cgi?view=groups" class="text-sm text-hbcu-700 hover:text-hbcu-900 font-medium px-3 py-2 rounded-lg hover:bg-hbcu-50 flex items-center gap-1.5 transition">
    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
    Groups
</a>
```

**Step 3: Add `company_id` to search SQL**

In the search query SELECT clause (line 174), add `a.company_id` after `a.do_followup`:

Change: `a.do_followup, a.pricing_requested,`
To: `a.do_followup, a.pricing_requested, a.company_id,`

**Step 4: Verify syntax**

Run: `cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c client_management.cgi`
Expected: `client_management.cgi syntax OK`

**Step 5: Test in browser**

1. Navigate to `https://hbcuconnect.com/admin/client_management.cgi`
2. Search for "General Dynamics"
3. Verify group name in result cards is a clickable link
4. Click the group name — should navigate to `?group_id=8`
5. Verify "Groups" link appears in search bar
6. Click "Groups" — should navigate to `?view=groups`

**Step 6: Fix ownership**

Run: `chown hbcuconnect:psacln /var/www/vhosts/hbcuconnect.com/httpdocs/admin/client_management.cgi`

---

### Task 8: End-to-End Testing

**Step 1: Test Groups List**

Navigate to: `https://hbcuconnect.com/admin/client_management.cgi?view=groups`
- [ ] Groups load in table with name, member count, active services badge
- [ ] Search filters the list
- [ ] Clicking a group navigates to `?group_id=N`

**Step 2: Test Group Management View**

Navigate to: `https://hbcuconnect.com/admin/client_management.cgi?group_id=8` (General Dynamics)
- [ ] Breadcrumb shows: Back to Search / Groups / General Dynamics
- [ ] Header shows group name and member count
- [ ] Reference invoice is detected and displayed with service badges
- [ ] Member roster shows invoiced accounts first (star icon, blue highlight)
- [ ] Roster sorted by last name, then first name
- [ ] Services column shows "Jobs, Resumes" or dash
- [ ] Subscribed column shows checkmark or X
- [ ] Export CSV downloads file with correct columns

**Step 3: Test Grant Access**

- [ ] Click "Grant Access" — confirmation dialog shows service details, invoice #, holder name
- [ ] Confirm — toast shows "Granted access to N members (X new, Y updated)"
- [ ] Roster refreshes and shows updated services
- [ ] Check `employer_history` for log entries with your username

**Step 4: Test Toggle Email**

- [ ] Click "Email Off" — confirmation dialog
- [ ] Confirm — toast shows count, roster refreshes
- [ ] Subscribed column all shows X
- [ ] Click "Email On" — reverses, all show checkmark
- [ ] Check `employer_history` for log entries

**Step 5: Test Ungroup All (use a TEST group, not a real one)**

- [ ] Create a test group in `clients.cgi` with 2-3 dummy accounts
- [ ] Navigate to that group in the new UI
- [ ] Click "Ungroup All" — double confirmation
- [ ] Redirects to groups list after completion
- [ ] Test group no longer appears
- [ ] Verify accounts have `company_id = NULL`

**Step 6: Test entry from search results**

- [ ] Search for a client with a group in main CMS
- [ ] Group name appears as clickable link
- [ ] Clicking navigates to group page
- [ ] "Groups" button in search bar works
