refactor(frontend): align calendar and list UI

This commit is contained in:
lizerk 2026-04-11 20:44:16 +03:00
parent 601f0d58d8
commit db05b4fcdb
18 changed files with 548 additions and 248 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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>
)}

View File

@ -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"] {

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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" />

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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()}

View File

@ -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">

View File

@ -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>

View File

@ -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) && (

View File

@ -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);

View File

@ -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,
};
}
}

View 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;
}