381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
/**
|
||
* Organizations page
|
||
*/
|
||
|
||
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
getOrganizations,
|
||
createOrganization,
|
||
updateOrganization,
|
||
deleteOrganization,
|
||
scanOrganization,
|
||
} from '../api/organizations';
|
||
import { Organization, OrganizationCreate, OrganizationPlatform } from '../types/organization';
|
||
import { Modal, ConfirmModal } from '../components/Modal';
|
||
|
||
export default function Organizations() {
|
||
const queryClient = useQueryClient();
|
||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
|
||
|
||
// Modal states
|
||
const [modalMessage, setModalMessage] = useState('');
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
|
||
const [confirmMessage, setConfirmMessage] = useState('');
|
||
const [showConfirm, setShowConfirm] = useState(false);
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['organizations'],
|
||
queryFn: () => getOrganizations(),
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: createOrganization,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||
setIsFormOpen(false);
|
||
setModalMessage('✅ Организация успешно добавлена');
|
||
setShowModal(true);
|
||
},
|
||
onError: (error: Error) => {
|
||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||
setShowModal(true);
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: number; data: any }) => updateOrganization(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||
setEditingOrg(null);
|
||
setModalMessage('✅ Организация успешно обновлена');
|
||
setShowModal(true);
|
||
},
|
||
onError: (error: Error) => {
|
||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||
setShowModal(true);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: deleteOrganization,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||
setModalMessage('✅ Организация успешно удалена');
|
||
setShowModal(true);
|
||
},
|
||
onError: (error: Error) => {
|
||
setModalMessage(`❌ Ошибка: ${error.message}`);
|
||
setShowModal(true);
|
||
},
|
||
});
|
||
|
||
const scanMutation = useMutation({
|
||
mutationFn: scanOrganization,
|
||
onSuccess: (result) => {
|
||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||
|
||
let message = `✅ Сканирование завершено!\n\n`;
|
||
message += `📦 Репозиториев найдено: ${result.repositories_found}\n`;
|
||
message += `➕ Репозиториев добавлено: ${result.repositories_added}\n`;
|
||
message += `🔀 PR найдено: ${result.pull_requests_found}\n`;
|
||
message += `📝 Задач создано: ${result.tasks_created}`;
|
||
|
||
if (result.errors.length > 0) {
|
||
message += `\n\n⚠️ Ошибки:\n${result.errors.join('\n')}`;
|
||
}
|
||
|
||
setModalMessage(message);
|
||
setShowModal(true);
|
||
},
|
||
onError: (error: Error) => {
|
||
setModalMessage(`❌ Ошибка сканирования: ${error.message}`);
|
||
setShowModal(true);
|
||
},
|
||
});
|
||
|
||
const handleDelete = (org: Organization) => {
|
||
setConfirmMessage(`Вы уверены, что хотите удалить организацию "${org.name}"?`);
|
||
setConfirmAction(() => () => deleteMutation.mutate(org.id));
|
||
setShowConfirm(true);
|
||
};
|
||
|
||
const handleScan = (org: Organization) => {
|
||
setConfirmMessage(`Начать сканирование организации "${org.name}"?\n\nБудут найдены все репозитории и PR.`);
|
||
setConfirmAction(() => () => scanMutation.mutate(org.id));
|
||
setShowConfirm(true);
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-gray-500">Загрузка...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900">Организации</h1>
|
||
<p className="text-gray-600 mt-1">
|
||
Управление организациями и автоматическое сканирование репозиториев
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsFormOpen(true)}
|
||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||
>
|
||
➕ Добавить организацию
|
||
</button>
|
||
</div>
|
||
|
||
{/* Organizations list */}
|
||
<div className="grid gap-4">
|
||
{data?.items.map((org) => (
|
||
<div
|
||
key={org.id}
|
||
className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-6"
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-xl font-semibold text-dark-text-primary">{org.name}</h3>
|
||
<span className={`px-2 py-1 rounded text-xs font-medium border ${
|
||
org.is_active ? 'bg-green-900/30 text-green-400 border-green-700' : 'bg-dark-card text-dark-text-muted border-dark-border'
|
||
}`}>
|
||
{org.is_active ? 'Активна' : 'Неактивна'}
|
||
</span>
|
||
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-900/30 text-blue-400 border border-blue-700">
|
||
{org.platform.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-2 space-y-1 text-sm text-dark-text-secondary">
|
||
<div>🌐 {org.base_url}</div>
|
||
{org.last_scan_at && (
|
||
<div>
|
||
🔍 Последнее сканирование:{' '}
|
||
{new Date(org.last_scan_at).toLocaleString('ru-RU')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 text-xs text-dark-text-muted">
|
||
Webhook: <code className="bg-dark-bg px-2 py-1 rounded border border-dark-border">{org.webhook_url}</code>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => handleScan(org)}
|
||
disabled={scanMutation.isPending}
|
||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||
>
|
||
🔍 Сканировать
|
||
</button>
|
||
<button
|
||
onClick={() => setEditingOrg(org)}
|
||
className="px-3 py-1.5 text-sm bg-dark-hover text-dark-text-primary rounded hover:bg-dark-border transition-colors border border-dark-border"
|
||
>
|
||
✏️ Изменить
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(org)}
|
||
className="px-3 py-1.5 text-sm bg-red-900/30 text-red-400 rounded hover:bg-red-900/50 transition-colors border border-red-700"
|
||
>
|
||
🗑️ Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{data?.items.length === 0 && (
|
||
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
|
||
<p className="text-dark-text-muted">Нет организаций</p>
|
||
<button
|
||
onClick={() => setIsFormOpen(true)}
|
||
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||
>
|
||
➕ Добавить первую организацию
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Form Modal */}
|
||
{isFormOpen && (
|
||
<OrganizationForm
|
||
onSubmit={(data) => createMutation.mutate(data)}
|
||
onCancel={() => setIsFormOpen(false)}
|
||
isSubmitting={createMutation.isPending}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit Form Modal */}
|
||
{editingOrg && (
|
||
<OrganizationForm
|
||
organization={editingOrg}
|
||
onSubmit={(data) => updateMutation.mutate({ id: editingOrg.id, data })}
|
||
onCancel={() => setEditingOrg(null)}
|
||
isSubmitting={updateMutation.isPending}
|
||
/>
|
||
)}
|
||
|
||
{/* Modals */}
|
||
<Modal
|
||
isOpen={showModal}
|
||
onClose={() => setShowModal(false)}
|
||
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
||
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
|
||
>
|
||
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
|
||
</Modal>
|
||
<ConfirmModal
|
||
isOpen={showConfirm}
|
||
onClose={() => setShowConfirm(false)}
|
||
onConfirm={() => {
|
||
if (confirmAction) confirmAction();
|
||
setShowConfirm(false);
|
||
}}
|
||
title="Подтверждение"
|
||
message={confirmMessage}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Organization Form Component
|
||
function OrganizationForm({
|
||
organization,
|
||
onSubmit,
|
||
onCancel,
|
||
isSubmitting,
|
||
}: {
|
||
organization?: Organization;
|
||
onSubmit: (data: OrganizationCreate) => void;
|
||
onCancel: () => void;
|
||
isSubmitting: boolean;
|
||
}) {
|
||
const [formData, setFormData] = useState<OrganizationCreate>({
|
||
name: organization?.name || '',
|
||
platform: (organization?.platform as OrganizationPlatform) || 'gitea',
|
||
base_url: organization?.base_url || '',
|
||
api_token: '',
|
||
webhook_secret: '',
|
||
});
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
onSubmit(formData);
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||
<div className="bg-dark-card rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-dark-border">
|
||
<h2 className="text-2xl font-bold mb-4 text-dark-text-primary">
|
||
{organization ? 'Редактировать организацию' : 'Новая организация'}
|
||
</h2>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||
Название организации *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="inno-js"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||
Платформа *
|
||
</label>
|
||
<select
|
||
value={formData.platform}
|
||
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
|
||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
<option value="gitea">Gitea</option>
|
||
<option value="github">GitHub</option>
|
||
<option value="bitbucket">Bitbucket</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||
Base URL *
|
||
</label>
|
||
<input
|
||
type="url"
|
||
required
|
||
value={formData.base_url}
|
||
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
|
||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="https://git.example.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||
API токен
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData.api_token}
|
||
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Опционально (используется master токен если не указан)"
|
||
/>
|
||
<p className="text-xs text-dark-text-muted mt-1">
|
||
💡 Если не указан, будет использован master токен из конфигурации сервера
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-dark-text-primary mb-1">
|
||
Webhook Secret
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.webhook_secret}
|
||
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
|
||
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Опционально (генерируется автоматически)"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||
>
|
||
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
className="flex-1 px-4 py-2 bg-dark-hover text-dark-text-primary border border-dark-border rounded-lg hover:bg-dark-border transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|