248 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|