Files
TasGrid/src/components/NotesSidebar.tsx
T
tcardoza e104d74e24
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m20s
notes tags hiding
2026-03-20 18:24:33 -05:00

248 lines
11 KiB
TypeScript

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";
import { cn } from "@/lib/utils";
import { NOTES_COLLECTION } from "@/lib/constants";
import { FilterBar } from "./FilterBar";
import { normalizeNoteKey } from "@/lib/tags";
export const NotesSidebar: Component<{
selectedNoteId: string | null;
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;
let n = store.notes.filter(note => {
if (note.deletedAt) return false;
// Hide child notes from the main list
if (note.tags && note.tags.some(t => t.startsWith("child-of-"))) return false;
return true;
});
// Query Filter
if (filter.query) {
const q = filter.query.toLowerCase();
n = n.filter(note =>
note.title.toLowerCase().includes(q) ||
note.tags.some(t => t.toLowerCase().includes(q))
);
}
// Tags Filter
if (filter.tags.length > 0) {
const required = filter.tags.filter(t => !t.excluded).map(t => t.name);
const excluded = filter.tags.filter(t => t.excluded).map(t => t.name);
if (required.length > 0) {
n = n.filter(note => required.some(r => note.tags.includes(r)));
}
if (excluded.length > 0) {
n = n.filter(note => !excluded.some(e => note.tags.includes(e)));
}
}
// Edited Today Filter
if (filter.editedToday) {
const today = new Date();
today.setHours(0, 0, 0, 0);
n = n.filter(note => {
const updated = new Date(note.updated);
return updated >= today;
});
}
// Owned By Me Filter
if (filter.ownedByMe) {
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
if (filter.starred) {
n = n.filter(note => note.tags?.includes(getFavoriteTag()));
}
return n.sort((a, b) => {
const titleA = a.title || "Untitled";
const titleB = b.title || "Untitled";
return titleB.localeCompare(titleA);
});
});
const handleCreateNote = async () => {
if (!currentUserId) return;
try {
const keyBase = normalizeNoteKey("new-note");
const result = await pb.collection(NOTES_COLLECTION).create({
title: "New Note",
key: `${keyBase}-${Date.now()}`,
content: "",
tags: [],
isPrivate: false,
user: currentUserId,
tasks: []
});
props.setSelectedNoteId(result.id);
} catch (e) {
console.error("Failed to create note", e);
}
};
return (
<div class="flex flex-col h-full bg-card">
<div class="z-20">
{/* 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..."
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="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>
</div>
</div>
{/* List */}
<div class="flex-1 overflow-y-auto p-2 space-y-1 pb-20 md:pb-2">
<For each={filteredNotes()} fallback={<div class="p-4 text-center text-xs text-muted-foreground">{store.isNotesLoading ? "Loading notes..." : "No notes found."}</div>}>
{(note) => (
<button
onClick={() => props.setSelectedNoteId(note.id)}
class={cn(
"w-full text-left p-3 rounded-xl transition-all border",
props.selectedNoteId === note.id
? "bg-primary/5 border-primary/20 shadow-sm"
: "bg-card border-transparent hover:border-border hover:bg-muted/50"
)}
>
<div class="font-semibold text-sm truncate flex items-center justify-between">
<div class="flex items-center gap-1.5 truncate">
<Show when={note.tags?.includes(getFavoriteTag())}>
<Star size={12} class="fill-amber-500 text-amber-500 shrink-0" />
</Show>
<span class="truncate">{note.title || "Untitled"}</span>
</div>
<Show when={note.isPrivate}>
<Lock size={12} class="text-orange-500 shrink-0 ml-2" />
</Show>
</div>
</button>
)}
</For>
</div>
</div>
);
};