Skip to content

Commit 4aa9077

Browse files
mojtabakarimiclaude
andcommitted
feat: port unplanned filter, avatars, and assign popup
- Medewerker filter gains "Nog niet ingepland" option; shows tasks with no assignees in the grid view - New Medew. column with stacked avatar circles, resolved via the host-side fetchPrivateFile bridge (Employee.image is private) - Per-row "+" button opens AssignPopup for toggling frappe.desk.form.assign_to.add / remove with optimistic flip - EMPLOYEE_FIELDS pulls image + user_id; EmployeeInfoBar hidden in unplanned mode Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 94a45fc commit 4aa9077

6 files changed

Lines changed: 359 additions & 26 deletions

File tree

src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const EMPLOYEE_FIELDS = [
6161
"custom_contract_days_per_week",
6262
"custom_contract_hours_per_week",
6363
"custom_department_function",
64+
"image",
65+
"user_id",
6466
];
6567

6668
const PROJECT_FIELDS = [

src/bridge.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ type RpcMethod =
2424
| "updateDocument"
2525
| "callMethod"
2626
| "getActiveInstanceId"
27-
| "getErpNextAppUrl";
27+
| "getErpNextAppUrl"
28+
| "fetchPrivateFile";
2829

2930
interface RpcEnvelope {
3031
id: string;
@@ -109,3 +110,25 @@ export function getErpNextAppUrl(): string {
109110
export function getActiveInstanceId(): string {
110111
return INSTANCE_ID;
111112
}
113+
114+
/**
115+
* Fetch a private ERPNext file (e.g. Employee.image) via the host bridge.
116+
* The iframe has no ERPNext session cookie, so we round-trip through the
117+
* parent tab which holds the bridged session. Returns an inline `data:`
118+
* URL ready to drop into an `<img src>`.
119+
*
120+
* Per-path cache avoids refetching the same avatar N times per render.
121+
* Unresolved paths resolve to `null` so the caller can fall back to a
122+
* default avatar without tripping into an error state.
123+
*/
124+
const fileUrlCache = new Map<string, Promise<string | null>>();
125+
export function fetchPrivateFileUrl(path: string | null | undefined): Promise<string | null> {
126+
if (!path) return Promise.resolve(null);
127+
const existing = fileUrlCache.get(path);
128+
if (existing) return existing;
129+
const p = rpc<{ contentType: string; base64: string }>("fetchPrivateFile", path)
130+
.then((r) => `data:${r.contentType};base64,${r.base64}`)
131+
.catch(() => null);
132+
fileUrlCache.set(path, p);
133+
return p;
134+
}

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export interface EmployeeData {
4040
custom_contract_days_per_week: number;
4141
custom_contract_hours_per_week: number;
4242
custom_department_function: string;
43+
/** ERPNext-hosted avatar path (relative, e.g. /private/files/xxx.jpg). */
44+
image: string | null;
45+
/** Linked User doctype name (email). Required by frappe.desk.form.assign_to.add. */
46+
user_id: string | null;
4347
}
4448

4549
export interface ProjectData {

src/views/AssignPopup.tsx

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2+
import { useKgPlanning } from "../store";
3+
import { callMethod, fetchPrivateFileUrl } from "../bridge";
4+
import type { EmployeeData } from "../types";
5+
6+
interface Props {
7+
taskName: string;
8+
taskSubject: string;
9+
assignedEmployees: string[];
10+
anchor: { x: number; y: number };
11+
onClose: () => void;
12+
}
13+
14+
/**
15+
* Inline popup for adding/removing assignees on a Task via Frappe's
16+
* `frappe.desk.form.assign_to` endpoints. Positioned against a viewport
17+
* anchor with edge-clamping so it stays fully visible when opened near
18+
* the right or bottom edge of the grid.
19+
*/
20+
export default function AssignPopup({ taskName, taskSubject, assignedEmployees, anchor, onClose }: Props) {
21+
const { data, reload } = useKgPlanning();
22+
const employees = data?.employees ?? [];
23+
const rootRef = useRef<HTMLDivElement | null>(null);
24+
const [busy, setBusy] = useState<string | null>(null);
25+
const [assigned, setAssigned] = useState<Set<string>>(new Set(assignedEmployees));
26+
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchor.x, top: anchor.y });
27+
28+
// Sync local optimistic set if the parent-provided list changes under us
29+
// (e.g. after reload() completes).
30+
useEffect(() => {
31+
setAssigned(new Set(assignedEmployees));
32+
}, [assignedEmployees]);
33+
34+
// Outside-click + Escape close. Registered once per open.
35+
useEffect(() => {
36+
const onDown = (e: MouseEvent) => {
37+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose();
38+
};
39+
const onKey = (e: KeyboardEvent) => {
40+
if (e.key === "Escape") onClose();
41+
};
42+
window.addEventListener("mousedown", onDown);
43+
window.addEventListener("keydown", onKey);
44+
return () => {
45+
window.removeEventListener("mousedown", onDown);
46+
window.removeEventListener("keydown", onKey);
47+
};
48+
}, [onClose]);
49+
50+
// Clamp to viewport after first layout — reading offsetWidth/Height is
51+
// only reliable once React has committed the DOM.
52+
useLayoutEffect(() => {
53+
const el = rootRef.current;
54+
if (!el) return;
55+
const margin = 8;
56+
const w = el.offsetWidth;
57+
const h = el.offsetHeight;
58+
let left = anchor.x;
59+
let top = anchor.y;
60+
if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin);
61+
if (top + h > window.innerHeight - margin) top = Math.max(margin, window.innerHeight - h - margin);
62+
setPos({ left, top });
63+
}, [anchor.x, anchor.y]);
64+
65+
async function toggle(emp: EmployeeData) {
66+
if (!emp.user_id) return;
67+
if (busy) return;
68+
const wasAssigned = assigned.has(emp.user_id);
69+
// Optimistic toggle so the checkbox flips immediately.
70+
setAssigned((prev) => {
71+
const next = new Set(prev);
72+
if (wasAssigned) next.delete(emp.user_id!);
73+
else next.add(emp.user_id!);
74+
return next;
75+
});
76+
setBusy(emp.user_id);
77+
try {
78+
if (wasAssigned) {
79+
await callMethod("frappe.desk.form.assign_to.remove", {
80+
doctype: "Task",
81+
name: taskName,
82+
assign_to: emp.user_id,
83+
});
84+
} else {
85+
await callMethod("frappe.desk.form.assign_to.add", {
86+
assign_to: JSON.stringify([emp.user_id]),
87+
doctype: "Task",
88+
name: taskName,
89+
description: taskSubject,
90+
});
91+
}
92+
await reload();
93+
} catch (err) {
94+
// Roll back the optimistic flip on failure.
95+
setAssigned((prev) => {
96+
const next = new Set(prev);
97+
if (wasAssigned) next.add(emp.user_id!);
98+
else next.delete(emp.user_id!);
99+
return next;
100+
});
101+
console.error("[kg-planning] assign toggle failed:", err);
102+
alert(err instanceof Error ? err.message : String(err));
103+
} finally {
104+
setBusy(null);
105+
}
106+
}
107+
108+
return (
109+
<div
110+
ref={rootRef}
111+
style={{ position: "fixed", left: pos.left, top: pos.top, width: 240, maxHeight: 380, zIndex: 1000 }}
112+
className="bg-white border border-slate-200 rounded-md shadow-lg flex flex-col overflow-hidden"
113+
>
114+
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-700 flex items-center justify-between">
115+
<span className="truncate" title={taskSubject}>Toewijzen aan</span>
116+
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 cursor-pointer" aria-label="Close">×</button>
117+
</div>
118+
<ul className="flex-1 overflow-y-auto py-1">
119+
{employees.map((e) => (
120+
<AssignRow
121+
key={e.name}
122+
emp={e}
123+
checked={!!e.user_id && assigned.has(e.user_id)}
124+
disabled={!e.user_id || busy === e.user_id}
125+
onToggle={() => toggle(e)}
126+
/>
127+
))}
128+
</ul>
129+
</div>
130+
);
131+
}
132+
133+
function AssignRow({ emp, checked, disabled, onToggle }: {
134+
emp: EmployeeData;
135+
checked: boolean;
136+
disabled: boolean;
137+
onToggle: () => void;
138+
}) {
139+
const [avatar, setAvatar] = useState<string | null>(null);
140+
useEffect(() => {
141+
let cancelled = false;
142+
fetchPrivateFileUrl(emp.image).then((url) => {
143+
if (!cancelled) setAvatar(url);
144+
});
145+
return () => { cancelled = true; };
146+
}, [emp.image]);
147+
148+
return (
149+
<li>
150+
<button
151+
onClick={onToggle}
152+
disabled={disabled}
153+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left ${
154+
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-slate-50 cursor-pointer"
155+
}`}
156+
title={emp.user_id ?? "No linked user"}
157+
>
158+
<input type="checkbox" checked={checked} readOnly className="cursor-pointer" />
159+
{avatar ? (
160+
<img src={avatar} alt="" className="w-6 h-6 rounded-full object-cover border border-slate-200" />
161+
) : (
162+
<div className="w-6 h-6 rounded-full bg-slate-200 flex items-center justify-center text-[10px] text-slate-500">
163+
{initials(emp.employee_name)}
164+
</div>
165+
)}
166+
<span className="flex-1 truncate">{emp.employee_name}</span>
167+
</button>
168+
</li>
169+
);
170+
}
171+
172+
function initials(name: string): string {
173+
const parts = name.trim().split(/\s+/).filter(Boolean);
174+
if (parts.length === 0) return "?";
175+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
176+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
177+
}

src/views/FilterBar.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export default function FilterBar() {
2424
value={selectedEmployee}
2525
onChange={setSelectedEmployee}
2626
placeholder="Alle medewerkers"
27-
options={employees.map((e) => ({ value: e.name, label: e.employee_name }))}
27+
options={[
28+
{ value: "__unplanned__", label: "\u26A0 Nog niet ingepland" },
29+
...employees.map((e) => ({ value: e.name, label: e.employee_name })),
30+
]}
2831
/>
2932

3033
{activeView === "gantt" && (
@@ -98,7 +101,7 @@ function FilterSelect({ label, value, onChange, placeholder, options }: FilterSe
98101
* Displays contract details for context. */
99102
export function EmployeeInfoBar() {
100103
const { selectedEmployee, getEmployeeById } = useKgPlanning();
101-
if (!selectedEmployee) return null;
104+
if (!selectedEmployee || selectedEmployee === "__unplanned__") return null;
102105
const emp = getEmployeeById(selectedEmployee);
103106
if (!emp) return null;
104107

0 commit comments

Comments
 (0)