refactor(frontend): align calendar and list UI
This commit is contained in:
parent
601f0d58d8
commit
db05b4fcdb
@ -496,6 +496,7 @@ class EventRelationSerializer(serializers.ModelSerializer):
|
||||
|
||||
event_title = serializers.CharField(source="event.title", read_only=True)
|
||||
user_name = serializers.SerializerMethodField()
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
content_type = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ContentType.objects.all(),
|
||||
required=False, # Делаем необязательным
|
||||
@ -511,6 +512,7 @@ class EventRelationSerializer(serializers.ModelSerializer):
|
||||
"object_id",
|
||||
"distinction",
|
||||
"user_name",
|
||||
"user_avatar",
|
||||
]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
@ -521,6 +523,18 @@ class EventRelationSerializer(serializers.ModelSerializer):
|
||||
return str(obj.content_object)
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_user_avatar(self, obj):
|
||||
"""URL аватара связанного сотрудника."""
|
||||
user = getattr(obj, "content_object", None)
|
||||
avatar = getattr(user, "avatar", None)
|
||||
if avatar:
|
||||
try:
|
||||
return avatar.url
|
||||
except Exception:
|
||||
return str(avatar)
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Автоматически устанавливает content_type для User."""
|
||||
if "content_type" not in validated_data:
|
||||
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -153,23 +153,23 @@ export default function EquipmentPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
{loading ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<div className="app-surface rounded-2xl p-8 text-center">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка оборудования...</p>
|
||||
<p className="app-text-muted text-sm">Загрузка оборудования...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl bg-red-50 p-6 text-center">
|
||||
<div className="app-feedback-danger rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Оборудование</p>
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Оборудование</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void openCreateModal(); }}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600"
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Добавить оборудование
|
||||
</button>
|
||||
@ -186,14 +186,14 @@ export default function EquipmentPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Поиск по оборудованию"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Фильтры"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${filtersOpen ? "border-sky-400 bg-sky-50 text-sky-600" : "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100"}`}
|
||||
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
{activeFilterCount > 0 && (
|
||||
@ -207,7 +207,7 @@ export default function EquipmentPage() {
|
||||
<select
|
||||
value={ordering}
|
||||
onChange={(e) => setOrdering(e.target.value)}
|
||||
className="w-full appearance-none rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-8 text-xs font-medium text-gray-700 transition hover:bg-gray-100 focus:border-sky-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
|
||||
aria-label="Сортировка списка оборудования"
|
||||
>
|
||||
{orderingOptions.map((option) => (
|
||||
@ -226,8 +226,8 @@ export default function EquipmentPage() {
|
||||
onClick={() => setListMode(mode.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
listMode === mode.value
|
||||
? "bg-sky-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
? "app-pill-active"
|
||||
: "app-pill"
|
||||
}`}
|
||||
>
|
||||
<span>{mode.label}</span>
|
||||
@ -237,27 +237,27 @@ export default function EquipmentPage() {
|
||||
|
||||
{/* Filters panel */}
|
||||
{filtersOpen && (
|
||||
<div className="mb-3 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все статусы</option>
|
||||
{Object.entries(statusMeta).map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все категории</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все отделы</option>
|
||||
{departments.map((dep) => (
|
||||
<option key={dep.id} value={dep.id}>{dep.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={responsibleFilter} onChange={(e) => setResponsibleFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={responsibleFilter} onChange={(e) => setResponsibleFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все сотрудники</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{displayUserName(emp)}</option>
|
||||
@ -265,13 +265,13 @@ export default function EquipmentPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-xs text-gray-500">Дата покупки:</span>
|
||||
<input type="date" value={dateFromFilter} onChange={(e) => setDateFromFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="С" />
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
<input type="date" value={dateToFilter} onChange={(e) => setDateToFilter(e.target.value)} className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" placeholder="По" />
|
||||
<span className="app-text-muted shrink-0 text-xs">Дата покупки:</span>
|
||||
<input type="date" value={dateFromFilter} onChange={(e) => setDateFromFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" placeholder="С" />
|
||||
<span className="app-text-muted text-xs">—</span>
|
||||
<input type="date" value={dateToFilter} onChange={(e) => setDateToFilter(e.target.value)} className="app-input flex-1 rounded-lg px-3 py-2 text-sm" placeholder="По" />
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button type="button" onClick={() => { setStatusFilter(""); setCategoryFilter(""); setDepartmentFilter(""); setResponsibleFilter(""); setDateFromFilter(""); setDateToFilter(""); }} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100">
|
||||
<button type="button" onClick={() => { setStatusFilter(""); setCategoryFilter(""); setDepartmentFilter(""); setResponsibleFilter(""); setDateFromFilter(""); setDateToFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
Очистить фильтры
|
||||
</button>
|
||||
)}
|
||||
@ -528,8 +528,8 @@ export default function EquipmentPage() {
|
||||
{/* ===== Modal create/edit ===== */}
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"} size="md" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
<button type="button" onClick={closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
|
||||
<button type="button" onClick={() => handleSave(modalMode)} disabled={busyKey !== null} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{modalMode === "create" ? "Добавить" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
@ -539,12 +539,12 @@ export default function EquipmentPage() {
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Название */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Название оборудования *</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Название оборудования *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Ноутбук Lenovo ThinkPad X1..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -558,8 +558,8 @@ export default function EquipmentPage() {
|
||||
/>
|
||||
|
||||
{isCreateMode && previewInventoryNumber && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
|
||||
Следующий инвентарный номер: <span className="font-semibold text-gray-900">{previewInventoryNumber}</span>
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2 text-sm text-[var(--muted-foreground)]">
|
||||
Следующий инвентарный номер: <span className="font-semibold text-[var(--foreground)]">{previewInventoryNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -585,14 +585,14 @@ export default function EquipmentPage() {
|
||||
|
||||
{modalMode === "create" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Количество</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Количество</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.quantity}
|
||||
onChange={(e) => setForm((p) => ({ ...p, quantity: Math.max(1, Math.min(100, Number(e.target.value) || 1)) }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -600,58 +600,58 @@ export default function EquipmentPage() {
|
||||
{/* Дата покупки + стоимость */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Дата покупки *</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Дата покупки *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.purchase_date}
|
||||
onChange={(e) => setForm((p) => ({ ...p, purchase_date: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Стоимость (₽) *</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Стоимость (₽) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.purchase_cost}
|
||||
onChange={(e) => setForm((p) => ({ ...p, purchase_cost: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Серийный номер */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Серийный номер</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Серийный номер</label>
|
||||
<input
|
||||
value={form.serial_number}
|
||||
onChange={(e) => setForm((p) => ({ ...p, serial_number: e.target.value }))}
|
||||
placeholder="SN-XXXXX"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Расположение */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Расположение</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Расположение</label>
|
||||
<input
|
||||
value={form.location}
|
||||
onChange={(e) => setForm((p) => ({ ...p, location: e.target.value }))}
|
||||
placeholder="Офис 305, стол 2..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Заметки */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Заметки</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Заметки</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))}
|
||||
placeholder="Заметки об оборудовании..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -663,10 +663,10 @@ export default function EquipmentPage() {
|
||||
"Добавить обслуживание"
|
||||
} size="md" footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeOperationModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Перевести</button>}
|
||||
<button type="button" onClick={closeOperationModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
|
||||
{operationModal === "transfer" && <button type="button" onClick={() => { void handleTransfer(); }} disabled={busyKey !== null} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">Перевести</button>}
|
||||
{operationModal === "writeoff" && <button type="button" onClick={() => { void handleWriteOff(); }} disabled={busyKey !== null} className="rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60">Списать</button>}
|
||||
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">Добавить</button>}
|
||||
{operationModal === "maintenance" && <button type="button" onClick={() => { void handleMaintenance(); }} disabled={busyKey !== null} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">Добавить</button>}
|
||||
</div>
|
||||
}>
|
||||
{operationModal === "transfer" && (
|
||||
@ -686,24 +686,24 @@ export default function EquipmentPage() {
|
||||
onSelect={(id) => setTransferForm((prev) => ({ ...prev, to_person: id }))}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Причина</label>
|
||||
<textarea value={transferForm.reason} onChange={(e) => setTransferForm((prev) => ({ ...prev, reason: e.target.value }))} rows={3} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" />
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Причина</label>
|
||||
<textarea value={transferForm.reason} onChange={(e) => setTransferForm((prev) => ({ ...prev, reason: e.target.value }))} rows={3} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationModal === "writeoff" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Причина списания</label>
|
||||
<textarea value={writeOffReason} onChange={(e) => setWriteOffReason(e.target.value)} rows={4} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" />
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Причина списания</label>
|
||||
<textarea value={writeOffReason} onChange={(e) => setWriteOffReason(e.target.value)} rows={4} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationModal === "maintenance" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Тип обслуживания</label>
|
||||
<select value={maintenanceForm.type} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, type: e.target.value }))} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Тип обслуживания</label>
|
||||
<select value={maintenanceForm.type} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, type: e.target.value }))} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="maintenance">Обслуживание</option>
|
||||
<option value="inspection">Осмотр</option>
|
||||
@ -712,17 +712,17 @@ export default function EquipmentPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Дата</label>
|
||||
<input type="date" value={maintenanceForm.date} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, date: e.target.value }))} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" />
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Дата</label>
|
||||
<input type="date" value={maintenanceForm.date} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, date: e.target.value }))} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Стоимость</label>
|
||||
<input type="number" step="0.01" value={maintenanceForm.cost} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, cost: e.target.value }))} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" />
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Стоимость</label>
|
||||
<input type="number" step="0.01" value={maintenanceForm.cost} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, cost: e.target.value }))} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Описание</label>
|
||||
<textarea value={maintenanceForm.description} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, description: e.target.value }))} rows={4} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100" />
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Описание</label>
|
||||
<textarea value={maintenanceForm.description} onChange={(e) => setMaintenanceForm((prev) => ({ ...prev, description: e.target.value }))} rows={4} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -124,6 +124,11 @@ textarea {
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--foreground);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.app-field-label {
|
||||
@ -169,6 +174,11 @@ textarea {
|
||||
background: var(--surface-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.app-color-input::-webkit-color-swatch-wrapper {
|
||||
@ -184,6 +194,13 @@ textarea {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.app-input:hover,
|
||||
.app-select:hover,
|
||||
.app-color-input:hover {
|
||||
background: var(--surface-tertiary);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 22%, var(--border-strong));
|
||||
}
|
||||
|
||||
.app-input:focus,
|
||||
.app-select:focus {
|
||||
background: var(--surface-elevated);
|
||||
@ -192,6 +209,12 @@ textarea {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.app-color-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.app-input[type="date"],
|
||||
.app-input[type="datetime-local"],
|
||||
.app-input[type="time"] {
|
||||
|
||||
@ -610,7 +610,7 @@ export default function MessagesPage() {
|
||||
value={newChatName}
|
||||
onChange={(e) => setNewChatName(e.target.value)}
|
||||
placeholder="Введите название чата..."
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -622,7 +622,7 @@ export default function MessagesPage() {
|
||||
onChange={(e) => setNewChatDescription(e.target.value)}
|
||||
placeholder="Краткое описание чата..."
|
||||
rows={3}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,12 +2,11 @@
|
||||
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Bell, Check, CheckCheck, Filter, Search, Trash2, Settings } from 'lucide-react';
|
||||
import { Bell, CheckCheck, Filter, Search, Trash2 } from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
import { getVerbCategory, getVerbName } from '@/lib/verbTranslations';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
// Локальное состояние для ВСЕХ уведомлений (не только непрочитанных)
|
||||
@ -165,20 +164,15 @@ export default function NotificationsPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-4 py-6">
|
||||
<section className="mx-auto max-w-5xl app-surface rounded-2xl p-4 sm:p-5">
|
||||
{/* Заголовок */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">Уведомления</h1>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
{unreadCount > 0 ? `${unreadCount} непрочитанных` : 'Все прочитано'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Уведомления</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
Прочитать все
|
||||
@ -192,19 +186,12 @@ export default function NotificationsPage() {
|
||||
console.log(`Удалено ${count} прочитанных уведомлений`);
|
||||
}
|
||||
}}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
className="app-feedback-danger inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Удалить прочитанные
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/notifications/settings"
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Настройки
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -217,7 +204,7 @@ export default function NotificationsPage() {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по уведомлениям..."
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm focus:bg-[var(--surface-elevated)] focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@ -279,6 +266,30 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: 'all', label: 'Все', count: notifications.length },
|
||||
{ key: 'unread', label: 'Непрочитанные', count: unreadCount },
|
||||
{ key: 'read', label: 'Прочитанные', count: readCount },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setFilterRead(item.key as 'all' | 'unread' | 'read')}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
filterRead === item.key ? 'app-pill-active' : 'app-pill'
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
filterRead === item.key ? 'app-pill-count-active' : 'app-pill-count'
|
||||
}`}>
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Бейджи категорий с счетчиками */}
|
||||
{categoryCounts.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
@ -356,17 +367,17 @@ export default function NotificationsPage() {
|
||||
{filteredNotifications.map((notification: any) => (
|
||||
<article
|
||||
key={notification.id}
|
||||
className={`group rounded-xl border transition ${
|
||||
className={`group overflow-hidden rounded-xl border transition ${
|
||||
notification.is_read
|
||||
? 'app-surface'
|
||||
: 'app-selected'
|
||||
? 'app-surface hover:border-[var(--border-strong)]'
|
||||
: 'app-unread-surface border-[color:color-mix(in_srgb,var(--accent-primary)_20%,var(--border-subtle))] hover:border-[color:var(--accent-primary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* Иконка */}
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xl ${
|
||||
notification.is_read ? 'bg-[var(--surface-secondary)]' : 'bg-[var(--accent-soft)]'
|
||||
notification.is_read ? 'bg-[var(--surface-secondary)]' : 'bg-[color:color-mix(in_srgb,var(--accent-primary)_14%,var(--surface-primary))]'
|
||||
}`}
|
||||
style={
|
||||
notification.color
|
||||
@ -435,7 +446,7 @@ export default function NotificationsPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,6 +149,7 @@ export default function ProcurementPage() {
|
||||
urgencyFilter,
|
||||
userLink,
|
||||
scope,
|
||||
scopeCounts,
|
||||
} = useProcurementPage(user);
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
@ -158,34 +159,34 @@ export default function ProcurementPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
{loading ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<div className="app-surface rounded-2xl p-8 text-center">
|
||||
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
|
||||
<p className="text-sm text-gray-500">Загрузка заявок на закупку...</p>
|
||||
<p className="app-text-muted text-sm">Загрузка заявок на закупку...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-2xl bg-red-50 p-6 text-center">
|
||||
<div className="app-feedback-danger rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
{/* ── header ── */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Закупки</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setActiveSection("requests")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "requests" ? "bg-sky-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}`}>
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>
|
||||
{/* <div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setActiveSection("requests")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "requests" ? "app-pill-active" : "app-pill"}`}>
|
||||
Заявки
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveSection("stats")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "stats" ? "bg-sky-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}`}>
|
||||
<button type="button" onClick={() => setActiveSection("stats")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "stats" ? "app-pill-active" : "app-pill"}`}>
|
||||
Статистика
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveSection("suppliers")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "suppliers" ? "bg-sky-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}`}>
|
||||
<button type="button" onClick={() => setActiveSection("suppliers")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "suppliers" ? "app-pill-active" : "app-pill"}`}>
|
||||
Поставщики
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
{activeSection === "requests" && (
|
||||
<button type="button" onClick={openCreate} className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-600">
|
||||
<button type="button" onClick={openCreate} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<Plus size={14} /> Создать заявку
|
||||
</button>
|
||||
)}
|
||||
@ -212,14 +213,14 @@ export default function ProcurementPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") loadPage1(); }}
|
||||
placeholder="Поиск по заявкам..."
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Фильтры"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${filtersOpen ? "border-sky-400 bg-sky-50 text-sky-600" : "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100"}`}
|
||||
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
{activeFilterCount > 0 && (
|
||||
@ -231,7 +232,7 @@ export default function ProcurementPage() {
|
||||
<select
|
||||
value={ordering}
|
||||
onChange={(e) => setOrdering(e.target.value)}
|
||||
className="w-full appearance-none rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-8 text-xs font-medium text-gray-700 transition hover:bg-gray-100 focus:border-sky-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
|
||||
aria-label="Сортировка списка закупок"
|
||||
>
|
||||
{orderingOptions.map((option) => (
|
||||
@ -251,35 +252,40 @@ export default function ProcurementPage() {
|
||||
onClick={() => setScope(tab.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
scope === tab.value
|
||||
? "bg-sky-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
? "app-pill-active"
|
||||
: "app-pill"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
<span>{tab.label}</span>
|
||||
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
scope === tab.value ? "app-pill-count-active" : "app-pill-count"
|
||||
}`}>
|
||||
{scopeCounts[tab.value] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── filters panel ── */}
|
||||
{filtersOpen && (
|
||||
<div className="mb-3 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все статусы</option>
|
||||
{Object.entries(statusMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все уровни срочности</option>
|
||||
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все отделы</option>
|
||||
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
<select value={periodFilter} onChange={(e) => setPeriodFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800">
|
||||
<select value={periodFilter} onChange={(e) => setPeriodFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
{activeFilterCount > 0 && (
|
||||
<button type="button" onClick={() => { setStatusFilter(""); setUrgencyFilter(""); setDepartmentFilter(""); setPeriodFilter(""); }} className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition">
|
||||
<button type="button" onClick={() => { setStatusFilter(""); setUrgencyFilter(""); setDepartmentFilter(""); setPeriodFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
Очистить фильтры
|
||||
</button>
|
||||
)}
|
||||
@ -526,8 +532,8 @@ export default function ProcurementPage() {
|
||||
{/* ══════════ Create / Edit modal ══════════ */}
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={closeModal} className="rounded-lg bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600 disabled:opacity-60">
|
||||
<button type="button" onClick={closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
|
||||
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
@ -538,24 +544,24 @@ export default function ProcurementPage() {
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Название */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Название заявки *</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Название заявки *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Закупка офисной техники..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Описание и обоснование *</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Описание и обоснование *</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Обоснуйте необходимость закупки..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -569,11 +575,11 @@ export default function ProcurementPage() {
|
||||
onSelect={(id) => setForm((f) => ({ ...f, department: id }))}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">Срочность</label>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Срочность</label>
|
||||
<select
|
||||
value={form.urgency}
|
||||
onChange={(e) => setForm((f) => ({ ...f, urgency: e.target.value as UrgencyLevel }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="low">Низкая</option>
|
||||
<option value="medium">Средняя</option>
|
||||
@ -586,8 +592,8 @@ export default function ProcurementPage() {
|
||||
{/* ── Items ── */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-gray-500">Позиции *</label>
|
||||
<button type="button" onClick={addItemRow} className="inline-flex items-center gap-1 text-xs font-medium text-sky-600 hover:text-sky-700">
|
||||
<label className="app-text-muted text-xs font-medium">Позиции *</label>
|
||||
<button type="button" onClick={addItemRow} className="app-link-accent inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Plus size={13} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
@ -605,13 +611,13 @@ export default function ProcurementPage() {
|
||||
value={it.name}
|
||||
onChange={(e) => updateItemRow(idx, { name: e.target.value })}
|
||||
placeholder="Название позиции *"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={it.description}
|
||||
onChange={(e) => updateItemRow(idx, { description: e.target.value })}
|
||||
placeholder="Описание (необязательно)"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<input
|
||||
@ -620,13 +626,13 @@ export default function ProcurementPage() {
|
||||
onChange={(e) => updateItemRow(idx, { quantity: e.target.value })}
|
||||
placeholder="Кол-во"
|
||||
min={1}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={it.unit}
|
||||
onChange={(e) => updateItemRow(idx, { unit: e.target.value })}
|
||||
placeholder="Ед."
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -634,7 +640,7 @@ export default function ProcurementPage() {
|
||||
value={it.estimated_unit_price}
|
||||
onChange={(e) => updateItemRow(idx, { estimated_unit_price: e.target.value })}
|
||||
placeholder="Цена/ед."
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
{it.quantity && it.estimated_unit_price ? money(Number(it.quantity) * Number(it.estimated_unit_price)) : "—"}
|
||||
@ -644,7 +650,7 @@ export default function ProcurementPage() {
|
||||
value={it.supplier_info}
|
||||
onChange={(e) => updateItemRow(idx, { supplier_info: e.target.value })}
|
||||
placeholder="Информация о поставщике (необязательно)"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none"
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -181,7 +181,7 @@ function RequestsPageContent() {
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input value={h.search} onChange={(e) => h.setSearch(e.target.value)} placeholder="Поиск по заявлениям" className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm transition focus:bg-[var(--surface-elevated)]" />
|
||||
<input value={h.search} onChange={(e) => h.setSearch(e.target.value)} placeholder="Поиск по заявлениям" className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" />
|
||||
</div>
|
||||
<button type="button" title="Фильтры" onClick={() => h.setFiltersOpen((v) => !v)} className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${h.filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}>
|
||||
<Filter size={16} />
|
||||
@ -191,7 +191,7 @@ function RequestsPageContent() {
|
||||
</button>
|
||||
<div className="relative w-[148px] shrink-0">
|
||||
<ArrowUpDown size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<select value={h.ordering} onChange={(e) => h.setOrdering(e.target.value)} className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium transition hover:bg-[var(--surface-tertiary)]" aria-label="Сортировка">
|
||||
<select value={h.ordering} onChange={(e) => h.setOrdering(e.target.value)} className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium" aria-label="Сортировка">
|
||||
{orderingOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={14} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
|
||||
@ -12,6 +12,7 @@ import { CalendarModal } from "@/components/CalendarModal";
|
||||
import { EventModal } from "@/components/EventModal";
|
||||
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
|
||||
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
|
||||
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import "../app/calendar/calendar.css";
|
||||
|
||||
@ -213,7 +214,7 @@ export function BigCalendar() {
|
||||
end: new Date(occ.end),
|
||||
allDay: false, // Явно указываем что событие с временем
|
||||
calendar: occ.calendar, // Используем calendar из occurrence (важно для "Все события")
|
||||
color_event: occ.color_event || '#3498db',
|
||||
color_event: resolveEventColor(occ.color_event),
|
||||
event_id: occ.event_id,
|
||||
rule: occ.rule,
|
||||
isOccurrence: true, // Помечаем как occurrence для корректного отображения
|
||||
@ -290,7 +291,7 @@ export function BigCalendar() {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
calendar: selectedCalendarId,
|
||||
color_event: "#3498db",
|
||||
color_event: DEFAULT_EVENT_COLOR,
|
||||
});
|
||||
|
||||
// Закрываем модал просмотра дня и открываем модал создания
|
||||
@ -353,7 +354,7 @@ export function BigCalendar() {
|
||||
|
||||
// Стилизация событий по цвету
|
||||
const eventStyleGetter = (event: CalendarEvent) => {
|
||||
const backgroundColor = event.color_event || "#3498db";
|
||||
const backgroundColor = resolveEventColor(event.color_event);
|
||||
return {
|
||||
style: {
|
||||
backgroundColor,
|
||||
|
||||
@ -4,6 +4,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, UserPlus, Trash2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import { Modal } from '@/components/ui';
|
||||
import { loadAllPages } from '@/lib/shared';
|
||||
import { resolveMediaUrl } from '@/lib/url';
|
||||
|
||||
interface Participant {
|
||||
id: number;
|
||||
@ -12,6 +14,8 @@ interface Participant {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
avatar?: string | null;
|
||||
};
|
||||
distinction: string;
|
||||
}
|
||||
@ -22,6 +26,7 @@ interface Employee {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
avatar?: string | null;
|
||||
departments?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@ -78,8 +83,8 @@ export default function CalendarParticipantsModal({
|
||||
|
||||
const loadEmployees = async () => {
|
||||
try {
|
||||
const data = await api.getEmployees({ limit: 200 });
|
||||
setEmployees(data.results || []);
|
||||
const allEmployees = await loadAllPages<Employee>((params) => api.getEmployees(params));
|
||||
setEmployees(allEmployees);
|
||||
} catch (error) {
|
||||
console.error('Failed to load employees:', error);
|
||||
}
|
||||
@ -277,12 +282,27 @@ export default function CalendarParticipantsModal({
|
||||
onChange={() => toggleUserSelection(emp.id)}
|
||||
className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--foreground)]">
|
||||
{emp.first_name} {emp.last_name}
|
||||
</span>
|
||||
{emp.email && (
|
||||
<span className="app-text-muted text-xs">({emp.email})</span>
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={resolveMediaUrl(emp.avatar)}
|
||||
alt={`${emp.first_name} ${emp.last_name}`.trim() || 'Сотрудник'}
|
||||
className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="text-xs font-medium">
|
||||
{emp.first_name?.[0] || emp.last_name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-[var(--foreground)]">
|
||||
{emp.first_name} {emp.last_name}
|
||||
</div>
|
||||
{emp.email && (
|
||||
<div className="truncate app-text-muted text-xs">{emp.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@ -339,16 +359,24 @@ export default function CalendarParticipantsModal({
|
||||
className="app-surface-elevated flex items-center justify-between rounded-lg px-4 py-3 transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<span className="text-sm font-medium">
|
||||
{firstName[0]}{lastName[0]}
|
||||
</span>
|
||||
</div>
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={resolveMediaUrl(user.avatar)}
|
||||
alt={`${firstName} ${lastName}`.trim() || 'Сотрудник'}
|
||||
className="app-avatar-frame h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<span className="text-sm font-medium">
|
||||
{firstName[0]}{lastName[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--foreground)]">
|
||||
{firstName} {lastName}
|
||||
</div>
|
||||
<div className="app-text-muted text-xs">@{username}</div>
|
||||
<div className="app-text-muted text-xs">{user.email || `@${username}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { Check, X, Trash2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { EVENT_COLOR_OPTIONS, resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import { loadAllPages } from "@/lib/shared";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
|
||||
// Нормализация byweekday в массив чисел
|
||||
function normalizeByweekday(byweekday: any): number[] {
|
||||
@ -43,6 +46,7 @@ export function EventModal({
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(false);
|
||||
const [addingParticipants, setAddingParticipants] = useState(false);
|
||||
const [pendingParticipantIds, setPendingParticipantIds] = useState<number[]>([]);
|
||||
const customColorInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Ref для отслеживания, был ли уже выполнен авто-выбор дня (чтобы не перезаписывать выбор пользователя)
|
||||
const autoWeekdaySetRef = useRef(false);
|
||||
@ -95,6 +99,7 @@ export function EventModal({
|
||||
|
||||
setEditingEvent({
|
||||
...event,
|
||||
color_event: resolveEventColor(event.color_event),
|
||||
isRecurring: event.isRecurring ?? false,
|
||||
repeatMode,
|
||||
});
|
||||
@ -106,6 +111,10 @@ export function EventModal({
|
||||
setEventParticipants([]);
|
||||
}
|
||||
|
||||
if (showParticipants) {
|
||||
loadAvailableEmployees();
|
||||
}
|
||||
|
||||
// Загружаем правило для повторяющегося события
|
||||
if (event.id && event.rule) {
|
||||
loadRule(event.rule);
|
||||
@ -219,8 +228,8 @@ export function EventModal({
|
||||
const loadAvailableEmployees = async () => {
|
||||
setLoadingEmployees(true);
|
||||
try {
|
||||
const data = await apiClient.getEmployees({ limit: 200 });
|
||||
setAvailableEmployees(data.results || []);
|
||||
const allEmployees = await loadAllPages<any>((params) => apiClient.getEmployees(params));
|
||||
setAvailableEmployees(allEmployees);
|
||||
} catch (error) {
|
||||
console.error('Failed to load employees:', error);
|
||||
} finally {
|
||||
@ -328,7 +337,7 @@ export function EventModal({
|
||||
? editingEvent.end
|
||||
: editingEvent.end?.toISOString?.() || new Date(editingEvent.end).toISOString(),
|
||||
calendar: editingEvent.calendar,
|
||||
color_event: editingEvent.color_event || '#3498db',
|
||||
color_event: resolveEventColor(editingEvent.color_event),
|
||||
rule: ruleId,
|
||||
};
|
||||
|
||||
@ -385,7 +394,7 @@ export function EventModal({
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editingEvent.title?.trim() || saving}
|
||||
className="app-action-primary flex-1"
|
||||
className="app-action-primary flex-1 rounded-xl px-4 py-3 text-sm font-semibold shadow-sm"
|
||||
>
|
||||
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
|
||||
</button>
|
||||
@ -394,7 +403,7 @@ export function EventModal({
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="app-action-danger px-4 py-2.5"
|
||||
className="app-action-danger rounded-xl px-4 py-3"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@ -403,6 +412,17 @@ export function EventModal({
|
||||
</div>
|
||||
);
|
||||
|
||||
const activeColor = resolveEventColor(editingEvent?.color_event);
|
||||
const isPresetColor = EVENT_COLOR_OPTIONS.some((option) => option.value === activeColor);
|
||||
const employeeMap = new Map(availableEmployees.map((employee) => [employee.id, employee]));
|
||||
const filteredEmployees = availableEmployees.filter((emp) => {
|
||||
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ""}`.toLowerCase();
|
||||
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
|
||||
const notParticipant = !eventParticipants.find((participant) => participant.object_id === emp.id);
|
||||
const notPending = !pendingParticipantIds.includes(emp.id);
|
||||
return matchesSearch && notParticipant && notPending;
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@ -412,14 +432,14 @@ export function EventModal({
|
||||
closeOnEsc={!saving}
|
||||
footer={footerContent}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="app-field-label">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingEvent.title || ''}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, title: e.target.value })}
|
||||
className="app-input w-full"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
placeholder="Название события"
|
||||
autoFocus
|
||||
/>
|
||||
@ -430,7 +450,7 @@ export function EventModal({
|
||||
<textarea
|
||||
value={editingEvent.description || ''}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, description: e.target.value })}
|
||||
className="app-input min-h-28 w-full resize-y"
|
||||
className="app-input min-h-28 w-full rounded-lg px-3 py-2.5 text-sm leading-6 resize-y"
|
||||
placeholder="Описание (необязательно)"
|
||||
rows={3}
|
||||
/>
|
||||
@ -450,7 +470,7 @@ export function EventModal({
|
||||
...editingEvent,
|
||||
start: new Date(e.target.value).toISOString()
|
||||
})}
|
||||
className="app-input w-full"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -467,7 +487,7 @@ export function EventModal({
|
||||
...editingEvent,
|
||||
end: new Date(e.target.value).toISOString()
|
||||
})}
|
||||
className="app-input w-full"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -475,19 +495,55 @@ export function EventModal({
|
||||
|
||||
<div>
|
||||
<label className="app-field-label">Цвет</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{EVENT_COLOR_OPTIONS.map((option) => {
|
||||
const isActive = activeColor === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setEditingEvent({ ...editingEvent, color_event: option.value })}
|
||||
className={`h-9 w-9 rounded-full border-2 transition ${
|
||||
isActive
|
||||
? "scale-105 border-[color:var(--accent-primary)] shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-primary)_22%,transparent)]"
|
||||
: "border-[color:var(--border-primary)] hover:border-[color:var(--accent-primary)]"
|
||||
}`}
|
||||
style={{ backgroundColor: option.value }}
|
||||
title={option.label}
|
||||
aria-label={option.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => customColorInputRef.current?.click()}
|
||||
className={`relative h-9 w-9 rounded-full border-2 transition ${
|
||||
isPresetColor
|
||||
? "border-[color:var(--border-primary)] hover:border-[color:var(--accent-primary)]"
|
||||
: "scale-105 border-[color:var(--accent-primary)] shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-primary)_22%,transparent)]"
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 180deg, #ef4444, #f59e0b, #22c55e, #1296ea, #8b5cf6, #ef4444)",
|
||||
}}
|
||||
title="Выбрать другой цвет"
|
||||
aria-label="Выбрать другой цвет"
|
||||
>
|
||||
<span className="absolute inset-[7px] rounded-full border border-white/20 bg-[var(--surface-primary)]" />
|
||||
</button>
|
||||
<input
|
||||
ref={customColorInputRef}
|
||||
type="color"
|
||||
value={editingEvent.color_event || '#3498db'}
|
||||
value={activeColor}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, color_event: e.target.value })}
|
||||
className="app-color-input"
|
||||
className="sr-only"
|
||||
aria-label="Выбор произвольного цвета"
|
||||
/>
|
||||
<span className="app-text-muted text-xs">{editingEvent.color_event || '#3498db'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Повторяющееся событие */}
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<label className="app-choice-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -506,13 +562,21 @@ export function EventModal({
|
||||
</label>
|
||||
|
||||
{editingEvent.isRecurring && (
|
||||
<div className="app-surface space-y-4 rounded-xl p-4">
|
||||
<div className="app-surface-muted space-y-4 rounded-2xl p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Параметры повторения</p>
|
||||
<p className="app-text-muted text-xs">
|
||||
Настрой периодичность и правило завершения серии.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="app-surface space-y-4 rounded-xl p-4">
|
||||
<div>
|
||||
<label className="app-field-label">Частота повторения</label>
|
||||
<select
|
||||
value={editingEvent.frequency || 'WEEKLY'}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, frequency: e.target.value })}
|
||||
className="app-select w-full"
|
||||
className="app-select w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
>
|
||||
<option value="DAILY">Ежедневно</option>
|
||||
<option value="WEEKLY">Еженедельно</option>
|
||||
@ -531,7 +595,7 @@ export function EventModal({
|
||||
max="365"
|
||||
value={editingEvent.interval || 1}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, interval: parseInt(e.target.value) })}
|
||||
className="app-input w-20"
|
||||
className="app-input w-24 rounded-lg px-3 py-2.5 text-center text-sm"
|
||||
/>
|
||||
<span className="app-text-muted text-sm">
|
||||
{editingEvent.frequency === 'DAILY' && 'дней'}
|
||||
@ -568,7 +632,7 @@ export function EventModal({
|
||||
: [...current, day.value].sort();
|
||||
setEditingEvent({ ...editingEvent, byweekday: updated });
|
||||
}}
|
||||
className={`px-2 py-1.5 text-xs ${
|
||||
className={`rounded-lg px-2 py-1.5 text-xs font-medium transition ${
|
||||
isSelected
|
||||
? 'app-pill-active'
|
||||
: 'app-pill'
|
||||
@ -625,7 +689,7 @@ export function EventModal({
|
||||
max="999"
|
||||
value={editingEvent.count || 10}
|
||||
onChange={(e) => setEditingEvent({ ...editingEvent, count: parseInt(e.target.value) })}
|
||||
className="app-input w-full"
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
placeholder="Количество повторений"
|
||||
/>
|
||||
) : (
|
||||
@ -634,6 +698,7 @@ export function EventModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -642,7 +707,7 @@ export function EventModal({
|
||||
{showParticipants && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
<label className="app-field-label mb-0">
|
||||
Участники события
|
||||
{!eventParticipantsLoading && eventParticipants.length > 0 && (
|
||||
<span className="app-text-muted ml-1">({eventParticipants.length})</span>
|
||||
@ -656,7 +721,7 @@ export function EventModal({
|
||||
loadAvailableEmployees();
|
||||
}
|
||||
}}
|
||||
className="app-link-accent text-xs font-medium"
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
{showAddParticipants ? 'Отмена' : '+ Добавить участников'}
|
||||
</button>
|
||||
@ -674,11 +739,19 @@ export function EventModal({
|
||||
className="app-selected flex items-center justify-between rounded-xl px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="app-avatar-fallback h-6 w-6">
|
||||
<span className="text-xs font-medium">
|
||||
{emp.first_name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={resolveMediaUrl(emp.avatar)}
|
||||
alt={`${emp.first_name || ""} ${emp.last_name || ""}`.trim() || "Сотрудник"}
|
||||
className="app-avatar-frame h-6 w-6 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-avatar-fallback flex h-6 w-6 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="text-xs font-medium">
|
||||
{emp.first_name?.[0] || emp.last_name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-[var(--foreground)]">{emp.first_name} {emp.last_name}</div>
|
||||
<div className="app-text-muted">attendee</div>
|
||||
@ -702,32 +775,51 @@ export function EventModal({
|
||||
<p className="app-text-muted py-2 text-xs">Загрузка...</p>
|
||||
) : editingEvent.id && eventParticipants.length > 0 ? (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{eventParticipants.map((participant: any) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="app-surface-muted flex items-center justify-between rounded-xl px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="app-avatar-fallback h-6 w-6">
|
||||
<span className="text-xs font-medium">
|
||||
{participant.user_name?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-[var(--foreground)]">{participant.user_name}</div>
|
||||
<div className="app-text-muted">{participant.distinction || 'attendee'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveParticipant(participant.id)}
|
||||
className="app-action-danger p-1.5"
|
||||
title="Удалить участника"
|
||||
{eventParticipants.map((participant: any) => {
|
||||
const employee = employeeMap.get(participant.object_id);
|
||||
const participantName =
|
||||
participant.user_name ||
|
||||
[employee?.first_name, employee?.last_name].filter(Boolean).join(" ") ||
|
||||
"Сотрудник";
|
||||
const avatarUrl = participant.user_avatar || employee?.avatar;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="app-surface-muted flex items-center justify-between rounded-xl px-3 py-2"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={resolveMediaUrl(avatarUrl)}
|
||||
alt={participantName}
|
||||
className="app-avatar-frame h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="text-xs font-medium">
|
||||
{participantName?.[0] || '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 text-xs">
|
||||
<div className="truncate font-medium text-[var(--foreground)]">{participantName}</div>
|
||||
<div className="truncate app-text-muted">
|
||||
{employee?.email || participant.distinction || 'attendee'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveParticipant(participant.id)}
|
||||
className="app-action-danger p-1.5"
|
||||
title="Удалить участника"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : editingEvent.id ? (
|
||||
<p className="app-text-muted py-2 text-xs">Нет участников</p>
|
||||
@ -737,53 +829,92 @@ export function EventModal({
|
||||
|
||||
{/* Форма добавления участников */}
|
||||
{showAddParticipants && (
|
||||
<div className="app-surface-muted space-y-3 rounded-xl p-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск сотрудников..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="app-input w-full"
|
||||
/>
|
||||
<div className="app-surface-muted space-y-3 rounded-2xl p-3">
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск сотрудников..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-1 text-xs">
|
||||
<span className="app-text-muted">
|
||||
{loadingEmployees ? "Загружаю сотрудников..." : `Найдено: ${filteredEmployees.length}`}
|
||||
</span>
|
||||
<span className="app-accent-text font-medium">
|
||||
Выбрано: {selectedEmployeeIds.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingEmployees ? (
|
||||
<p className="app-text-muted py-2 text-xs">Загрузка сотрудников...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{availableEmployees
|
||||
.filter(emp => {
|
||||
const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase();
|
||||
const matchesSearch = fullName.includes(searchQuery.toLowerCase());
|
||||
const notParticipant = !eventParticipants.find(p => p.object_id === emp.id);
|
||||
const notPending = !pendingParticipantIds.includes(emp.id);
|
||||
return matchesSearch && notParticipant && notPending;
|
||||
})
|
||||
.map(emp => (
|
||||
<label
|
||||
key={emp.id}
|
||||
className="app-surface flex cursor-pointer items-center gap-2 rounded-xl p-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployeeIds.includes(emp.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedEmployeeIds([...selectedEmployeeIds, emp.id]);
|
||||
<div className="app-surface max-h-72 space-y-2 overflow-y-auto rounded-2xl p-2">
|
||||
{filteredEmployees.length > 0 ? (
|
||||
filteredEmployees.map((emp) => {
|
||||
const isSelected = selectedEmployeeIds.includes(emp.id);
|
||||
return (
|
||||
<button
|
||||
key={emp.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedEmployeeIds(selectedEmployeeIds.filter((id) => id !== emp.id));
|
||||
} else {
|
||||
setSelectedEmployeeIds(selectedEmployeeIds.filter(id => id !== emp.id));
|
||||
setSelectedEmployeeIds([...selectedEmployeeIds, emp.id]);
|
||||
}
|
||||
}}
|
||||
className="app-checkbox"
|
||||
/>
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-[var(--foreground)]">
|
||||
{emp.first_name} {emp.last_name}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
|
||||
isSelected
|
||||
? "app-selected"
|
||||
: "app-surface-muted hover:bg-[var(--surface-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={resolveMediaUrl(emp.avatar)}
|
||||
alt={`${emp.first_name || ""} ${emp.last_name || ""}`.trim() || "Сотрудник"}
|
||||
className="app-avatar-frame h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="app-avatar-fallback flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
|
||||
<span className="text-sm font-semibold">
|
||||
{emp.first_name?.[0] || emp.last_name?.[0] || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{emp.first_name} {emp.last_name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-[var(--muted-foreground)]">
|
||||
{emp.email || "Почта не указана"}
|
||||
</div>
|
||||
</div>
|
||||
{emp.email && <div className="app-text-muted">{emp.email}</div>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition ${
|
||||
isSelected
|
||||
? "border-[color:var(--accent-primary)] bg-[var(--accent-primary)] text-white"
|
||||
: "border-[color:var(--border-strong)] text-transparent"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Check size={14} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-3 py-6 text-center">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">Ничего не найдено</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Попробуй изменить запрос или очистить фильтр поиска.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEmployeeIds.length > 0 && (
|
||||
@ -791,7 +922,7 @@ export function EventModal({
|
||||
type="button"
|
||||
onClick={handleAddParticipants}
|
||||
disabled={addingParticipants}
|
||||
className="app-action-primary w-full"
|
||||
className="app-action-primary w-full rounded-xl px-4 py-3 text-sm font-semibold"
|
||||
>
|
||||
{addingParticipants ? 'Добавление...' : `Добавить выбранных (${selectedEmployeeIds.length})`}
|
||||
</button>
|
||||
|
||||
@ -105,7 +105,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
<div className="app-menu absolute right-0 top-12 z-[60] flex max-h-[600px] w-80 flex-col rounded-xl sm:w-96 animate-fade-in">
|
||||
{/* Заголовок */}
|
||||
<div className="app-divider flex items-center justify-between border-b p-4">
|
||||
<h3 className="text-base font-semibold text-[var(--foreground)]">Уведомления</h3>
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Уведомления</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
@ -149,9 +149,9 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
Загрузка...
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="app-text-muted p-8 text-center">
|
||||
<Bell className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Нет уведомлений</p>
|
||||
<div className="app-text-muted p-10 text-center">
|
||||
<Bell className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm font-medium">Нет уведомлений</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
@ -208,7 +208,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
</div>
|
||||
|
||||
{/* Футер */}
|
||||
<div className="app-divider app-surface-muted border-t p-3 text-center">
|
||||
<div className="app-divider border-t p-3 text-center">
|
||||
<a
|
||||
href="/notifications"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@ -240,7 +240,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<div className="app-menu flex max-h-[60vh] flex-col overflow-hidden rounded-xl">
|
||||
<div className="app-divider flex items-center justify-between border-b p-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">Уведомления</h3>
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Уведомления</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
@ -276,9 +276,9 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
{loading ? (
|
||||
<div className="app-text-muted p-8 text-center text-sm">Загрузка...</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="app-text-muted p-8 text-center">
|
||||
<Bell className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Нет уведомлений</p>
|
||||
<div className="app-text-muted p-10 text-center">
|
||||
<Bell className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm font-medium">Нет уведомлений</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
@ -314,7 +314,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-divider app-surface-muted border-t p-2 text-center">
|
||||
<div className="app-divider border-t p-2 text-center">
|
||||
<a
|
||||
href="/notifications"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { Plus, Clock, Calendar } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
|
||||
@ -91,7 +92,7 @@ export function ViewDayEventsModal({
|
||||
{/* Color Indicator */}
|
||||
<div
|
||||
className="mt-1 h-8 w-1 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.color_event || "#3498db" }}
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event) }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect } from "react";
|
||||
@ -134,7 +135,7 @@ export function ViewEventDetailsModal({
|
||||
<div className="app-text-muted flex items-center gap-1.5 text-xs">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.color_event || "#3498db" }}
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event) }}
|
||||
/>
|
||||
<span className="truncate">Календарь #{event.calendar}</span>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
|
||||
import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService";
|
||||
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
|
||||
// Типы
|
||||
interface CalendarCardProps {
|
||||
@ -92,7 +93,7 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
calendar: selectedCalendarId,
|
||||
color_event: "#3498db",
|
||||
color_event: DEFAULT_EVENT_COLOR,
|
||||
};
|
||||
onOpenEventModal(newEvent, date);
|
||||
}, [selectedCalendarId, onOpenEventModal]);
|
||||
@ -490,7 +491,7 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.color_event || event.color || "#3498db" }}
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event || event.color) }}
|
||||
/>
|
||||
<p className="truncate text-xs font-medium text-[var(--foreground)]">{event.title}</p>
|
||||
{(event.is_recurring || event.rule) && (
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { DEFAULT_EVENT_COLOR } from "@/lib/calendar-event-colors";
|
||||
import type { CalendarEvent } from "@/services/calendarService";
|
||||
|
||||
export interface CalendarModalsState {
|
||||
@ -84,7 +85,7 @@ export function useCalendarModals() {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
calendar: currentSelectedCalendarId,
|
||||
color_event: "#3498db",
|
||||
color_event: DEFAULT_EVENT_COLOR,
|
||||
} as CalendarEvent);
|
||||
|
||||
setShowDayEventsModal(false);
|
||||
|
||||
@ -51,6 +51,7 @@ type ProcurementSection = "requests" | "stats" | "suppliers";
|
||||
type PaginatedLike<T> = {
|
||||
results?: T[];
|
||||
next?: string | null;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const getPaginatedResults = <T,>(response: unknown): T[] => {
|
||||
@ -76,6 +77,17 @@ const getPaginatedNext = (response: unknown): number | null => {
|
||||
return extractNextPage((response as PaginatedLike<unknown>).next ?? null);
|
||||
};
|
||||
|
||||
const getPaginatedCount = (response: unknown): number => {
|
||||
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const count = (response as PaginatedLike<unknown>).count;
|
||||
return typeof count === "number" && Number.isFinite(count)
|
||||
? count
|
||||
: getPaginatedResults(response).length;
|
||||
};
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
try {
|
||||
@ -110,6 +122,14 @@ export function useProcurementPage(user: User | null) {
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
const [scopeCounts, setScopeCounts] = useState<Record<ScopeTab, number>>({
|
||||
all: 0,
|
||||
mine: 0,
|
||||
department: 0,
|
||||
pending_approvals: 0,
|
||||
my_work: 0,
|
||||
available: 0,
|
||||
});
|
||||
|
||||
const [scope, setScope] = useState<ScopeTab>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@ -181,6 +201,23 @@ export function useProcurementPage(user: User | null) {
|
||||
[departmentFilter, periodFilter, scope, searchQuery, statusFilter, urgencyFilter],
|
||||
);
|
||||
|
||||
const buildScopeCountParams = useCallback(
|
||||
(targetScope: ScopeTab): Record<string, string | number> => {
|
||||
const params: Record<string, string | number> = { page: 1 };
|
||||
if (targetScope === "mine") params.scope = "mine";
|
||||
else if (targetScope === "department") params.scope = "department";
|
||||
else if (targetScope === "my_work") params.scope = "my_work";
|
||||
else if (targetScope === "available") params.scope = "available";
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (searchQuery.trim()) params.search = searchQuery.trim();
|
||||
return params;
|
||||
},
|
||||
[departmentFilter, periodFilter, searchQuery, statusFilter, urgencyFilter],
|
||||
);
|
||||
|
||||
const loadPage1 = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -204,6 +241,34 @@ export function useProcurementPage(user: User | null) {
|
||||
void loadPage1();
|
||||
}, [loadPage1]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const scopes: ScopeTab[] = ["all", "mine", "department", "pending_approvals", "my_work", "available"];
|
||||
const results = await Promise.all(
|
||||
scopes.map(async (scopeKey) => {
|
||||
const response: unknown = scopeKey === "pending_approvals"
|
||||
? await apiClient.getPendingApprovals(buildScopeCountParams(scopeKey))
|
||||
: await apiClient.getProcurementRequests(buildScopeCountParams(scopeKey));
|
||||
return [scopeKey, getPaginatedCount(response)] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setScopeCounts(Object.fromEntries(results) as Record<ScopeTab, number>);
|
||||
}
|
||||
} catch (countsError) {
|
||||
console.error("Load procurement scope counts error:", countsError);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [buildScopeCountParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@ -520,6 +585,7 @@ export function useProcurementPage(user: User | null) {
|
||||
actionSuccess,
|
||||
busyKey,
|
||||
nextPage,
|
||||
scopeCounts,
|
||||
scope,
|
||||
setScope,
|
||||
searchQuery,
|
||||
@ -572,4 +638,4 @@ export function useProcurementPage(user: User | null) {
|
||||
getRequestAmount,
|
||||
loadPage1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/src/lib/calendar-event-colors.ts
Normal file
16
frontend/src/lib/calendar-event-colors.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export const DEFAULT_EVENT_COLOR = "#1296ea";
|
||||
|
||||
export const EVENT_COLOR_OPTIONS = [
|
||||
{ value: DEFAULT_EVENT_COLOR, label: "Фирменный голубой" },
|
||||
{ value: "#0ea5e9", label: "Небесный" },
|
||||
{ value: "#14b8a6", label: "Бирюзовый" },
|
||||
{ value: "#22c55e", label: "Зеленый" },
|
||||
{ value: "#f59e0b", label: "Янтарный" },
|
||||
{ value: "#f97316", label: "Оранжевый" },
|
||||
{ value: "#ef4444", label: "Красный" },
|
||||
{ value: "#8b5cf6", label: "Фиолетовый" },
|
||||
];
|
||||
|
||||
export function resolveEventColor(color?: string | null) {
|
||||
return color || DEFAULT_EVENT_COLOR;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user