Compare commits

..

49 Commits

Author SHA1 Message Date
vchikalkin
e668f0ded2 apps/web: add BIGSTRING metaObject fieldType 2024-03-27 14:45:03 +03:00
vchikalkin
dc1c315702 apps/web: pass cookie to getConfig request 2024-02-22 10:52:26 +03:00
vchikalkin
4d5ab7fd36 apps/web: fix pass cookie to api request 2024-02-21 11:51:29 +03:00
vchikalkin
4733f2bb61 Form/Documents: show http error after upload document 2024-02-02 16:28:17 +03:00
vchikalkin
54df128e56 Form/Common: pass http errors 2024-02-02 15:25:50 +03:00
vchikalkin
08aee48f07 Form/Documents: reload page after upload files 2024-02-02 12:29:46 +03:00
vchikalkin
b6001ca466 Form/Common: fix reload page 2024-02-02 12:18:41 +03:00
vchikalkin
8ebf5ff249 Form/Common: block form on save 2024-02-02 11:54:18 +03:00
vchikalkin
a821906190 components/Form: combine imports 2024-02-01 18:27:12 +03:00
vchikalkin
be938b4d62 Form/Common: disable save button if form values are not changed 2024-02-01 18:17:23 +03:00
vchikalkin
381ba678d9 Form/Documents: disable buttons if no files are selected for upload 2024-02-01 18:09:15 +03:00
vchikalkin
7590352ba5 Form/Documents: reset InputFile components onClick cancel button 2024-02-01 18:02:20 +03:00
vchikalkin
2b9cdd0d73 Form/Common: pass handleRetract function to button 2024-02-01 15:13:55 +03:00
vchikalkin
c40eda7fd8 apps/web: add UploadFiles button 2024-01-30 12:50:05 +03:00
vchikalkin
cf60724068 apps/web: upgrade packages 2024-01-30 11:26:03 +03:00
vchikalkin
f97597d1fb ci: remove test empty file 2023-12-24 10:57:32 +03:00
vchikalkin
83317cd0d2 ci: add test empty file 2023-12-24 10:55:40 +03:00
vchikalkin
e1e12da730 apps/web: upgrade next 2023-12-24 10:47:12 +03:00
vchikalkin
e40d5488b9 Docker: fix pass env vars 2023-12-23 22:34:09 +03:00
vchikalkin
0a5be044b1 project: remove .env 2023-12-23 18:03:08 +03:00
vchikalkin
0f82f208d9 Revert "apps/web: Dockerfile: remove using dotenv"
This reverts commit 36e158cb19894f6ce40c2f6b3cc41e8106384900.
2023-12-23 16:53:24 +03:00
vchikalkin
4b9ba14ee4 Revert "docker-compose.yml: remove environment"
This reverts commit 566c362c8a01c2e9416214bcce6e333e06bf79d5.
2023-12-23 16:53:12 +03:00
vchikalkin
8bce97b40a Revert "apps/web: Dockerfile: revert RUN pnpm add -g turbo"
This reverts commit 782408da5ea41d6481fbb8a9ba9cf674416c86b2.
2023-12-23 16:53:01 +03:00
vchikalkin
782408da5e apps/web: Dockerfile: revert RUN pnpm add -g turbo 2023-12-23 16:36:36 +03:00
vchikalkin
566c362c8a docker-compose.yml: remove environment 2023-12-23 16:35:30 +03:00
vchikalkin
36e158cb19 apps/web: Dockerfile: remove using dotenv 2023-12-23 16:32:11 +03:00
vchikalkin
ef827fb73e apps/web: Dockerfile: remove COPY .env .env 2023-12-23 16:23:41 +03:00
vchikalkin
f611e561ac apps/web: again trying to fix environment variables 2023-12-23 15:50:07 +03:00
vchikalkin
780794c53f apps/web: fix pass USE_DEV_COLORS to client 2023-12-23 15:28:46 +03:00
vchikalkin
ae7faa2cbf revert .env file 2023-12-23 15:08:16 +03:00
vchikalkin
4502b05b6f Revert "project: remove .env"
This reverts commit 22da611c52a1acedb6bb8be07f5695a4e114e6c9.
2023-12-23 15:07:40 +03:00
vchikalkin
22da611c52 project: remove .env
docker-compose.yml: uncomment environment
2023-12-23 15:06:37 +03:00
vchikalkin
126643982b apps/web: fix pass USE_DEV_COLORS variable to client 2023-12-23 15:03:09 +03:00
vchikalkin
e033886097 apps/web: next.config.js: pass env 2023-12-23 14:46:48 +03:00
vchikalkin
6918fb3766 Revert "docker-compose.yml: comment environment"
This reverts commit e56285070eb149f60012f00d951334d27bbc867b.
2023-12-23 14:46:22 +03:00
vchikalkin
e56285070e docker-compose.yml: comment environment 2023-12-23 14:43:03 +03:00
vchikalkin
92c3b0ca02 project: add docker-compose.yml 2023-12-23 14:27:27 +03:00
vchikalkin
b7c2cc0ff2 apps/web: fix Elements nullish values 2023-12-21 15:21:10 +03:00
vchikalkin
27d5fe4741 apps/web: simplify combineDocuments function 2023-11-30 16:39:08 +03:00
vchikalkin
a8d3c96d6d apps/web: combineDocuments: add missing documentTypes to res 2023-11-30 16:01:01 +03:00
vchikalkin
5f8a9c5320 apps/web: move getDocs function to server
rename getDocs -> combineDocuments
2023-11-30 15:53:49 +03:00
vchikalkin
74d5c11f8e apps/web: add canUpload prop to file 2023-11-30 15:40:08 +03:00
vchikalkin
6ffb48a3b9 apps/web: fix files list on mobile 2023-11-30 12:09:55 +03:00
vchikalkin
aaaa6fffbb apps/web: add close button to Overlay 2023-11-30 11:59:56 +03:00
vchikalkin
2ff3589715 apps/web: fix files map key 2023-11-30 11:55:27 +03:00
vchikalkin
55372c01b7 apps/web: show all documents in Files 2023-11-30 11:26:09 +03:00
vchikalkin
a5eb858f4a apps/web: hide download document button if no href 2023-11-30 10:49:07 +03:00
vchikalkin
498a64cdc9 apps/web: fix manifest 401 2023-11-28 13:01:50 +03:00
vchikalkin
24a12720d2 merge experimental/migrate-to-pnpm 2023-11-28 12:39:06 +03:00
34 changed files with 1606 additions and 303 deletions

3
.env
View File

@ -1,3 +0,0 @@
USE_DEV_COLORS=
URL_IUS_DIRECT=

View File

@ -32,5 +32,5 @@
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"editor.inlineSuggest.showToolbar": "onHover"
"editor.inlineSuggest.showToolbar": "always"
}

View File

@ -6,7 +6,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN pnpm add -g turbo dotenv-cli
RUN pnpm add -g turbo
COPY . .
RUN turbo prune --scope=web --docker
@ -28,8 +28,9 @@ RUN pnpm install
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY .env .env
RUN pnpm dotenv -e .env turbo run build --filter=web...
ARG URL_IUS_DIRECT
ARG NEXT_PUBLIC_USE_DEV_COLORS
RUN pnpm turbo run build --filter=web...
FROM node:alpine AS runner
WORKDIR /app

View File

@ -7,42 +7,46 @@ import wretch from 'wretch';
const urls = getUrls();
const api = wretch(urls.URL_IUS).options({ cache: 'no-store' }).errorType('json');
type Input = { pageUrlParams: PageUrlParams; payload?: unknown };
type Input = { cookie?: string; pageUrlParams: PageUrlParams; payload?: unknown };
export async function getData({ pageUrlParams }: Input) {
export async function getData({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({
...pageUrlParams,
route: '',
});
return api
.headers({ cookie })
.get(url)
.res<t.ResponseGetData>((cb) => cb.json())
.then((res) => res);
}
export async function getMetaData({ pageUrlParams }: Input) {
export async function getMetaData({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({ ...pageUrlParams, route: '/meta' });
return api
.headers({ cookie })
.get(url)
.res<t.ResponseMetaData>((res) => res.json())
.then((res) => res);
}
export async function getConfig({ pageUrlParams }: Input) {
export async function getConfig({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({ ...pageUrlParams, route: '/config' });
return api
.headers({ cookie })
.get(url)
.res<t.ResponseConfig>((res) => res.json())
.then((res) => res);
}
export async function getConditions({ pageUrlParams }: Input) {
export async function getConditions({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({ ...pageUrlParams, route: '/conditions' });
return api
.headers({ cookie })
.get(url)
.res<t.ResponseConditions>((res) => res.text())
.then((res) => res);
@ -55,7 +59,7 @@ export async function save({ pageUrlParams, payload }: Input) {
.post(payload, url)
.res<boolean>((res) => res.ok)
.then((res) => res)
.catch((error: WretchError) => error.json as t.HttpValidationError);
.catch((error: WretchError) => error.json as t.HttpValidationError | t.HttpError);
}
export async function retract({ pageUrlParams, payload }: Input) {
@ -65,28 +69,30 @@ export async function retract({ pageUrlParams, payload }: Input) {
.post(payload, url)
.res<boolean>((res) => res.ok)
.then((res) => res)
.catch((error: WretchError) => error.json as t.HttpValidationError);
.catch((error: WretchError) => error.json as t.HttpValidationError | t.HttpError);
}
export async function getDocumentTypes({ pageUrlParams }: Input) {
export async function getDocumentTypes({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({ ...pageUrlParams, route: '/documenttypes' });
return api
.headers({ cookie })
.get(url)
.res<t.ResponseDocumentTypes>((res) => res.json())
.then((res) => res);
}
export async function getDocuments({ pageUrlParams }: Input) {
export async function getDocuments({ pageUrlParams, cookie = '' }: Input) {
const url = createUrl({ ...pageUrlParams, route: '/documents' });
return api
.headers({ cookie })
.get(url)
.res<t.ResponseDocuments>((res) => res.json())
.then((res) => res);
}
export async function uploadDocument({
export function uploadDocument({
pageUrlParams,
document,
formData,
@ -100,5 +106,15 @@ export async function uploadDocument({
return fetch(urls.URL_IUS + url, {
body: formData,
method: 'POST',
});
})
.then((res) => {
if (res.ok) {
return true;
}
return res.json();
})
.catch((error) => {
throw error as t.HttpError;
});
}

34
apps/web/api/ius/tools.ts Normal file
View File

@ -0,0 +1,34 @@
import type { Document, DocumentType } from './types';
export function combineDocuments({
documentTypes,
documents,
}: {
documentTypes: DocumentType[];
documents: Document[];
}) {
if (!documents.length) {
return documentTypes.map((x) => ({ ...(x as Document), canUpload: true }));
}
const nonUploadableDocuments = documents
.filter(
(document) =>
!documentTypes.some(
(documentType) => documentType.documentTypeId === document.documentTypeId
)
)
.map((document) => ({ ...document, canUpload: false }));
return documentTypes
.map((documentType) => {
const targetDocument = documents.find(
(document) => document.documentTypeId === documentType.documentTypeId
);
return { ...documentType, ...targetDocument, canUpload: true };
})
.concat(nonUploadableDocuments);
}
export type CombinedDocuments = ReturnType<typeof combineDocuments>;

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type MetaObject = {
disabled: boolean;
fieldType: 'CHECKBOX' | 'DECIMAL' | 'INT' | 'STRING' | 'TEXTAREA';
fieldType: 'CHECKBOX' | 'DECIMAL' | 'INT' | 'STRING' | 'BIGSTRING';
label: string;
max: number;
min: number;
@ -16,11 +16,9 @@ export type DocumentType = {
name: string;
};
export type Document = {
documentId: string;
documentTypeId: string;
href: string;
name: string;
export type Document = DocumentType & {
documentId?: string;
href?: string;
};
export type ResponseGetData = Record<string, Value>;

View File

@ -4,10 +4,14 @@ import type { PageProps } from '@/types/page';
import { withError } from '@/utils/error';
import { getPageUrlParams } from '@/utils/url';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
export async function generateMetadata(pageProps: PageProps): Promise<Metadata> {
const pageUrlParams = getPageUrlParams(pageProps);
const { title } = await apiIUS.getConfig({ pageUrlParams });
const headersList = headers();
const cookie = headersList.get('cookie') ?? '';
const { title } = await apiIUS.getConfig({ cookie, pageUrlParams });
const text = `Условия: ${title} | Эволюция`;
return {
@ -24,7 +28,10 @@ export default async function Page(pageProps: PageProps) {
return withError({
render: async () => {
const pageUrlParams = getPageUrlParams(pageProps);
const conditions = await apiIUS.getConditions({ pageUrlParams });
const headersList = headers();
const cookie = headersList.get('cookie') ?? '';
const conditions = await apiIUS.getConditions({ cookie, pageUrlParams });
return <Conditions html={conditions} />;
},

View File

@ -1,13 +1,19 @@
import * as apiIUS from '@/api/ius/query';
import { combineDocuments } from '@/api/ius/tools';
import { Form } from '@/components/Form';
import type { FormComponentProps } from '@/components/Form/types';
import type { PageProps } from '@/types/page';
import { withError } from '@/utils/error';
import { getPageUrlParams } from '@/utils/url';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
export async function generateMetadata(pageProps: PageProps): Promise<Metadata> {
const pageUrlParams = getPageUrlParams(pageProps);
const { title } = await apiIUS.getConfig({ pageUrlParams });
const headersList = headers();
const cookie = headersList.get('cookie') ?? '';
const { title } = await apiIUS.getConfig({ cookie, pageUrlParams });
const text = `${title} | Эволюция`;
return {
@ -25,14 +31,24 @@ export default async function Page(pageProps: PageProps) {
render: async () => {
const pageUrlParams = getPageUrlParams(pageProps);
const headersList = headers();
const cookie = headersList.get('cookie') ?? '';
return Promise.all([
apiIUS.getData({ pageUrlParams }),
apiIUS.getMetaData({ pageUrlParams }),
apiIUS.getConfig({ pageUrlParams }),
apiIUS.getDocumentTypes({ pageUrlParams }),
apiIUS.getDocuments({ pageUrlParams }),
apiIUS.getData({ cookie, pageUrlParams }),
apiIUS.getMetaData({ cookie, pageUrlParams }),
apiIUS.getConfig({ cookie, pageUrlParams }),
apiIUS.getDocumentTypes({ cookie, pageUrlParams }),
apiIUS.getDocuments({ cookie, pageUrlParams }),
]).then(([data, metaData, { title }, documentTypes, documents]) => {
const props = { data, documentTypes, documents, metaData, pageUrlParams, title };
const combinedDocuments = combineDocuments({ documentTypes, documents });
const props: FormComponentProps = {
combinedDocuments,
data,
metaData,
pageUrlParams,
title,
};
return <Form {...props} />;
});

View File

@ -46,6 +46,7 @@ export default function RootLayout({ children }: { readonly children: React.Reac
<head>
<meta charSet="utf-8" />
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="/manifest.webmanifest" crossOrigin="use-credentials" />
</head>
<body className={inter.className}>
<Header>

View File

@ -1,25 +0,0 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
background_color: '#fff',
description: 'External | Эволюция',
display: 'standalone',
icons: [
{
sizes: '192x192',
src: '/android-chrome-192x192.png',
type: 'image/png',
},
{
sizes: '512x512',
src: '/android-chrome-512x512.png',
type: 'image/png',
},
],
name: 'External | Эволюция',
short_name: 'External | Эволюция',
start_url: '/',
theme_color: '#fff',
};
}

View File

@ -1,99 +0,0 @@
/* eslint-disable react/jsx-curly-newline */
/* eslint-disable no-negated-condition */
import { FormContext } from './context/form-context';
import * as apiIus from '@/api/ius/query';
import { useFormStore } from '@/store/ius/form';
import { Button } from '@repo/ui';
import { useRouter } from 'next/navigation';
import { pick } from 'radash';
import { useCallback } from 'react';
import { useContext } from 'react';
const ERROR_UPLOAD_DOCUMENT = 'Произошла ошибка при загрузке документов';
export function Buttons() {
const { reset, resetValidation, setValidation, values } = useFormStore();
const { formFiles, pageUrlParams, setFormState } = useContext(FormContext);
const router = useRouter();
const handleSave = useCallback(() => {
apiIus.save({ pageUrlParams, payload: values }).then((res) => {
if (typeof res !== 'boolean') {
setTimeout(() => {
setFormState({ status: 'edit' });
}, 300);
Object.keys(res.errors).forEach((name) => {
const elementValidation = res?.errors?.[name];
if (elementValidation)
setValidation({ message: elementValidation[0] ?? '', name, valid: false });
});
} else {
setFormState({ status: 'success' });
setTimeout(() => {
router.refresh();
}, 500);
}
});
}, [pageUrlParams, router, setFormState, setValidation, values]);
const handleUploadFiles = useCallback(() => {
setFormState({ status: 'pending' });
resetValidation();
const uploadFiles = formFiles.map((formFile) => {
const formData = new FormData();
formData.append('file', formFile.file);
const document = pick(formFile, ['documentTypeId']);
return apiIus.uploadDocument({
document,
formData,
pageUrlParams,
});
});
return Promise.allSettled(uploadFiles).catch(() => {
setFormState({ status: 'error', text: ERROR_UPLOAD_DOCUMENT });
throw new Error(ERROR_UPLOAD_DOCUMENT);
});
}, [formFiles, pageUrlParams, resetValidation, setFormState]);
const handleRetract = useCallback(() => {
setFormState({ status: 'pending' });
resetValidation();
apiIus.retract({ pageUrlParams, payload: values }).then((res) => {
if (typeof res !== 'boolean') {
setTimeout(() => {
setFormState({ status: 'edit' });
}, 300);
Object.keys(res.errors).forEach((name) => {
const elementValidation = res?.errors?.[name];
if (elementValidation)
setValidation({ message: elementValidation[0] ?? '', name, valid: false });
});
} else {
setFormState({ status: 'success' });
setTimeout(() => {
router.refresh();
}, 500);
}
});
}, [pageUrlParams, resetValidation, router, setFormState, setValidation, values]);
return (
<div className="grid grid-cols-1 gap-2 gap-x-4 md:grid-cols-3">
<Button
intent="outline-danger"
onClick={() => {
reset();
}}
>
Отмена
</Button>
<Button intent="outline-secondary" onClick={() => handleRetract()}>
Возврат на доработку
</Button>
<Button onClick={() => handleUploadFiles().then(() => handleSave())}>Сохранить</Button>
</div>
);
}

View File

@ -0,0 +1,95 @@
/* eslint-disable react/jsx-curly-newline */
/* eslint-disable no-negated-condition */
import { FormContext } from '../context/form-context';
import * as apiIus from '@/api/ius/query';
import { useFormStore } from '@/store/ius/form';
import { Button } from '@repo/ui';
import { useCallback, useContext } from 'react';
const ERROR_RETRACT = 'Произошла ошибка при возврате на доработку';
const ERROR_SAVE = 'Произошла ошибка при сохранении';
export function Buttons() {
const { reset, resetValidation, setValidation, status, values } = useFormStore();
const { pageUrlParams, setFormState } = useContext(FormContext);
const handleSave = useCallback(() => {
setFormState({ status: 'pending' });
resetValidation();
apiIus.save({ pageUrlParams, payload: values }).then((res) => {
if (typeof res !== 'boolean') {
const { errors } = res;
if (Array.isArray(errors)) {
setFormState({ status: 'error', text: errors?.at(0) || ERROR_SAVE });
return;
}
Object.keys(errors).forEach((name) => {
const elementValidation = errors?.[name];
if (elementValidation)
setValidation({ message: elementValidation[0] ?? '', name, valid: false });
});
setTimeout(() => {
setFormState({ status: 'edit' });
}, 300);
} else {
setFormState({ status: 'success' });
setTimeout(() => {
window.location.reload();
}, 500);
}
});
}, [pageUrlParams, resetValidation, setFormState, setValidation, values]);
const handleRetract = useCallback(() => {
setFormState({ status: 'pending' });
resetValidation();
apiIus.retract({ pageUrlParams, payload: values }).then((res) => {
if (typeof res !== 'boolean') {
const { errors } = res;
if (Array.isArray(errors)) {
setFormState({ status: 'error', text: errors?.at(0) || ERROR_RETRACT });
return;
}
setTimeout(() => {
setFormState({ status: 'edit' });
}, 300);
Object.keys(errors).forEach((name) => {
const elementValidation = errors?.[name];
if (elementValidation)
setValidation({ message: elementValidation[0] ?? '', name, valid: false });
});
} else {
setFormState({ status: 'success' });
setTimeout(() => {
window.location.reload();
}, 500);
}
});
}, [pageUrlParams, resetValidation, setFormState, setValidation, values]);
return (
<div className="grid grid-cols-1 gap-2 gap-x-4 md:grid-cols-3">
<Button
intent="outline-danger"
onClick={() => {
reset();
}}
>
Отмена
</Button>
<Button intent="outline-secondary" onClick={handleRetract}>
Возврат на доработку
</Button>
<Button onClick={handleSave} disabled={status !== 'edited'}>
Сохранить
</Button>
</div>
);
}

View File

@ -1,9 +1,9 @@
import type { Props } from './types';
import type { FormComponentProps } from '../types';
import type { MetaObject } from '@/api/ius/types';
import { mapFieldTypeElement } from '@/config/elements';
import { useFormStore } from '@/store/ius/form';
import { ElementContainer } from '@repo/ui';
import { get, omit } from 'radash';
import { get } from 'radash';
import { useEffect } from 'react';
function RenderElement({
@ -31,9 +31,9 @@ function RenderElement({
>
<Element
loading={!Object.keys(values).length}
checked={fieldType === 'CHECKBOX' ? Boolean(values[name]) || false : false}
checked={fieldType === 'CHECKBOX' ? Boolean(values[name]) ?? false : false}
id={name}
value={values[name] || ''}
value={values[name] ?? ''}
min={min}
max={max}
onChange={(e) => {
@ -49,21 +49,31 @@ function RenderElement({
);
}
export function Elements({ data, metaData }: Props) {
export function Elements({ data, metaData }: FormComponentProps) {
const { init } = useFormStore();
useEffect(() => {
init(data);
}, [data, init]);
const defaultElements = Object.keys(metaData).filter(
(x) => metaData[x]?.fieldType !== 'BIGSTRING'
);
const bigStringElements = Object.keys(metaData).filter(
(x) => metaData[x]?.fieldType === 'BIGSTRING'
);
return (
<>
<div className="mt-2 grid gap-2 gap-x-4 md:grid md:grid-cols-2 lg:grid-cols-3">
{(Object.keys(omit(metaData, ['comment'])) as Array<keyof MetaObject>).map((name) => (
{defaultElements.map((name) => (
<RenderElement key={name} {...get(metaData, name)} name={name} />
))}
</div>
<RenderElement {...get(metaData, 'comment')} fieldType="TEXTAREA" name="comment" />
{bigStringElements.map((name) => (
<RenderElement key={name} {...get(metaData, name)} name={name} fieldType="BIGSTRING" />
))}
</>
);
}

View File

@ -0,0 +1,2 @@
export * from './Buttons';
export * from './Elements';

View File

@ -0,0 +1,76 @@
/* eslint-disable react/jsx-curly-newline */
/* eslint-disable no-negated-condition */
import { FormContext } from '../context/form-context';
import * as apiIus from '@/api/ius/query';
import type { HttpError } from '@/api/ius/types';
import { useFormStore } from '@/store/ius/form';
import { Button } from '@repo/ui';
import { pick } from 'radash';
import { useCallback, useContext } from 'react';
const ERROR_UPLOAD_DOCUMENT = 'Произошла ошибка при загрузке документов';
const SUCCESS_UPLOAD_DOCUMENTS = 'Файлы успешно загружены';
export function Buttons() {
const { resetValidation } = useFormStore();
const { formFiles, pageUrlParams, setFormFiles, setFormState } = useContext(FormContext);
const handleUploadFiles = useCallback(() => {
setFormState({ status: 'pending' });
resetValidation();
const uploadFiles = formFiles.map((formFile) => {
const formData = new FormData();
formData.append('file', formFile.file);
const document = pick(formFile, ['documentTypeId']);
return apiIus.uploadDocument({
document,
formData,
pageUrlParams,
});
});
return Promise.all(uploadFiles)
.then(async (res) => {
const errors = res.filter((x) => typeof x !== 'boolean') as HttpError[];
if (!errors.length) {
setFormState({
status: 'success',
text: SUCCESS_UPLOAD_DOCUMENTS,
});
setTimeout(() => {
window.location.reload();
}, 500);
return;
}
const error = errors.find((x) => x.errors.length)?.errors.at(0) || ERROR_UPLOAD_DOCUMENT;
setFormState({ status: 'error', text: error });
})
.catch((error) => {
setFormState({
status: 'error',
text: error ? JSON.stringify(error) : ERROR_UPLOAD_DOCUMENT,
});
});
}, [formFiles, pageUrlParams, resetValidation, setFormState]);
const handleCancel = () => {
setFormFiles([]);
};
return (
<div className="grid grid-cols-1 gap-2 gap-x-4 md:grid-cols-3">
<Button intent="outline-danger" onClick={handleCancel} disabled={!formFiles.length}>
Отмена
</Button>
<Button onClick={handleUploadFiles} disabled={!formFiles.length}>
Загрузить файлы
</Button>
</div>
);
}

View File

@ -1,17 +1,16 @@
import { FormContext } from './context/form-context';
import type { Props } from './types';
import type * as IUS from '@/api/ius/types';
import { FormContext } from '../context/form-context';
import type { FormComponentProps } from '../types';
import { ArrowDownTrayIcon } from '@heroicons/react/24/solid';
import { Heading, InputFile } from '@repo/ui';
import Link from 'next/link';
import { useContext } from 'react';
import { useContext, useEffect, useRef } from 'react';
type DownloadDocumentProps = Pick<FileProps, 'document'>;
function DownloadDocument({ document }: DownloadDocumentProps) {
return document ? (
return document?.href ? (
<Link
href={'/api/ius' + document?.href}
href={'/api/ius' + document.href}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 h-10 px-5 py-2.5 text-sm font-medium hover:underline"
@ -27,14 +26,14 @@ function DownloadDocument({ document }: DownloadDocumentProps) {
}
type FileProps = {
readonly document: IUS.Document | undefined;
readonly documentType: IUS.DocumentType;
readonly document: FormComponentProps['combinedDocuments'][number];
};
function File({ document, documentType }: FileProps) {
const { documentTypeId, name } = documentType;
function File({ document }: FileProps) {
const { formFiles, setFormFiles } = useContext(FormContext);
const { canUpload, documentTypeId, name } = document;
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files !== null) {
const file = event.target.files.item(0);
@ -46,26 +45,29 @@ function File({ document, documentType }: FileProps) {
}
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const uploadableFile = formFiles.some((x) => x.documentTypeId === document.documentTypeId);
if (!uploadableFile && inputRef.current) inputRef.current.value = '';
}, [document.documentTypeId, formFiles]);
return (
<div key={documentTypeId} className="flex flex-col gap-1">
<label className="mb-2 block text-sm font-normal text-gray-900">{name}:</label>
<InputFile onChange={handleFileChange} />
<InputFile ref={inputRef} onChange={handleFileChange} disabled={!canUpload} />
<DownloadDocument document={document} />
</div>
);
}
export function Files({ documentTypes, documents }: Props) {
export function Files({ combinedDocuments }: FormComponentProps) {
return (
<div className="grid gap-4">
<Heading className="text-sms">Документы</Heading>
<div className="flex flex-col gap-2">
{documentTypes.map((documentType) => (
<File
key={documentType.documentTypeId}
documentType={documentType}
document={documents.find((x) => x.documentTypeId === documentType.documentTypeId)}
/>
<Heading className="text-sm">Документы</Heading>
<div className="grid gap-2 md:grid-cols-2">
{combinedDocuments.map((document) => (
<File key={document.documentTypeId} document={document} />
))}
</div>
</div>

View File

@ -0,0 +1,2 @@
export * from './Buttons';
export * from './Files';

View File

@ -1,44 +1,61 @@
'use client';
import { FormContext } from './context/form-context';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/solid';
import { Background, LoadingSpinner } from '@repo/ui';
import { Background, Button, LoadingSpinner } from '@repo/ui';
import type { PropsWithChildren } from 'react';
import { useContext } from 'react';
function OverlayWrapper({ children }: PropsWithChildren) {
return (
<Background className="absolute left-0 top-0 grid h-full w-full place-items-center bg-opacity-80 backdrop-blur-sm">
<div className="absolute bottom-[50vh] flex flex-row items-center gap-2 md:relative md:bottom-0">
<div className="absolute bottom-[50vh] flex flex-col items-center gap-2 md:relative md:bottom-0">
{children}
</div>
</Background>
);
}
function StateContentWrapper({ children }: PropsWithChildren) {
return <div className="flex flex-row items-center gap-2">{children}</div>;
}
export function Overlay() {
const { formState } = useContext(FormContext);
const { formState, setFormState } = useContext(FormContext);
const { status, text } = formState;
if (status === 'pending')
return (
<OverlayWrapper>
let stateContent: JSX.Element | false = false;
if (status === 'pending') {
stateContent = (
<StateContentWrapper>
{LoadingSpinner} <p className="font-medium">Загрузка...</p>
</OverlayWrapper>
</StateContentWrapper>
);
if (status === 'success')
return (
<OverlayWrapper>
<CheckCircleIcon className="h-10 w-10 fill-green-500" title="OK" />{' '}
}
if (status === 'success') {
stateContent = (
<StateContentWrapper>
<CheckCircleIcon className="h-10 w-10 fill-green-500" title="OK" />
<p className="font-medium">Данные сохранены</p>
</OverlayWrapper>
);
if (status === 'error') {
return (
<OverlayWrapper>
<XCircleIcon className="h-10 w-10 fill-red-500" title="Error" />{' '}
<p className="font-medium">{text}</p>
</OverlayWrapper>
</StateContentWrapper>
);
}
return false;
if (status === 'error') {
stateContent = (
<>
<StateContentWrapper>
<XCircleIcon className="h-10 w-10 fill-red-500" title="Error" />
<p className="font-medium">{text}</p>
</StateContentWrapper>{' '}
<Button type="button" intent="text" onClick={() => setFormState({ status: 'edit' })}>
Закрыть
</Button>
</>
);
}
if (!stateContent) return false;
return <OverlayWrapper>{stateContent}</OverlayWrapper>;
}

View File

@ -1,17 +1,16 @@
'use client';
import { Buttons } from './Buttons';
import * as Common from './Common';
import { FormContext, FormContextProvider } from './context/form-context';
import { Elements } from './Elements';
import { Files } from './Files';
import * as Documents from './Documents';
import { Header } from './Header';
import { Overlay } from './Overlay';
import type { Props } from './types';
import type { FormComponentProps } from './types';
import { createUrl } from '@/utils/url';
import { Background, Divider } from '@repo/ui';
import type { FC } from 'react';
import { useContext } from 'react';
function Content(props: Props) {
function Content(props: FormComponentProps) {
const { title } = props;
const { pageUrlParams } = useContext(FormContext);
@ -19,16 +18,17 @@ function Content(props: Props) {
<Background className="relative flex w-full flex-col gap-2 p-5">
<Overlay />
<Header title={title} link={'/ius' + createUrl({ ...pageUrlParams, route: '/conditions' })} />
<Elements {...props} />
<Common.Elements {...props} />
<Common.Buttons />
<Divider />
<Files {...props} />
<Documents.Files {...props} />
<Documents.Buttons />
<Divider />
<Buttons />
</Background>
);
}
function withContext<T extends Props>(Component: FC<T>) {
function withContext<T extends FormComponentProps>(Component: FC<T>) {
return (props: T) => {
const { pageUrlParams } = props;

View File

@ -1,10 +1,10 @@
import type { CombinedDocuments } from '@/api/ius/tools';
import type * as IUS from '@/api/ius/types';
import type { PageUrlParams } from '@/utils/url';
export type Props = {
export type FormComponentProps = {
readonly combinedDocuments: CombinedDocuments;
readonly data: IUS.ResponseGetData;
readonly documentTypes: IUS.ResponseDocumentTypes;
readonly documents: IUS.ResponseDocuments;
readonly metaData: IUS.ResponseMetaData;
readonly pageUrlParams: PageUrlParams;
readonly title: string;

View File

@ -1,10 +1,12 @@
import { getEnv } from '@/config/env';
import { getClientEnv } from '@/config/env';
import Image from 'next/image';
import logo from 'public/assets/images/logo-primary.svg';
import logoDev from 'public/assets/images/logo-primary-dev.svg';
const env = getEnv();
const env = getClientEnv();
export function Logo() {
return <Image priority alt="logo" src={env.USE_DEV_COLORS ? logoDev : logo} height="24" />;
return (
<Image priority alt="logo" src={env.NEXT_PUBLIC_USE_DEV_COLORS ? logoDev : logo} height="24" />
);
}

View File

@ -6,9 +6,9 @@ function wrapMap<C, T extends Record<MetaObject['fieldType'], C>>(arg: T) {
}
export const mapFieldTypeElement = wrapMap({
BIGSTRING: Textarea,
CHECKBOX: Checkbox,
DECIMAL: InputNumber,
INT: InputNumber,
STRING: Input,
TEXTAREA: Textarea,
});

View File

@ -1,5 +1,6 @@
const envSchema = require('./schema/env.js');
const { clientEnvSchema, serverEnvSchema } = require('./schema/env');
const getEnv = () => envSchema.parse(process.env);
const getClientEnv = () => clientEnvSchema.parse(process.env);
const getServerEnv = () => serverEnvSchema.parse(process.env);
module.exports = { getEnv };
module.exports = { getClientEnv, getServerEnv };

View File

@ -2,7 +2,15 @@ const { z } = require('zod');
const envSchema = z.object({
URL_IUS_DIRECT: z.string(),
USE_DEV_COLORS: z.string().optional(),
NEXT_PUBLIC_USE_DEV_COLORS: z.string().optional(),
});
module.exports = envSchema;
const serverEnvSchema = envSchema.pick({
URL_IUS_DIRECT: true,
});
const clientEnvSchema = envSchema.pick({
NEXT_PUBLIC_USE_DEV_COLORS: true,
});
module.exports = { envSchema, serverEnvSchema, clientEnvSchema };

View File

@ -1,10 +1,10 @@
import { getEnv } from './env';
import { getServerEnv } from './env';
import proxyUrls from '@/constants/urls';
import { isServer } from '@/utils/common';
export function getUrls() {
if (isServer()) {
const env = getEnv();
const env = getServerEnv();
const { URL_IUS_DIRECT } = env;
return {

View File

@ -1,4 +1,4 @@
import { getEnv } from '../config/env';
import { getClientEnv } from '../config/env';
export const COLORS_PROD = {
COLOR_DANGER: '#B20004',
@ -12,5 +12,5 @@ export const COLORS_DEV = {
COLOR_SECONDARY: '#c54a84',
COLOR_TERTIARTY: '#FF9112',
};
const env = getEnv();
export const COLORS = env.USE_DEV_COLORS ? COLORS_DEV : COLORS_PROD;
const env = getClientEnv();
export const COLORS = env.NEXT_PUBLIC_USE_DEV_COLORS ? COLORS_DEV : COLORS_PROD;

View File

@ -1,12 +1,12 @@
const fs = require('fs');
const path = require('path');
const envSchema = require('./config/schema/env');
const { envSchema } = require('./config/schema/env');
const urls = require('./constants/urls');
const env = envSchema.parse(process.env);
const favicons = fs.readdirSync('./public/favicon/prod');
const faviconSubPath = env.USE_DEV_COLORS ? '/favicon/dev' : '/favicon/prod';
const faviconSubPath = env.NEXT_PUBLIC_USE_DEV_COLORS ? '/favicon/dev' : '/favicon/prod';
function buildFaviconRewrite(source) {
return {
destination: String.prototype.concat(faviconSubPath, source),
@ -34,6 +34,7 @@ const nextConfig = {
...favicons.map((fileName) => buildFaviconRewrite(`/${fileName}`)),
];
},
env,
};
module.exports = nextConfig;

View File

@ -11,14 +11,14 @@
"dependencies": {
"@heroicons/react": "^2.0.18",
"@repo/tsconfig": "workspace:*",
"next": "^14.0.3",
"@repo/ui": "workspace:*",
"next": "^14.1.0",
"radash": "^11.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@repo/ui": "workspace:*",
"wretch": "^2.7.0",
"zod": "^3.22.4",
"zustand": "^4.4.6"
"zustand": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.6",

View File

@ -0,0 +1,21 @@
{
"background_color": "#fff",
"description": "External | Эволюция",
"display": "standalone",
"icons": [
{
"sizes": "192x192",
"src": "/android-chrome-192x192.png",
"type": "image/png"
},
{
"sizes": "512x512",
"src": "/android-chrome-512x512.png",
"type": "image/png"
}
],
"name": "External | Эволюция",
"short_name": "External | Эволюция",
"start_url": "/",
"theme_color": "#fff"
}

View File

@ -15,6 +15,7 @@ type FormState = {
resetValidation: () => void;
setValidation: (input: { name: string } & ElementValidation) => void;
setValue: ({ name, value }: { name: string; value: Values[number] }) => void;
status?: 'init' | 'edited';
validation: Record<string, ElementValidation | undefined>;
values: Values;
};
@ -24,10 +25,12 @@ export const useFormStore = create<FormState>((set) => ({
init: (values) =>
set(() => ({
defaultValues: values,
status: 'init',
values,
})),
reset: () =>
set((state) => ({
status: 'init',
validation: {},
values: state.defaultValues,
})),
@ -44,6 +47,7 @@ export const useFormStore = create<FormState>((set) => ({
})),
setValue: ({ name, value }) =>
set((state) => ({
status: 'edited',
validation: {
...state.validation,
[name]: undefined,

View File

@ -1,12 +1,20 @@
version: '3.3'
services:
external_client:
ports:
- '3000:3000'
environment:
- URL_IUS_DIRECT=${URL_IUS_DIRECT}
- USE_DEV_COLORS=${USE_DEV_COLORS}
- NEXT_PUBLIC_USE_DEV_COLORS=${NEXT_PUBLIC_USE_DEV_COLORS}
build:
args:
- URL_IUS_DIRECT=${URL_IUS_DIRECT}
- NEXT_PUBLIC_USE_DEV_COLORS=${NEXT_PUBLIC_USE_DEV_COLORS}
context: .
dockerfile: ./apps/web/Dockerfile
networks:
- external_network
restart: always
networks:
external_network:
external:
name: external_network

View File

@ -22,6 +22,7 @@ const variants = cva(
'border border-primary text-primary-500 hover:bg-primary-500 hover:text-white',
secondary:
'bg-primary-50 text-primary-500 border border-transparent hover:bg-primary-100 hover:text-primary-600',
text: 'bg-none text-primary-500 hover:bg-primary-50',
},
},
}

View File

@ -29,7 +29,7 @@ export const InputFile = forwardRef<HTMLInputElement, InputProps>((props, ref) =
type="file"
className="file:bg-primary-50 file:text-primary-500 hover:file:bg-primary-100 block w-full text-sm
text-slate-500 file:mr-4 file:rounded-sm file:border-0
file:px-4 file:py-2 file:text-sm
file:font-semibold hover:file:cursor-pointer"
file:px-4 file:py-2 file:text-sm file:font-semibold
hover:file:cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 disabled:file:cursor-not-allowed"
/>
));

1233
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff