EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
page.tsx
Go to the documentation of this file.
1'use client';
2
3import { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { Icon } from '@/contexts/IconContext';
6
7interface Department {
8 id: number;
9 name: string;
10}
11
12interface DepartmentNode extends Department {
13 children: number[];
14 isExpanded: boolean;
15}
16
17export default function DepartmentHierarchySettings() {
18 const [departments, setDepartments] = useState<Department[]>([]);
19 const [hierarchy, setHierarchy] = useState<Record<number, number[]>>({});
20 const [nodes, setNodes] = useState<Record<number, DepartmentNode>>({});
21 const [loading, setLoading] = useState(true);
22 const [saving, setSaving] = useState(false);
23 const [draggedDept, setDraggedDept] = useState<number | null>(null);
24 const [dragOverDept, setDragOverDept] = useState<number | null>(null);
25 const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
26
27 useEffect(() => {
28 loadData();
29 }, []);
30
31 const loadData = async () => {
32 try {
33 setLoading(true);
34
35 // Load departments
36 const deptResult = await apiClient.getDepartments();
37 if (deptResult.success && deptResult.data) {
38 const deptArray = (deptResult.data as any)?.DATS || [];
39 const deptList: Department[] = deptArray.map((dept: any) => ({
40 id: dept.f100 || dept.Depid,
41 name: dept.f101 || dept.Name
42 })).filter((d: Department) => d.id && d.name);
43
44 setDepartments(deptList.sort((a, b) => a.id - b.id));
45
46 // Load hierarchy configuration
47 const hierarchyResult = await fetch('/api/v1/departments/hierarchy');
48 const hierarchyData = await hierarchyResult.json();
49
50 if (hierarchyData.success) {
51 const loadedHierarchy = hierarchyData.data || {};
52 setHierarchy(loadedHierarchy);
53
54 // Build node structure
55 const nodeMap: Record<number, DepartmentNode> = {};
56 deptList.forEach(dept => {
57 nodeMap[dept.id] = {
58 ...dept,
59 children: loadedHierarchy[dept.id] || [],
60 isExpanded: false
61 };
62 });
63 setNodes(nodeMap);
64 }
65 }
66 } catch (error) {
67 console.error('Error loading data:', error);
68 showMessage('error', 'Failed to load department data');
69 } finally {
70 setLoading(false);
71 }
72 };
73
74 const showMessage = (type: 'success' | 'error', text: string) => {
75 setMessage({ type, text });
76 setTimeout(() => setMessage(null), 3000);
77 };
78
79 const handleSave = async () => {
80 try {
81 setSaving(true);
82
83 // Convert nodes back to hierarchy format
84 const newHierarchy: Record<number, number[]> = {};
85 Object.values(nodes).forEach(node => {
86 if (node.children.length > 0) {
87 newHierarchy[node.id] = node.children;
88 }
89 });
90
91 const response = await fetch('/api/v1/departments/hierarchy', {
92 method: 'POST',
93 headers: { 'Content-Type': 'application/json' },
94 body: JSON.stringify({ hierarchy: newHierarchy })
95 });
96
97 const result = await response.json();
98
99 if (result.success) {
100 setHierarchy(newHierarchy);
101 showMessage('success', 'Department hierarchy saved successfully!');
102 } else {
103 showMessage('error', result.error || 'Failed to save hierarchy');
104 }
105 } catch (error) {
106 console.error('Error saving hierarchy:', error);
107 showMessage('error', 'Failed to save department hierarchy');
108 } finally {
109 setSaving(false);
110 }
111 };
112
113 const handleDragStart = (e: React.DragEvent, deptId: number) => {
114 setDraggedDept(deptId);
115 e.dataTransfer.effectAllowed = 'move';
116 // Add visual feedback
117 if (e.currentTarget instanceof HTMLElement) {
118 e.currentTarget.style.opacity = '0.5';
119 }
120 };
121
122 const handleDragEnd = (e: React.DragEvent) => {
123 setDraggedDept(null);
124 setDragOverDept(null);
125 // Reset visual feedback
126 if (e.currentTarget instanceof HTMLElement) {
127 e.currentTarget.style.opacity = '1';
128 }
129 };
130
131 const handleDragOver = (e: React.DragEvent, targetId: number) => {
132 e.preventDefault();
133 e.stopPropagation(); // Stop bubbling to parent elements
134 e.dataTransfer.dropEffect = 'move';
135 setDragOverDept(targetId);
136 };
137
138 const handleDragLeave = (e: React.DragEvent) => {
139 e.stopPropagation(); // Stop bubbling to parent elements
140 // Only clear if actually leaving the element
141 if (e.currentTarget === e.target) {
142 setDragOverDept(null);
143 }
144 };
145
146 const handleDrop = (e: React.DragEvent, parentId: number) => {
147 e.preventDefault();
148 e.stopPropagation(); // Stop bubbling to parent elements
149
150 if (draggedDept === null || draggedDept === parentId) {
151 setDraggedDept(null);
152 return;
153 }
154
155 const updatedNodes = { ...nodes };
156 const parent = updatedNodes[parentId];
157
158 // Check if already a child
159 if (parent.children.includes(draggedDept)) {
160 setDraggedDept(null);
161 return;
162 }
163
164 // Remove from any existing parent (if moving within hierarchy)
165 Object.values(updatedNodes).forEach(node => {
166 if (node.children.includes(draggedDept)) {
167 node.children = node.children.filter(id => id !== draggedDept);
168 }
169 });
170
171 // Add to new parent
172 parent.children = [...parent.children, draggedDept];
173 setNodes(updatedNodes);
174
175 setDraggedDept(null);
176 };
177
178 const removeChild = (parentId: number, childId: number) => {
179 const updatedNodes = { ...nodes };
180 updatedNodes[parentId].children = updatedNodes[parentId].children.filter(id => id !== childId);
181 setNodes(updatedNodes);
182 };
183
184 const moveChild = (parentId: number, childId: number, direction: 'up' | 'down') => {
185 const updatedNodes = { ...nodes };
186 const children = [...updatedNodes[parentId].children];
187 const index = children.indexOf(childId);
188
189 if (direction === 'up' && index > 0) {
190 [children[index], children[index - 1]] = [children[index - 1], children[index]];
191 } else if (direction === 'down' && index < children.length - 1) {
192 [children[index], children[index + 1]] = [children[index + 1], children[index]];
193 }
194
195 updatedNodes[parentId].children = children;
196 setNodes(updatedNodes);
197 };
198
199 const toggleExpand = (deptId: number) => {
200 const updatedNodes = { ...nodes };
201 updatedNodes[deptId].isExpanded = !updatedNodes[deptId].isExpanded;
202 setNodes(updatedNodes);
203 };
204
205 const clearHierarchy = () => {
206 if (confirm('Are you sure you want to clear all department hierarchy settings?')) {
207 const clearedNodes = { ...nodes };
208 Object.keys(clearedNodes).forEach(key => {
209 clearedNodes[Number(key)].children = [];
210 clearedNodes[Number(key)].isExpanded = false;
211 });
212 setNodes(clearedNodes);
213 }
214 };
215
216 if (loading) {
217 return (
218 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
219 <div className="text-center py-12">
220 <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#00946b] mx-auto"></div>
221 <p className="mt-4 text-muted">Loading departments...</p>
222 </div>
223 </div>
224 );
225 }
226
227 return (
228 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
229
230 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
231 <div className="mb-8">
232 <h1 className="text-3xl font-bold text-text flex items-center gap-2">
233 <Icon name="account_tree" size={32} className="text-brand" />
234 Department Hierarchy Configuration
235 </h1>
236 <p className="mt-2 text-muted">
237 Configure how departments (also called categories) cascade in product forms. Drag departments onto others to create parent-child relationships.
238 </p>
239 <p className="mt-1 text-sm text-muted italic">
240 Note: "Department" and "Category" mean the same thing - they're different ways to classify your products.
241 </p>
242 </div>
243
244 {/* Message Banner */}
245 {message && (
246 <div className={`mb-6 p-4 rounded-lg ${
247 message.type === 'success' ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger'
248 }`}>
249 {message.text}
250 </div>
251 )}
252
253 {/* Action Buttons */}
254 <div className="mb-6 flex justify-end items-center">
255 <button
256 onClick={handleSave}
257 disabled={saving}
258 className="px-6 py-3 bg-success text-surface rounded-lg hover:bg-success/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-semibold flex items-center gap-2"
259 >
260 <Icon name={saving ? "hourglass_empty" : "save"} size={20} />
261 {saving ? 'Saving...' : 'Save Configuration'}
262 </button>
263 </div>
264
265 <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
266 {/* Available Departments (Source) */}
267 <div className="bg-surface rounded-lg shadow-md p-6">
268 <h2 className="text-xl font-bold text-text mb-4">Available Departments (Categories)</h2>
269 <p className="text-sm text-muted mb-4">
270 Drag departments from here onto departments in the hierarchy section to create parent-child relationships.
271 </p>
272 <div className="space-y-2">
273 {departments.map(dept => (
274 <div
275 key={dept.id}
276 draggable
277 onDragStart={(e) => handleDragStart(e, dept.id)}
278 onDragEnd={handleDragEnd}
279 className={`p-3 bg-surface-2 border-2 rounded-lg cursor-move transition-all transform ${
280 draggedDept === dept.id
281 ? 'border-brand bg-info/10 scale-105 shadow-lg'
282 : 'border-border hover:bg-surface-2 hover:border-brand hover:shadow-md'
283 }`}
284 >
285 <div className="flex items-center">
286 <Icon name="drag_indicator" size={20} className="text-muted mr-2" />
287 <span className="font-medium text-text">{dept.name}</span>
288 <span className="ml-2 text-sm text-muted">(ID: {dept.id})</span>
289 </div>
290 </div>
291 ))}
292 </div>
293 </div>
294
295 {/* Hierarchy Configuration (Target) */}
296 <div className="bg-surface rounded-lg shadow-md p-6">
297 <h2 className="text-xl font-bold text-text mb-4">Department Hierarchy</h2>
298 <p className="text-sm text-muted mb-4">
299 Drop departments here to define which categories can be sub-categories of others. This creates a cascading selection in product forms.
300 </p>
301 <div className="mb-3 p-3 bg-info/10 border border-info/30 rounded text-sm text-info">
302 <strong>How levels work:</strong> Children you add here become <strong>Department 2</strong> options when the parent is selected as <strong>Department 1</strong>. Continue the chain for levels 3 and 4.
303 </div>
304 <div className="space-y-3">
305 {departments.map(dept => {
306 const node = nodes[dept.id];
307 if (!node) return null;
308
309 return (
310 <div
311 key={dept.id}
312 onDragOver={(e) => handleDragOver(e, dept.id)}
313 onDragLeave={handleDragLeave}
314 onDrop={(e) => handleDrop(e, dept.id)}
315 className={`border-2 rounded-lg transition-all relative ${
316 draggedDept === dept.id
317 ? 'border-border bg-surface-2 opacity-50'
318 : dragOverDept === dept.id
319 ? 'border-brand bg-info/10 shadow-lg scale-102 ring-4 ring-blue-300 animate-pulse'
320 : 'border-border hover:border-[#00946b] bg-surface'
321 }`}
322 >
323 {/* Drop Indicator */}
324 {dragOverDept === dept.id && draggedDept !== dept.id && (
325 <div className="absolute top-1 right-1 bg-brand text-surface px-3 py-1 rounded text-xs font-bold shadow-xl z-10 pointer-events-none animate-bounce flex items-center gap-1">
326 <Icon name="add" size={14} /> Add as Level 2
327 </div>
328 )}
329 <div className="p-3 flex items-center justify-between">
330 <div className="flex items-center flex-1">
331 {node.children.length > 0 && (
332 <button
333 onClick={() => toggleExpand(dept.id)}
334 className="mr-2 text-muted hover:text-text"
335 >
336 <Icon name={node.isExpanded ? "expand_more" : "chevron_right"} size={20} />
337 </button>
338 )}
339 <div className="flex items-center gap-2">
340 <span className="font-semibold text-text">{dept.name}</span>
341 <span className="text-xs text-muted">(ID: {dept.id})</span>
342 {node.children.length > 0 && (
343 <span className="text-xs bg-info/20 text-info px-2 py-0.5 rounded font-medium">
344 Level 1 → Level 2
345 </span>
346 )}
347 </div>
348 </div>
349 {node.children.length > 0 && (
350 <span className="text-xs bg-success text-surface px-2 py-1 rounded">
351 {node.children.length} child{node.children.length !== 1 ? 'ren' : ''}
352 </span>
353 )}
354 </div>
355
356 {/* Children */}
357 {node.isExpanded && node.children.length > 0 && (
358 <div className="px-3 pb-3 space-y-2">
359 <div className="ml-8 pl-4 border-l-2 border-border space-y-2">
360 {node.children.map((childId, childIndex) => {
361 const childDept = departments.find(d => d.id === childId);
362 if (!childDept) return null;
363 const childNode = nodes[childId];
364 const isFirst = childIndex === 0;
365 const isLast = childIndex === node.children.length - 1;
366 return (
367 <div key={childId}>
368 <div
369 draggable
370 onDragStart={(e) => handleDragStart(e, childId)}
371 onDragEnd={handleDragEnd}
372 onDragOver={(e) => handleDragOver(e, childId)}
373 onDragLeave={handleDragLeave}
374 onDrop={(e) => handleDrop(e, childId)}
375 className={`relative flex items-center justify-between p-2 bg-surface-2 rounded border-2 transition-all cursor-move ${
376 draggedDept === childId
377 ? 'border-border opacity-50'
378 : dragOverDept === childId
379 ? 'border-accent bg-accent/20 shadow-xl ring-4 ring-accent/30 animate-pulse'
380 : 'border-border hover:border-accent'
381 }`}
382 >
383 {/* Drop Indicator for Level 3 */}
384 {dragOverDept === childId && draggedDept !== childId && (
385 <div className="absolute top-1 right-1 bg-info text-surface px-3 py-1 rounded text-xs font-bold shadow-xl z-10 pointer-events-none animate-bounce flex items-center gap-1">
386 <Icon name="add" size={14} /> Add as Level 3
387 </div>
388 )}
389 <div className="flex items-center gap-2 flex-1">
390 {childNode?.children.length > 0 && (
391 <button
392 onClick={() => toggleExpand(childId)}
393 className="text-muted hover:text-text text-xs"
394 >
395 <Icon name={childNode.isExpanded ? "expand_more" : "chevron_right"} size={16} />
396 </button>
397 )}
398 <span className="text-sm text-text">↳ {childDept.name}</span>
399 {childNode?.children.length > 0 && (
400 <span className="text-xs bg-accent/20 text-accent px-2 py-0.5 rounded font-medium">
401 Level 2 → Level 3
402 </span>
403 )}
404 </div>
405 <div className="flex items-center gap-1">
406 <button
407 onClick={() => moveChild(dept.id, childId, 'up')}
408 disabled={isFirst}
409 className="text-muted/70 hover:text-text disabled:opacity-30 disabled:cursor-not-allowed text-xs px-1"
410 title="Move up"
411 >
412
413 </button>
414 <button
415 onClick={() => moveChild(dept.id, childId, 'down')}
416 disabled={isLast}
417 className="text-muted/70 hover:text-text disabled:opacity-30 disabled:cursor-not-allowed text-xs px-1"
418 title="Move down"
419 >
420
421 </button>
422 <button
423 onClick={() => removeChild(dept.id, childId)}
424 className="text-danger hover:text-danger/80 text-xs px-2 py-1"
425 >
426 Remove
427 </button>
428 </div>
429 </div>
430
431 {/* Grandchildren (Level 3) */}
432 {childNode?.children.length > 0 && childNode.isExpanded && (
433 <div className="ml-8 pl-4 mt-2 border-l-2 border-border space-y-2">
434 {childNode.children.map((grandchildId, grandchildIndex) => {
435 const grandchildDept = departments.find(d => d.id === grandchildId);
436 if (!grandchildDept) return null;
437 const grandchildNode = nodes[grandchildId];
438 const isFirst = grandchildIndex === 0;
439 const isLast = grandchildIndex === childNode.children.length - 1;
440 return (
441 <div key={grandchildId}>
442 <div
443 draggable
444 onDragStart={(e) => handleDragStart(e, grandchildId)}
445 onDragEnd={handleDragEnd}
446 onDragOver={(e) => handleDragOver(e, grandchildId)}
447 onDragLeave={handleDragLeave}
448 onDrop={(e) => handleDrop(e, grandchildId)}
449 className={`relative flex items-center justify-between p-2 bg-surface-2 rounded border-2 transition-all cursor-move ${
450 draggedDept === grandchildId
451 ? 'border-border opacity-50'
452 : dragOverDept === grandchildId
453 ? 'border-success bg-success/20 shadow-xl ring-4 ring-success/30 animate-pulse'
454 : 'border-border hover:border-success'
455 }`}
456 >
457 {/* Drop Indicator for Level 4 */}
458 {dragOverDept === grandchildId && draggedDept !== grandchildId && (
459 <div className="absolute top-1 right-1 bg-success text-surface px-3 py-1 rounded text-xs font-bold shadow-xl z-10 pointer-events-none animate-bounce flex items-center gap-1">
460 <Icon name="add" size={14} /> Add as Level 4
461 </div>
462 )}
463 <div className="flex items-center gap-2 flex-1">
464 {grandchildNode?.children.length > 0 && (
465 <button
466 onClick={() => toggleExpand(grandchildId)}
467 className="text-muted hover:text-text text-xs"
468 >
469 <Icon name={grandchildNode.isExpanded ? "expand_more" : "chevron_right"} size={16} />
470 </button>
471 )}
472 <span className="text-sm text-text">↳↳ {grandchildDept.name}</span>
473 {grandchildNode?.children.length > 0 && (
474 <span className="text-xs bg-success/20 text-success px-2 py-0.5 rounded font-medium">
475 Level 3 → Level 4
476 </span>
477 )}
478 </div>
479 <div className="flex items-center gap-1">
480 <button
481 onClick={() => moveChild(childId, grandchildId, 'up')}
482 disabled={isFirst}
483 className="text-muted/70 hover:text-text disabled:opacity-30 disabled:cursor-not-allowed text-xs px-1"
484 title="Move up"
485 >
486
487 </button>
488 <button
489 onClick={() => moveChild(childId, grandchildId, 'down')}
490 disabled={isLast}
491 className="text-muted/70 hover:text-text disabled:opacity-30 disabled:cursor-not-allowed text-xs px-1"
492 title="Move down"
493 >
494
495 </button>
496 <button
497 onClick={() => removeChild(childId, grandchildId)}
498 className="text-danger hover:text-danger/80 text-xs px-2 py-1"
499 >
500 Remove
501 </button>
502 </div>
503 </div>
504
505 {/* Great-grandchildren (Level 4) */}
506 {grandchildNode?.children.length > 0 && grandchildNode.isExpanded && (
507 <div className="ml-8 pl-4 mt-2 border-l-2 border-border space-y-2">
508 {grandchildNode.children.map(greatGrandchildId => {
509 const greatGrandchildDept = departments.find(d => d.id === greatGrandchildId);
510 if (!greatGrandchildDept) return null;
511 return (
512 <div key={greatGrandchildId} className="flex items-center justify-between p-2 bg-surface-2 rounded border border-border">
513 <div className="flex items-center gap-2">
514 <span className="text-sm text-text">↳↳↳ {greatGrandchildDept.name}</span>
515 <span className="text-xs bg-success/20 text-success px-2 py-0.5 rounded font-medium">
516 Level 4
517 </span>
518 </div>
519 <button
520 onClick={() => removeChild(grandchildId, greatGrandchildId)}
521 className="text-danger hover:text-danger/80 text-xs px-2 py-1"
522 >
523 Remove
524 </button>
525 </div>
526 );
527 })}
528 </div>
529 )}
530 </div>
531 );
532 })}
533 </div>
534 )}
535 </div>
536 );
537 })}
538 </div>
539 </div>
540 )}
541 </div>
542 );
543 })}
544 </div>
545 </div>
546 </div>
547
548 {/* Help Section */}
549 <div className="mt-8 bg-info/10 border border-info/30 rounded-lg p-6">
550 <h3 className="font-semibold text-info mb-2">How it works:</h3>
551 <ul className="space-y-2 text-info text-sm">
552 <li>• <strong>Level 1 → 2:</strong> Drag a department from the left panel and drop it onto a department on the right</li>
553 <li>• <strong>Level 2 → 3:</strong> Click the arrow to expand Level 2 items, then drag and drop onto the purple children</li>
554 <li>• <strong>Level 3 → 4:</strong> Expand Level 3 items and drag onto the orange children</li>
555 <li>• Each level creates a cascading dropdown in product forms</li>
556 <li>• Hover over items to see which level they accept (border color changes)</li>
557 <li>• Click "Remove" to delete a relationship</li>
558 <li>• Don't forget to click "Save Configuration" when you're done!</li>
559 </ul>
560 </div>
561 </div>
562 </div>
563 );
564}