329 lines
9.8 KiB
Svelte
329 lines
9.8 KiB
Svelte
<script lang="ts">
|
|
import AssignColumnHeader from './AssignColumnHeader.svelte';
|
|
import AssignServiceGroup from './AssignServiceGroup.svelte';
|
|
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
|
import type { SvelteSet } from 'svelte/reactivity';
|
|
|
|
type EnrichedService = {
|
|
id: string;
|
|
date: string;
|
|
status: string;
|
|
notes: string | null;
|
|
teamMembers: { pk: string }[];
|
|
accountAddressId: string;
|
|
accountName: string;
|
|
addressName: string | null;
|
|
address: string | null;
|
|
};
|
|
|
|
type ColumnType = 'unassigned' | 'readyToAssign' | 'assigned';
|
|
|
|
interface Props {
|
|
column: ColumnType;
|
|
services: EnrichedService[];
|
|
groupedServices: Map<string, EnrichedService[]>;
|
|
expandedGroups: SvelteSet<string>;
|
|
updatingServices: Set<string>;
|
|
openTeamMemberDropdown: string | null;
|
|
editingDateServiceId?: string | null;
|
|
// Selection props
|
|
selectedServices: SvelteSet<string>;
|
|
isBulkOperating?: boolean;
|
|
// For Ready to Assign bulk operations
|
|
nonAdminTeamMembers?: { id: string; fullName: string }[];
|
|
bulkSelectedTeamMember?: string | null;
|
|
showBulkTeamMemberDropdown?: boolean;
|
|
// Callbacks
|
|
onToggleGroup: (groupKey: string) => void;
|
|
getTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
|
getNonDispatchTeamMemberNames: (teamMembers: { pk: string }[]) => string[];
|
|
getAvailableTeamMembers: (service: EnrichedService) => { pk: string; name: string }[];
|
|
getStagedTeamMemberDetails: (serviceId: string) => { pk: string; name: string }[];
|
|
hasStagedMembers: (serviceId: string) => boolean;
|
|
onAddDispatch: (service: EnrichedService) => void;
|
|
onRemoveDispatch: (service: EnrichedService) => void;
|
|
onSubmitStaged: (service: EnrichedService) => void;
|
|
onRemoveNonDispatch: (service: EnrichedService) => void;
|
|
onStageTeamMember: (serviceId: string, memberPk: string) => void;
|
|
onUnstageTeamMember: (serviceId: string, memberPk: string) => void;
|
|
onToggleDropdown: (serviceId: string | null) => void;
|
|
// Selection callbacks
|
|
onToggleSelection: (serviceId: string) => void;
|
|
onSelectAll: () => void;
|
|
onClearSelection: () => void;
|
|
onBulkAction: () => void;
|
|
// For Ready to Assign
|
|
onBulkTeamMemberSelect?: (memberPk: string) => void;
|
|
onToggleBulkTeamMemberDropdown?: () => void;
|
|
// Unassigned column specific
|
|
onUpdateDate?: (service: EnrichedService, newDate: string) => void;
|
|
onDeleteService?: (service: EnrichedService) => void;
|
|
onStartEditDate?: (serviceId: string) => void;
|
|
onCancelEditDate?: () => void;
|
|
}
|
|
|
|
let {
|
|
column,
|
|
services,
|
|
groupedServices,
|
|
expandedGroups,
|
|
updatingServices,
|
|
openTeamMemberDropdown,
|
|
editingDateServiceId = null,
|
|
selectedServices,
|
|
isBulkOperating = false,
|
|
nonAdminTeamMembers = [],
|
|
bulkSelectedTeamMember = null,
|
|
showBulkTeamMemberDropdown = false,
|
|
onToggleGroup,
|
|
getTeamMemberNames,
|
|
getNonDispatchTeamMemberNames,
|
|
getAvailableTeamMembers,
|
|
getStagedTeamMemberDetails,
|
|
hasStagedMembers,
|
|
onAddDispatch,
|
|
onRemoveDispatch,
|
|
onSubmitStaged,
|
|
onRemoveNonDispatch,
|
|
onStageTeamMember,
|
|
onUnstageTeamMember,
|
|
onToggleDropdown,
|
|
onToggleSelection,
|
|
onSelectAll,
|
|
onClearSelection,
|
|
onBulkAction,
|
|
onBulkTeamMemberSelect,
|
|
onToggleBulkTeamMemberDropdown,
|
|
onUpdateDate,
|
|
onDeleteService,
|
|
onStartEditDate,
|
|
onCancelEditDate
|
|
}: Props = $props();
|
|
|
|
let hasSelection = $derived(selectedServices.size > 0);
|
|
let allSelected = $derived(services.length > 0 && selectedServices.size === services.length);
|
|
|
|
function getEmptyIcon(): string {
|
|
switch (column) {
|
|
case 'unassigned':
|
|
case 'assigned':
|
|
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
|
|
case 'readyToAssign':
|
|
return 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z';
|
|
}
|
|
}
|
|
|
|
function getEmptyMessage(): string {
|
|
switch (column) {
|
|
case 'unassigned':
|
|
return 'No unassigned services';
|
|
case 'readyToAssign':
|
|
return 'No services ready to assign';
|
|
case 'assigned':
|
|
return 'No assigned services';
|
|
}
|
|
}
|
|
|
|
function getBulkActionLabel(): string {
|
|
switch (column) {
|
|
case 'unassigned':
|
|
return 'Add Dispatch';
|
|
case 'readyToAssign':
|
|
return 'Assign';
|
|
case 'assigned':
|
|
return 'Remove Team';
|
|
}
|
|
}
|
|
|
|
function getBulkActionDisabled(): boolean {
|
|
if (isBulkOperating) return true;
|
|
if (selectedServices.size === 0) return true;
|
|
return column === 'readyToAssign' && !bulkSelectedTeamMember;
|
|
}
|
|
|
|
function getSelectedTeamMemberName(): string {
|
|
if (!bulkSelectedTeamMember) return 'Select team member...';
|
|
const member = nonAdminTeamMembers.find((m) => {
|
|
// Extract pk from global id
|
|
const parts = atob(m.id).split(':');
|
|
return parts[1] === bulkSelectedTeamMember;
|
|
});
|
|
return member?.fullName ?? 'Unknown';
|
|
}
|
|
</script>
|
|
|
|
<div class="flex w-1/3 flex-col overflow-hidden rounded-xl bg-theme-card shadow-md">
|
|
<AssignColumnHeader {column} count={services.length} />
|
|
|
|
<!-- Bulk Action Bar -->
|
|
{#if services.length > 0}
|
|
<div class="border-b border-theme bg-theme-card px-3 py-2">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<!-- Select All / Clear -->
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
onclick={() => (allSelected ? onClearSelection() : onSelectAll())}
|
|
class="flex items-center gap-1.5 rounded interactive px-2 py-1 text-xs font-medium {hasSelection
|
|
? 'text-primary-600 dark:text-primary-400'
|
|
: 'text-theme-muted'}"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
style="fill: none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
{#if allSelected}
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
{:else if hasSelection}
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
{:else}
|
|
<circle cx="12" cy="12" r="9" />
|
|
{/if}
|
|
</svg>
|
|
{#if hasSelection}
|
|
<span>{selectedServices.size} selected</span>
|
|
{:else}
|
|
<span>Select all</span>
|
|
{/if}
|
|
</button>
|
|
{#if hasSelection}
|
|
<button onclick={onClearSelection} class="text-xs text-theme-muted hover:text-theme">
|
|
Clear
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Bulk Action -->
|
|
{#if hasSelection}
|
|
<div class="flex items-center gap-2">
|
|
{#if column === 'readyToAssign'}
|
|
<!-- Team member dropdown for bulk assignment -->
|
|
<div class="relative">
|
|
<button
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleBulkTeamMemberDropdown?.();
|
|
}}
|
|
class="flex items-center gap-1 rounded border border-theme interactive bg-theme px-2 py-1 text-xs font-medium text-theme"
|
|
>
|
|
<span class="max-w-[100px] truncate">{getSelectedTeamMemberName()}</span>
|
|
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{#if showBulkTeamMemberDropdown}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
role="menu"
|
|
tabindex="-1"
|
|
onclick={(e) => e.stopPropagation()}
|
|
class="absolute top-full right-0 z-30 mt-1 max-h-48 w-48 overflow-y-auto rounded-lg border border-theme bg-theme-card py-1 shadow-lg"
|
|
>
|
|
{#each nonAdminTeamMembers as member (member.id)}
|
|
{@const memberPk = atob(member.id).split(':')[1]}
|
|
<button
|
|
onclick={() => onBulkTeamMemberSelect?.(memberPk)}
|
|
class="block w-full px-3 py-2 text-left text-sm text-theme hover:bg-black/5 dark:hover:bg-white/10 {bulkSelectedTeamMember ===
|
|
memberPk
|
|
? 'bg-primary-50 dark:bg-primary-900/20'
|
|
: ''}"
|
|
>
|
|
{member.fullName}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<button
|
|
onclick={onBulkAction}
|
|
disabled={getBulkActionDisabled()}
|
|
class="flex items-center gap-1 rounded bg-primary-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{#if isBulkOperating}
|
|
<IconSpinner class="h-3 w-3" />
|
|
{:else}
|
|
<svg class="h-3 w-3" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
{getBulkActionLabel()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex-1 space-y-2 overflow-y-auto bg-theme p-4">
|
|
{#each [...groupedServices.entries()] as [groupKey, groupServices] (groupKey)}
|
|
<AssignServiceGroup
|
|
{groupKey}
|
|
services={groupServices}
|
|
{column}
|
|
isExpanded={expandedGroups.has(groupKey)}
|
|
{updatingServices}
|
|
{openTeamMemberDropdown}
|
|
{editingDateServiceId}
|
|
{selectedServices}
|
|
onToggle={() => onToggleGroup(groupKey)}
|
|
{getTeamMemberNames}
|
|
{getNonDispatchTeamMemberNames}
|
|
{getAvailableTeamMembers}
|
|
{getStagedTeamMemberDetails}
|
|
{hasStagedMembers}
|
|
{onAddDispatch}
|
|
{onRemoveDispatch}
|
|
{onSubmitStaged}
|
|
{onRemoveNonDispatch}
|
|
{onStageTeamMember}
|
|
{onUnstageTeamMember}
|
|
{onToggleDropdown}
|
|
{onToggleSelection}
|
|
{onUpdateDate}
|
|
{onDeleteService}
|
|
{onStartEditDate}
|
|
{onCancelEditDate}
|
|
/>
|
|
{/each}
|
|
{#if services.length === 0}
|
|
<div class="py-12 text-center text-theme-muted">
|
|
<svg
|
|
class="mx-auto mb-3 h-12 w-12"
|
|
style="fill: none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d={getEmptyIcon()}
|
|
/>
|
|
</svg>
|
|
{getEmptyMessage()}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|