Compare commits

...

4 Commits

Author SHA1 Message Date
tcardoza ff9bf7afa4 filter and search improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m8s
2026-03-13 11:14:49 -05:00
tcardoza 8ad5499f06 filtering by status added 2026-03-13 11:05:33 -05:00
tcardoza bd8c610584 removed migration code now that migration is complete 2026-03-13 10:52:40 -05:00
tcardoza 11daf918b6 migration of job_id tags in notes and updated mine filter 2026-03-13 10:50:22 -05:00
3 changed files with 315 additions and 16 deletions
+188 -5
View File
@@ -17,6 +17,8 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
const [tagSearch, setTagSearch] = createSignal("");
const [newTemplateName, setNewTemplateName] = createSignal("");
const [isSaving, setIsSaving] = createSignal(false);
const [isJobStatusOpen, setIsJobStatusOpen] = createSignal(false);
const [isJobDivisionOpen, setIsJobDivisionOpen] = createSignal(false);
const mode = () => props.mode || 'tasks';
@@ -30,13 +32,36 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
const hasActiveFilters = () => {
const f = currentFilter();
const baseActive = (f.query || "") !== "" || (f.tags || []).length > 0 || f.editedToday || f.ownedByMe || f.starred;
const jobActive = (f.jobStatus?.length || 0) > 0 || (f.jobDivision?.length || 0) > 0;
if (mode() === 'tasks') {
return baseActive || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
}
return baseActive;
return baseActive || jobActive;
};
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
const JOB_STATUSES = [
"Estimating",
"Est Sent",
"Est Hold",
"Follow Up",
"Awarded",
"In Progress",
"On Hold",
"Billed (In Progress)",
"Billed (Closed)",
"Archive",
"Not Awarded",
"Not Bidding"
];
const JOB_DIVISIONS = ["C#", "R#", "S#"];
const normalizeFilterTags = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => {
if (typeof i === "string") return { name: i, excluded: false };
return { name: i.name, excluded: !!i.excluded };
});
};
return (
<div class={cn(mode() === 'tasks' ? "fixed top-4 right-5 sm:right-6 z-[60]" : "relative", props.class)}>
@@ -54,10 +79,14 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
title="Filters & Search"
>
<div class="relative">
<Search size={18} class={cn("transition-transform duration-300", isOpen() && "scale-110")} />
{hasActiveFilters() && !isOpen() && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-primary animate-pulse border border-background" />
)}
<Search
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
</div>
</PopoverTrigger>
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
@@ -240,6 +269,160 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
</div>
</div>
</Show>
<Show when={mode() === 'notes'}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Status</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobStatus as any)}>
{(status) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
status.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-blue-500/10 text-blue-600 border-blue-500/20 hover:bg-blue-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
const next = current.map(s =>
s.name === status.name ? { ...s, excluded: !s.excluded } : s
);
currentSetFilter({ jobStatus: next as any });
}}
>
<Show when={status.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(status.excluded && "line-through opacity-70")}>{status.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobStatus as any).filter(s => s.name !== status.name);
currentSetFilter({ jobStatus: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobStatusOpen()} onOpenChange={setIsJobStatusOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Status</span>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<For each={JOB_STATUSES}>
{(status) => (
<CommandItem
value={status}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
if (!current.some(s => s.name === status)) {
currentSetFilter({ jobStatus: [...current, { name: status, excluded: false }] as any });
}
setIsJobStatusOpen(false);
}}
>
{status}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Division</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobDivision as any)}>
{(division) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
division.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
const next = current.map(d =>
d.name === division.name ? { ...d, excluded: !d.excluded } : d
);
currentSetFilter({ jobDivision: next as any });
}}
>
<Show when={division.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(division.excluded && "line-through opacity-70")}>{division.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobDivision as any).filter(d => d.name !== division.name);
currentSetFilter({ jobDivision: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobDivisionOpen()} onOpenChange={setIsJobDivisionOpen}>
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Division</span>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search division..." />
<CommandList>
<CommandEmpty>No division found.</CommandEmpty>
<CommandGroup>
<For each={JOB_DIVISIONS}>
{(division) => (
<CommandItem
value={division}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
if (!current.some(d => d.name === division)) {
currentSetFilter({ jobDivision: [...current, { name: division, excluded: false }] as any });
}
setIsJobDivisionOpen(false);
}}
>
{division}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</Show>
{/* Tags Multi-select */}
<div class="space-y-2.5">
+112 -7
View File
@@ -1,5 +1,5 @@
import { type Component, createMemo, For, Show } from "solid-js";
import { store, getFavoriteTag } from "@/store";
import { type Component, createMemo, createResource, For, Show } from "solid-js";
import { store, setNoteFilter, getFavoriteTag } from "@/store";
import { pb } from "@/lib/pocketbase";
import { Plus, Lock, Star } from "lucide-solid";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,79 @@ export const NotesSidebar: Component<{
setSelectedNoteId: (id: string | null) => void;
}> = (props) => {
const currentUserId = pb.authStore.model?.id;
const [accessibleJobs] = createResource(currentUserId, async (userId) => {
if (!userId) return new Set<string>();
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter: [
`EstimatorUser = "${userId}"`,
`OfficeRepUser = "${userId}"`,
`ProjectManagerUser = "${userId}"`,
`Estimator = "${userId}"`,
`Office_Rep = "${userId}"`,
`Project_Manager = "${userId}"`
].join(" || "),
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job access list for notes filter:", e);
return new Set<string>();
}
});
const [jobFilterIds] = createResource(
() => {
const f = store.noteFilter;
const normalize = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => (typeof i === "string" ? { name: i, excluded: false } : { name: i.name, excluded: !!i.excluded }));
};
const status = normalize(f.jobStatus as any).sort((a, b) => a.name.localeCompare(b.name));
const division = normalize(f.jobDivision as any).sort((a, b) => a.name.localeCompare(b.name));
const key = JSON.stringify({ status, division });
return key;
},
async (key) => {
if (!key) return null;
const parsed = JSON.parse(key) as { status: Array<{ name: string; excluded: boolean }>; division: Array<{ name: string; excluded: boolean }> };
const status = parsed.status || [];
const division = parsed.division || [];
if (status.length === 0 && division.length === 0) return null;
const parts: string[] = [];
const statusInclude = status.filter(s => !s.excluded).map(s => s.name);
const statusExclude = status.filter(s => s.excluded).map(s => s.name);
const divisionInclude = division.filter(d => !d.excluded).map(d => d.name);
const divisionExclude = division.filter(d => d.excluded).map(d => d.name);
if (statusInclude.length > 0) {
parts.push(`(${statusInclude.map(s => `Job_Status = "${s}"`).join(" || ")})`);
}
if (statusExclude.length > 0) {
parts.push(`(${statusExclude.map(s => `Job_Status != "${s}"`).join(" && ")})`);
}
if (divisionInclude.length > 0) {
parts.push(`(${divisionInclude.map(d => `Job_Division = "${d}"`).join(" || ")})`);
}
if (divisionExclude.length > 0) {
parts.push(`(${divisionExclude.map(d => `Job_Division != "${d}"`).join(" && ")})`);
}
const filter = parts.join(" && ");
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter,
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job filter list for notes:", e);
return new Set<string>();
}
}
);
const filteredNotes = createMemo(() => {
const filter = store.noteFilter;
@@ -56,7 +129,30 @@ export const NotesSidebar: Component<{
// Owned By Me Filter
if (filter.ownedByMe) {
n = n.filter(note => note.user === currentUserId);
const accessible = accessibleJobs();
n = n.filter(note => {
if (note.tags?.includes("prism_job")) {
if (!accessible) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return accessible.has(jobId);
}
return note.user === currentUserId;
});
}
// Job Status / Division Filter (notes only)
if ((filter.jobStatus?.length || 0) > 0 || (filter.jobDivision?.length || 0) > 0) {
const allowedJobIds = jobFilterIds();
n = n.filter(note => {
if (!note.tags?.includes("prism_job")) return false;
if (!allowedJobIds) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return allowedJobIds.has(jobId);
});
}
// Starred Filter
@@ -93,15 +189,24 @@ export const NotesSidebar: Component<{
{/* Header */}
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
<div class="flex-1">
<input
type="text"
placeholder="Search notes, tags..."
value={store.noteFilter.query}
onInput={(e) => setNoteFilter({ query: e.currentTarget.value })}
class="w-full bg-muted/30 border border-border/50 rounded-xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<FilterBar
mode="notes"
class="relative top-0 right-0 z-0"
triggerClass="w-full justify-start gap-2 px-3 h-9 bg-muted/40 hover:bg-muted/60 border-border/40"
triggerClass="h-9 w-9 p-0 bg-muted/40 hover:bg-muted/60 border-border/40"
/>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-9 w-9 p-0 shrink-0 shadow-sm rounded-xl">
<Plus size={18} />
</Button>
</div>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-9 w-9 p-0 shrink-0 shadow-sm rounded-xl">
<Plus size={18} />
</Button>
</div>
</div>
+15 -4
View File
@@ -171,6 +171,8 @@ export interface Filter {
editedToday: boolean;
ownedByMe: boolean;
starred: boolean;
jobStatus: FilterTag[];
jobDivision: FilterTag[];
}
// Background sync for Context Switcher (polling fallback for realtime)
@@ -298,7 +300,9 @@ export const [store, setStore] = createStore<TaskStore>({
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false
starred: false,
jobStatus: [],
jobDivision: []
},
shareRules: [],
filterTemplates: [],
@@ -316,7 +320,9 @@ export const [store, setStore] = createStore<TaskStore>({
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false
starred: false,
jobStatus: [],
jobDivision: []
}
});
@@ -1095,6 +1101,7 @@ const runLegacyTagMigration = async () => {
}
};
export const initStore = async () => {
if (!pb.authStore.isValid || store.isInitializing) return;
setStore("isInitializing", true);
@@ -2735,7 +2742,9 @@ export const clearFilter = () => {
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false
starred: false,
jobStatus: [],
jobDivision: []
});
};
@@ -2817,7 +2826,9 @@ export const clearNoteFilter = () => {
urgencyMax: 10,
editedToday: false,
ownedByMe: false,
starred: false
starred: false,
jobStatus: [],
jobDivision: []
});
syncPreferences();
};