249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
import { Position } from '../../types';
|
|||
|
|
import { Briefcase, Loader2, Plus, Pencil, Trash2, X } from 'lucide-react';
|
|||
|
|
|
|||
|
|
export const PositionsSection: React.FC = () => {
|
|||
|
|
const [positions, setPositions] = useState<Position[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|||
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|||
|
|
const [createName, setCreateName] = useState('');
|
|||
|
|
const [createIsManagerial, setCreateIsManagerial] = useState(false);
|
|||
|
|
const [editName, setEditName] = useState('');
|
|||
|
|
const [editIsManagerial, setEditIsManagerial] = useState(false);
|
|||
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const load = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
const list = await backendApi.getPositions();
|
|||
|
|
setPositions(list);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error loading positions:', err);
|
|||
|
|
setPositions([]);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, [load]);
|
|||
|
|
|
|||
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!createName.trim()) return;
|
|||
|
|
try {
|
|||
|
|
setSaving(true);
|
|||
|
|
await backendApi.createPosition({ name: createName.trim(), isManagerial: createIsManagerial });
|
|||
|
|
setIsCreateOpen(false);
|
|||
|
|
setCreateName('');
|
|||
|
|
setCreateIsManagerial(false);
|
|||
|
|
await load();
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert(err?.message || 'Ошибка создания должности');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startEdit = (p: Position) => {
|
|||
|
|
setEditingId(p.id);
|
|||
|
|
setEditName(p.name);
|
|||
|
|
setEditIsManagerial(p.isManagerial ?? false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancelEdit = () => {
|
|||
|
|
setEditingId(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpdate = async (id: string) => {
|
|||
|
|
if (!editName.trim()) return;
|
|||
|
|
try {
|
|||
|
|
setSaving(true);
|
|||
|
|
await backendApi.updatePosition(id, { name: editName.trim(), isManagerial: editIsManagerial });
|
|||
|
|
setEditingId(null);
|
|||
|
|
await load();
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert(err?.message || 'Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (id: string) => {
|
|||
|
|
try {
|
|||
|
|
setSaving(true);
|
|||
|
|
await backendApi.deletePosition(id);
|
|||
|
|
setDeleteConfirmId(null);
|
|||
|
|
await load();
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert(err?.message || 'Ошибка удаления');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center py-12">
|
|||
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div className="flex items-center gap-3 mb-6">
|
|||
|
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
|||
|
|
<Briefcase className="w-5 h-5 text-primary-600" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Справочник должностей</h3>
|
|||
|
|
<p className="text-xs text-slate-500">
|
|||
|
|
Должности для назначения сотрудникам. Отметьте «Руководящая должность» для должностей руководителей (мастер, начальник участка и т.д.)
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex justify-end mb-4">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setIsCreateOpen(true)}
|
|||
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-primary-600 text-white text-sm font-bold hover:bg-primary-700"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" /> Добавить должность
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isCreateOpen && (
|
|||
|
|
<form
|
|||
|
|
onSubmit={handleCreate}
|
|||
|
|
className="mb-6 p-4 bg-slate-50 rounded-2xl border border-slate-200 flex flex-wrap items-end gap-4"
|
|||
|
|
>
|
|||
|
|
<div className="flex-1 min-w-[200px]">
|
|||
|
|
<label className="block text-[10px] font-black uppercase text-slate-500 mb-1">Название должности</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={createName}
|
|||
|
|
onChange={(e) => setCreateName(e.target.value)}
|
|||
|
|
placeholder="Например: Мастер участка"
|
|||
|
|
className="w-full p-2.5 border border-slate-200 rounded-xl text-sm"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={createIsManagerial}
|
|||
|
|
onChange={(e) => setCreateIsManagerial(e.target.checked)}
|
|||
|
|
className="rounded border-slate-300"
|
|||
|
|
/>
|
|||
|
|
<span className="text-sm font-medium text-slate-700">Руководящая должность</span>
|
|||
|
|
</label>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button type="button" onClick={() => { setIsCreateOpen(false); setCreateName(''); setCreateIsManagerial(false); }} className="px-4 py-2.5 rounded-xl border border-slate-200 text-slate-600 text-sm font-bold">
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
<button type="submit" disabled={saving} className="px-4 py-2.5 rounded-xl bg-primary-600 text-white text-sm font-bold disabled:opacity-50">
|
|||
|
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="border border-slate-200 rounded-2xl overflow-hidden">
|
|||
|
|
<table className="w-full text-left">
|
|||
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
<th className="p-3 text-[10px] font-black uppercase text-slate-500">Должность</th>
|
|||
|
|
<th className="p-3 text-[10px] font-black uppercase text-slate-500 w-48">Руководящая должность</th>
|
|||
|
|
<th className="p-3 text-[10px] font-black uppercase text-slate-500 w-28">Действия</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{positions.length === 0 ? (
|
|||
|
|
<tr>
|
|||
|
|
<td colSpan={3} className="p-6 text-center text-slate-500 text-sm">
|
|||
|
|
Нет должностей. Добавьте первую.
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
) : (
|
|||
|
|
positions.map((p) => (
|
|||
|
|
<tr key={p.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
|||
|
|
<td className="p-3">
|
|||
|
|
{editingId === p.id ? (
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={editName}
|
|||
|
|
onChange={(e) => setEditName(e.target.value)}
|
|||
|
|
className="w-full p-2 border border-slate-200 rounded-lg text-sm"
|
|||
|
|
autoFocus
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<span className="font-medium text-slate-800">{p.name}</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
<td className="p-3">
|
|||
|
|
{editingId === p.id ? (
|
|||
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={editIsManagerial}
|
|||
|
|
onChange={(e) => setEditIsManagerial(e.target.checked)}
|
|||
|
|
className="rounded border-slate-300"
|
|||
|
|
/>
|
|||
|
|
<span className="text-sm text-slate-600">Руководящая</span>
|
|||
|
|
</label>
|
|||
|
|
) : (
|
|||
|
|
<span className={p.isManagerial ? 'text-emerald-600 font-medium' : 'text-slate-400'}>
|
|||
|
|
{p.isManagerial ? 'Да' : 'Нет'}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
<td className="p-3">
|
|||
|
|
{editingId === p.id ? (
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<button type="button" onClick={() => handleUpdate(p.id)} disabled={saving} className="px-3 py-1.5 rounded-lg bg-primary-600 text-white text-xs font-bold hover:bg-primary-700 disabled:opacity-50">
|
|||
|
|
Сохранить
|
|||
|
|
</button>
|
|||
|
|
<button type="button" onClick={cancelEdit} className="p-2 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100" title="Отмена">
|
|||
|
|
<X className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : deleteConfirmId === p.id ? (
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-xs text-slate-500">Удалить?</span>
|
|||
|
|
<button type="button" onClick={() => handleDelete(p.id)} disabled={saving} className="text-xs font-bold text-red-600 hover:underline">
|
|||
|
|
Да
|
|||
|
|
</button>
|
|||
|
|
<button type="button" onClick={() => setDeleteConfirmId(null)} className="text-xs font-bold text-slate-500 hover:underline">
|
|||
|
|
Нет
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex items-center gap-1">
|
|||
|
|
<button type="button" onClick={() => startEdit(p)} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-primary-600" title="Редактировать">
|
|||
|
|
<Pencil className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
<button type="button" onClick={() => setDeleteConfirmId(p.id)} className="p-2 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-500" title="Удалить">
|
|||
|
|
<Trash2 className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|