148 lines
7.9 KiB
TypeScript
148 lines
7.9 KiB
TypeScript
|
|
import React, { useState, useMemo } from 'react';
|
|||
|
|
import { DomaApplication, DomaApplicationStatus } from '../../types';
|
|||
|
|
import { Search, Clock, User, HardHat, ExternalLink, RotateCw } from 'lucide-react';
|
|||
|
|
import { ApplicationCardDetail } from './ApplicationCardDetail';
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
applications: DomaApplication[];
|
|||
|
|
onRefresh?: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusConfig = {
|
|||
|
|
new: { label: 'Новая', color: 'bg-red-500', textColor: 'text-red-600', bgColor: 'bg-red-100' },
|
|||
|
|
in_progress: { label: 'В работе', color: 'bg-blue-500', textColor: 'text-blue-600', bgColor: 'bg-blue-100' },
|
|||
|
|
deferred: { label: 'Отложена', color: 'bg-orange-500', textColor: 'text-orange-600', bgColor: 'bg-orange-100' },
|
|||
|
|
done: { label: 'Выполнена', color: 'bg-emerald-500', textColor: 'text-emerald-600', bgColor: 'bg-emerald-100' },
|
|||
|
|
canceled: { label: 'Отменена', color: 'bg-slate-400', textColor: 'text-slate-600', bgColor: 'bg-slate-100' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const ApplicationsRegistry: React.FC<Props> = ({ applications, onRefresh }) => {
|
|||
|
|
const [search, setSearch] = useState('');
|
|||
|
|
const [filter, setFilter] = useState<DomaApplicationStatus | 'all'>('all');
|
|||
|
|
const [openCardId, setOpenCardId] = useState<number | null>(null);
|
|||
|
|
|
|||
|
|
const filtered = useMemo(() => {
|
|||
|
|
return applications.filter(app => {
|
|||
|
|
const matchesSearch = app.address.toLowerCase().includes(search.toLowerCase()) || app.number.toLowerCase().includes(search.toLowerCase());
|
|||
|
|
const matchesFilter = filter === 'all' || app.status === filter;
|
|||
|
|
return matchesSearch && matchesFilter;
|
|||
|
|
}).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|||
|
|
}, [applications, search, filter]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4 animate-fade-in">
|
|||
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|||
|
|
<div className="relative flex-1 flex gap-2">
|
|||
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
placeholder="Поиск по адресу, номеру заявки..."
|
|||
|
|
value={search}
|
|||
|
|
onChange={e => setSearch(e.target.value)}
|
|||
|
|
className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
|
|||
|
|
/>
|
|||
|
|
{onRefresh && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => window.dispatchEvent(new CustomEvent('mkd-applications-changed'))}
|
|||
|
|
className="shrink-0 p-3 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-primary-600 hover:border-primary-300 transition-all"
|
|||
|
|
title="Обновить реестр"
|
|||
|
|
>
|
|||
|
|
<RotateCw className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex bg-slate-200/50 p-1 rounded-2xl shrink-0 overflow-x-auto no-scrollbar">
|
|||
|
|
{['all', 'new', 'in_progress', 'deferred', 'done'].map((f) => (
|
|||
|
|
<button
|
|||
|
|
key={f}
|
|||
|
|
onClick={() => setFilter(f as any)}
|
|||
|
|
className={`flex-shrink-0 min-w-[5rem] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all whitespace-nowrap ${filter === f ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
|||
|
|
>
|
|||
|
|
{f === 'all' ? 'Все' : f === 'new' ? 'Новые' : f === 'in_progress' ? 'В работе' : f === 'deferred' ? 'Отложены' : 'Готово'}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{filtered.map(app => (
|
|||
|
|
<ApplicationCard key={app.id} application={app} onOpenCard={() => setOpenCardId(app.id)} />
|
|||
|
|
))}
|
|||
|
|
{filtered.length === 0 && (
|
|||
|
|
<div className="py-20 text-center text-slate-400 italic">Заявки не найдены</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{openCardId !== null && (
|
|||
|
|
<ApplicationCardDetail
|
|||
|
|
applicationId={openCardId}
|
|||
|
|
onClose={() => setOpenCardId(null)}
|
|||
|
|
onUpdated={onRefresh}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ApplicationCard: React.FC<{ application: DomaApplication; onOpenCard: () => void }> = ({ application, onOpenCard }) => {
|
|||
|
|
const { label, color, textColor, bgColor } = statusConfig[application.status];
|
|||
|
|
|
|||
|
|
const timeSince = (dateStr: string) => {
|
|||
|
|
const date = new Date(dateStr);
|
|||
|
|
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
|||
|
|
if (seconds < 3600) return Math.floor(seconds/60) + " мин. назад";
|
|||
|
|
if (seconds < 86400) return Math.floor(seconds/3600) + " ч. назад";
|
|||
|
|
return Math.floor(seconds/86400) + " д. назад";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleCardClick = (e: React.MouseEvent) => {
|
|||
|
|
const target = e.target as HTMLElement;
|
|||
|
|
if (target.closest('a[href]')) return;
|
|||
|
|
onOpenCard();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onClick={handleCardClick}
|
|||
|
|
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all cursor-pointer group relative overflow-hidden"
|
|||
|
|
>
|
|||
|
|
<div className={`absolute left-0 top-0 bottom-0 w-1.5 ${color}`} />
|
|||
|
|
|
|||
|
|
<div className="pl-3">
|
|||
|
|
<div className="flex justify-between items-start mb-3">
|
|||
|
|
<div>
|
|||
|
|
<span className={`px-2 py-0.5 text-[9px] font-black rounded-full uppercase tracking-tighter ${bgColor} ${textColor}`}>{label}</span>
|
|||
|
|
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase">№ {application.number}</p>
|
|||
|
|
</div>
|
|||
|
|
{application.domaId && (
|
|||
|
|
<a
|
|||
|
|
href={`https://condo.d.doma.ai/ticket/${application.domaId}`}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
onClick={(e) => e.stopPropagation()}
|
|||
|
|
className="flex items-center gap-1 text-slate-400 group-hover:text-primary-600 transition-colors"
|
|||
|
|
>
|
|||
|
|
<ExternalLink className="w-4 h-4" />
|
|||
|
|
<span className="text-[9px] font-bold uppercase">Открыть в Doma.AI</span>
|
|||
|
|
</a>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<h3 className="font-black text-slate-800 text-sm mb-1 group-hover:text-primary-600 transition-colors">{application.address}, кв. {application.apartment}</h3>
|
|||
|
|
<p className="text-xs text-slate-500 leading-relaxed line-clamp-2">{application.description}</p>
|
|||
|
|
|
|||
|
|
<div className="mt-4 pt-4 border-t border-slate-50 flex flex-wrap gap-4 text-[10px] text-slate-400 font-bold uppercase tracking-tight">
|
|||
|
|
<div className="flex items-center gap-1.5"><Clock className="w-3 h-3" /> {timeSince(application.createdAt)}</div>
|
|||
|
|
<div className="flex items-center gap-1.5"><User className="w-3 h-3" /> {application.clientName}</div>
|
|||
|
|
{application.performer && (
|
|||
|
|
<div className="flex items-center gap-1.5 text-primary-600">
|
|||
|
|
<HardHat className="w-3 h-3"/> {application.performer.name}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|