diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 31560a8..b95757e 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -383,6 +383,7 @@ export const useWithdrawDraft = () => { onSuccess: () => { qc.invalidateQueries({ queryKey: ['review-queue'] }) qc.invalidateQueries({ queryKey: ['draft'] }) + qc.invalidateQueries({ queryKey: ['drafts'] }) // /me + by-dict }, }) } diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index ad1a56e..101928f 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -430,6 +430,26 @@ export const useDictPendingDrafts = (dictName: string | undefined) => enabled: Boolean(dictName), }) +/** + * Maker self-tracking — "что я отправил". Все статусы (PENDING/APPROVED/ + * REJECTED/WITHDRAWN), sorted submitted_at DESC. Refetch на 30s — maker + * увидит когда reviewer approve/reject. + */ +export const myDraftsQuery = (page = 0, size = 50) => + queryOptions({ + queryKey: ['drafts', 'me', page, size] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/drafts/me', { + params: { page, size }, + }) + return data + }, + refetchInterval: 30_000, + }) + +export const useMyDrafts = (page = 0, size = 50) => + useQuery(myDraftsQuery(page, size)) + /** Single draft by id — for diff viewer drawer. */ export const draftQuery = (id: string) => queryOptions({ diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0a6f0c4..f1d4554 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -19,6 +19,24 @@ i18n 'nav.webhooks': 'Webhooks', 'nav.search': 'Поиск', 'nav.reviews': 'Согласования', + 'nav.myDrafts': 'Мои черновики', + 'myDrafts.title': 'Мои черновики', + 'myDrafts.description': 'Все ваши submissions с историей: pending, approved, rejected, withdrawn.', + 'myDrafts.empty': 'Вы пока не отправляли черновиков.', + 'myDrafts.total_one': '{{count}} черновик', + 'myDrafts.total_few': '{{count}} черновика', + 'myDrafts.total_many': '{{count}} черновиков', + 'myDrafts.total_other': '{{count}} черновиков', + 'myDrafts.col.bk': 'Бизнес-ключ', + 'myDrafts.col.op': 'Операция', + 'myDrafts.col.status': 'Статус', + 'myDrafts.col.submitted': 'Отправлено', + 'myDrafts.col.reviewed': 'Решено', + 'myDrafts.col.reviewer': 'Reviewer', + 'myDrafts.col.comment': 'Комментарий', + 'myDrafts.col.actions': 'Действия', + 'myDrafts.action.withdraw': 'Отозвать', + 'myDrafts.confirmWithdraw': 'Отозвать draft? Это безвозвратно — статус станет WITHDRAWN.', 'reviews.title': 'Очередь согласований', 'reviews.description': 'Pending drafts от makers, ждут approve/reject. Pool model: первый approver обрабатывает.', 'reviews.empty': 'Нет pending согласований. Все обработаны 👍', @@ -425,6 +443,22 @@ i18n 'nav.webhooks': 'Webhooks', 'nav.search': 'Search', 'nav.reviews': 'Reviews', + 'nav.myDrafts': 'My drafts', + 'myDrafts.title': 'My drafts', + 'myDrafts.description': 'All your submissions with history: pending, approved, rejected, withdrawn.', + 'myDrafts.empty': "You haven't submitted any drafts yet.", + 'myDrafts.total_one': '{{count}} draft', + 'myDrafts.total_other': '{{count}} drafts', + 'myDrafts.col.bk': 'Business key', + 'myDrafts.col.op': 'Operation', + 'myDrafts.col.status': 'Status', + 'myDrafts.col.submitted': 'Submitted', + 'myDrafts.col.reviewed': 'Reviewed', + 'myDrafts.col.reviewer': 'Reviewer', + 'myDrafts.col.comment': 'Comment', + 'myDrafts.col.actions': 'Actions', + 'myDrafts.action.withdraw': 'Withdraw', + 'myDrafts.confirmWithdraw': 'Withdraw draft? This is irreversible — status becomes WITHDRAWN.', 'reviews.title': 'Review queue', 'reviews.description': 'Pending drafts from makers awaiting approve/reject. Pool model: first approver handles.', 'reviews.empty': 'No pending reviews. All clear 👍', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index 298a9ff..21d8564 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as WebhooksRouteImport } from './routes/webhooks' import { Route as SearchRouteImport } from './routes/search' import { Route as ReviewsRouteImport } from './routes/reviews' import { Route as OutboxRouteImport } from './routes/outbox' +import { Route as MyDraftsRouteImport } from './routes/my-drafts' import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as AuditRouteImport } from './routes/audit' import { Route as IndexRouteImport } from './routes/index' @@ -41,6 +42,11 @@ const OutboxRoute = OutboxRouteImport.update({ path: '/outbox', getParentRoute: () => rootRouteImport, } as any) +const MyDraftsRoute = MyDraftsRouteImport.update({ + id: '/my-drafts', + path: '/my-drafts', + getParentRoute: () => rootRouteImport, +} as any) const DictionariesRoute = DictionariesRouteImport.update({ id: '/dictionaries', path: '/dictionaries', @@ -81,6 +87,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute '/search': typeof SearchRoute @@ -93,6 +100,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/audit': typeof AuditRoute + '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute '/search': typeof SearchRoute @@ -106,6 +114,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren + '/my-drafts': typeof MyDraftsRoute '/outbox': typeof OutboxRoute '/reviews': typeof ReviewsRoute '/search': typeof SearchRoute @@ -121,6 +130,7 @@ export interface FileRouteTypes { | '/' | '/audit' | '/dictionaries' + | '/my-drafts' | '/outbox' | '/reviews' | '/search' @@ -133,6 +143,7 @@ export interface FileRouteTypes { to: | '/' | '/audit' + | '/my-drafts' | '/outbox' | '/reviews' | '/search' @@ -145,6 +156,7 @@ export interface FileRouteTypes { | '/' | '/audit' | '/dictionaries' + | '/my-drafts' | '/outbox' | '/reviews' | '/search' @@ -159,6 +171,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuditRoute: typeof AuditRoute DictionariesRoute: typeof DictionariesRouteWithChildren + MyDraftsRoute: typeof MyDraftsRoute OutboxRoute: typeof OutboxRoute ReviewsRoute: typeof ReviewsRoute SearchRoute: typeof SearchRoute @@ -195,6 +208,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OutboxRouteImport parentRoute: typeof rootRouteImport } + '/my-drafts': { + id: '/my-drafts' + path: '/my-drafts' + fullPath: '/my-drafts' + preLoaderRoute: typeof MyDraftsRouteImport + parentRoute: typeof rootRouteImport + } '/dictionaries': { id: '/dictionaries' path: '/dictionaries' @@ -279,6 +299,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuditRoute: AuditRoute, DictionariesRoute: DictionariesRouteWithChildren, + MyDraftsRoute: MyDraftsRoute, OutboxRoute: OutboxRoute, ReviewsRoute: ReviewsRoute, SearchRoute: SearchRoute, diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index f59948a..17922b0 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -65,6 +65,13 @@ function RootLayout() { > {t('nav.reviews')} + + {t('nav.myDrafts')} +
{ + if (op === 'CREATE') return 'success' + if (op === 'CLOSE') return 'warning' + return 'info' +} + +const statusVariant = ( + s: DraftStatus, +): 'warning' | 'success' | 'error' | 'neutral' => { + if (s === 'PENDING') return 'warning' + if (s === 'APPROVED') return 'success' + if (s === 'REJECTED') return 'error' + return 'neutral' +} + +/** + * Approval Workflow v2 Phase 4: maker self-tracking. Maker видит свои + * submissions всех статусов (PENDING/APPROVED/REJECTED/WITHDRAWN), sorted + * submitted_at DESC. Polled каждые 30s — maker увидит когда reviewer + * approve/reject. Withdraw кнопка для PENDING (только свои + maker-only + * gate в backend). + */ +function MyDraftsPage() { + const { t } = useTranslation() + const drafts = useMyDrafts(0, 50) + const withdrawMut = useWithdrawDraft() + const [busyId, setBusyId] = useState(undefined) + + const handleWithdraw = (id: string) => { + if (!confirm(t('myDrafts.confirmWithdraw'))) return + setBusyId(id) + withdrawMut.mutate(id, { + onSettled: () => setBusyId(undefined), + }) + } + + return ( +
+ + + {drafts.isLoading && } + + {drafts.error && ( + + {String(drafts.error)} + + )} + + {drafts.data && drafts.data.totalElements === 0 && ( + + )} + + {drafts.data && drafts.data.totalElements > 0 && ( + +
+

+ {t('myDrafts.total', { count: drafts.data.totalElements })} +

+
+ + + + {t('myDrafts.col.bk')} + {t('myDrafts.col.op')} + {t('myDrafts.col.status')} + {t('myDrafts.col.submitted')} + {t('myDrafts.col.reviewed')} + {t('myDrafts.col.reviewer')} + {t('myDrafts.col.comment')} + {t('myDrafts.col.actions')} + + + + {drafts.data.items.map((d) => ( + + {d.businessKey} + + {d.operation} + + + {d.status} + + + {new Date(d.submittedAt).toLocaleString()} + + + {d.reviewedAt + ? new Date(d.reviewedAt).toLocaleString() + : '—'} + + + {d.reviewerId ?? '—'} + + + {d.reviewComment ?? d.makerComment ?? '—'} + + + {d.status === 'PENDING' ? ( + + ) : ( + + )} + + + ))} + +
+
+ )} +
+ ) +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java index acd3a96..f3092e6 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java @@ -111,4 +111,32 @@ public class DraftController { public DraftResponse withdraw(@PathVariable UUID id) { return DraftResponse.from(draftService.withdraw(id)); } + + /** + * Maker self-tracking: "что я отправил". Возвращает все статусы (PENDING / + * APPROVED / REJECTED / WITHDRAWN) для current authenticated user, sorted + * submitted_at DESC. Phase 4 UX: makers видят свои submissions без access + * к /reviews (доступен только pool reviewer'ам). + */ + @GetMapping("/api/v1/drafts/me") + public Map myDrafts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + String makerId = currentUserId(); + var pg = draftService.listByMaker(makerId, PageRequest.of(page, Math.min(size, 200))); + return Map.of( + "items", pg.getContent().stream().map(DraftResponse::from).toList(), + "page", pg.getNumber(), + "size", pg.getSize(), + "totalElements", pg.getTotalElements(), + "totalPages", pg.getTotalPages()); + } + + /** Resolve current user from JWT subject. Fallback "anonymous" если auth off. */ + private static String currentUserId() { + var auth = org.springframework.security.core.context.SecurityContextHolder + .getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return "anonymous"; + return auth.getName(); + } }