Compare commits

..

No commits in common. "c8dbba7e57ec6da2618bbdc72e80b46404d5082a" and "66846c6afa1766e24dff50a33cff1797268172f5" have entirely different histories.

102 changed files with 5891 additions and 11461 deletions

View File

@ -762,43 +762,6 @@ def upgrade() -> None:
['code'], ['code'],
schema='v3', schema='v3',
) )
create_table_if_not_exists(
'form3_phase',
sa.Column('rf_project_report_id', sa.Integer(),primary_key=True),
sa.Column('phase_code', sa.String(), primary_key=True),
sa.Column('role', sa.String(), nullable=False),
sa.Column('column_keys', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('opens_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('closes_at', sa.DateTime(timezone=True), nullable=False),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_window', 'form3_phase',
sa.text('closes_at > opens_at'),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_columns', 'form3_phase',
sa.text('cardinality(column_keys) > 0'),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_no_admin', 'form3_phase',
sa.text("role != 'ADMIN'"),
schema='v3',
)
create_index_if_not_exists(
'ix_v3_form3_phase_active', 'form3_phase',
['rf_project_report_id', 'role', 'opens_at', 'closes_at'],
schema='v3',
)
create_index_if_not_exists(
'ix_v3_form3_phase_columns_gin', 'form3_phase',
['column_keys'],
schema='v3',
postgresql_using='gin',
)
create_table_if_not_exists( create_table_if_not_exists(
'security_detail', 'security_detail',
sa.Column('id', sa.Integer(), sa.Column('id', sa.Integer(),

View File

@ -1,63 +0,0 @@
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.del_vsp(p_vsp_id integer, p_deleted_by integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_old RECORD;
v_usr RECORD;
BEGIN
SELECT vsp.id, vsp.branch_id
INTO v_old
FROM v3.vsp vsp WHERE vsp.id = p_vsp_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'vsp #% не существует', p_vsp_id;
END IF;
SELECT usr.id
INTO v_usr
FROM v3.app_user usr WHERE usr.id = p_deleted_by;
IF NOT FOUND THEN
RAISE EXCEPTION 'user #% не существует', p_deleted_by;
END IF;
UPDATE v3.vsp
SET is_deleted = true,
is_active = false,
updated_by = p_deleted_by
WHERE id = p_vsp_id;
UPDATE v3.vsp
SET closed_at = CURRENT_DATE
WHERE id = p_vsp_id
AND closed_at IS NULL;
PERFORM v3.log_event(
'DELETE_VSP', 'VSP',
jsonb_build_object(
'vsp_id', p_vsp_id,
'entity_id', p_vsp_id,
'branch_id', v_old.branch_id,
'updated_by', p_deleted_by
),
null, null, v_old.branch_id
);
RETURN p_vsp_id;
END;
$function$;
"""
)
def downgrade() -> None:
pass

View File

@ -1,330 +0,0 @@
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN opens_at TYPE timestamp USING opens_at::timestamp')
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN closes_at TYPE timestamp USING closes_at::timestamp')
op.execute("ALTER TABLE v3.org_unit ADD COLUMN utc_offset VARCHAR(3) DEFAULT '+03' NOT NULL")
# заводим фазу с нужной таймзоной
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_inserted INTEGER;
v_utc_offset VARCHAR(3);
v_org_unit INTEGER;
BEGIN
SELECT form_type_code, org_unit_id INTO v_form_type, v_org_unit
FROM v3.budget_form
WHERE id = p_form_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form_phase
(budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_form_id, pt.sheet, pt.phase_code, pt.role,
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
(pt.closes_at::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = v_form_type
ON CONFLICT (budget_form_id, sheet, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
"""
)
# Создавая фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp, p_closes_at timestamp)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
BEGIN
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit_id;
INSERT INTO v3.form_phase(
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
)
VALUES (
p_budget_form_id,
p_sheet,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'create',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
"""
)
# Обнволяя фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_new v3.form_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
BEGIN
SELECT * INTO v_old
FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
p_budget_form_id, p_sheet, p_phase_code;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
p_budget_form_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'changes', v_changes
),
p_budget_form_id, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$
;
"""
)
# при обновлении таймзоны орг юнита обновляем актуальные фазы, связанные с ними
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz varchar(3), p_new_tz varchar(3))
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_result integer;
BEGIN
IF p_old_tz = p_new_tz THEN
RETURN 0;
END IF;
WITH updated AS (
UPDATE v3.form_phase
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
AND (
opens_at >= CURRENT_TIMESTAMP
OR closes_at >= CURRENT_TIMESTAMP
)
RETURNING *
)
SELECT COUNT(*) INTO v_result FROM updated;
RETURN v_result;
END;
$function$
;
"""
)
# op.add_column(
# 'org_unit',
# sa.Column(
# 'utc_offset',
# sa.String(3),
# nullable=False,
# server_default='+03',
# )
# )
def downgrade() -> None:
pass

View File

@ -1,79 +0,0 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> Optional[str]:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> List[str]:
statements: List[str] = []
current: List[str] = []
in_dollar = False
dollar_tag: Optional[str] = None
for line in sql.split("\n"):
stripped = line.strip()
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0006_form3.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

@ -1,180 +0,0 @@
from alembic import op
import sqlalchemy as sa
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE OR REPLACE FUNCTION v3.v_form1_smeta(p_year integer, p_org_unit_id integer)
RETURNS TABLE(row_type character varying, depth integer, section_code character varying, name character varying, supp_plan_q1 numeric, supp_plan_q2 numeric, supp_plan_q3 numeric, supp_plan_q4 numeric, supp_plan_year numeric, dev_plan_q1 numeric, dev_plan_q2 numeric, dev_plan_q3 numeric, dev_plan_q4 numeric, dev_plan_year numeric, total_plan_year numeric, supp_appr_q1 numeric, supp_appr_q2 numeric, supp_appr_q3 numeric, supp_appr_q4 numeric, supp_appr_year numeric, dev_appr_q1 numeric, dev_appr_q2 numeric, dev_appr_q3 numeric, dev_appr_q4 numeric, dev_appr_year numeric, total_appr_year numeric, supp_act_q1 numeric, supp_act_q2 numeric, supp_act_q3 numeric, supp_act_q4 numeric, supp_act_year numeric, dev_act_q1 numeric, dev_act_q2 numeric, dev_act_q3 numeric, dev_act_q4 numeric, dev_act_year numeric, total_act_year numeric, supp_corr_q2 numeric, supp_corr_q3 numeric, supp_corr_q4 numeric, dev_corr_q2 numeric, dev_corr_q3 numeric, dev_corr_q4 numeric)
LANGUAGE plpgsql
STABLE
SET search_path TO 'v3', 'pg_catalog'
AS $function$
#variable_conflict use_column
DECLARE
v_ahr INT; v_cap INT; v_oper INT;
v_sections TEXT[] := ARRAY['plan','approved','q1','q2','q3','q4'];
BEGIN
-- Одна budget_form на лист содержит ОБА направления (budget_line.direction).
-- Support/Development различаются параметром p_direction, а не отдельной формой.
WITH ranked AS (
SELECT bf.id AS fid, ei.sheet AS sh
FROM budget_form bf
JOIN budget_line bl ON bl.budget_form_id = bf.id
JOIN expense_item ei ON ei.id = bl.expense_item_id
WHERE bf.year = p_year AND bf.org_unit_id = p_org_unit_id AND bf.form_type_code = 'FORM_1'
GROUP BY bf.id, ei.sheet
)
SELECT
MAX(CASE WHEN sh='AHR' THEN fid END),
MAX(CASE WHEN sh='CAP' THEN fid END),
MAX(CASE WHEN sh='OPER' THEN fid END)
INTO v_ahr, v_cap, v_oper
FROM ranked;
RETURN QUERY
WITH
ahr_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
ahr_d AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_d AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
oper_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_oper, 'OPER', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
merged AS (
SELECT COALESCE(s.row_type, d.row_type) AS row_type,
COALESCE(s.depth, d.depth) AS depth,
COALESCE(s.sc, d.sc) AS sc,
COALESCE(s.nm, d.nm) AS nm,
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
d.pq1 AS d_pq1, d.pq2 AS d_pq2, d.pq3 AS d_pq3, d.pq4 AS d_pq4, d.pyr AS d_pyr,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
d.aq1 AS d_aq1, d.aq2 AS d_aq2, d.aq3 AS d_aq3, d.aq4 AS d_aq4, d.ayr AS d_ayr,
s.fq1, s.fq2, s.fq3, s.fq4,
d.fq1 AS d_fq1, d.fq2 AS d_fq2, d.fq3 AS d_fq3, d.fq4 AS d_fq4,
s.np2, s.np3, s.np4,
d.np2 AS d_np2, d.np3 AS d_np3, d.np4 AS d_np4
FROM ahr_s s FULL OUTER JOIN ahr_d d ON s.sc = d.sc
UNION ALL
SELECT COALESCE(s.row_type, d.row_type), COALESCE(s.depth, d.depth),
COALESCE(s.sc, d.sc), COALESCE(s.nm, d.nm),
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
d.pq1, d.pq2, d.pq3, d.pq4, d.pyr,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
d.aq1, d.aq2, d.aq3, d.aq4, d.ayr,
s.fq1, s.fq2, s.fq3, s.fq4,
d.fq1, d.fq2, d.fq3, d.fq4,
s.np2, s.np3, s.np4,
d.np2, d.np3, d.np4
FROM cap_s s FULL OUTER JOIN cap_d d ON s.sc = d.sc
UNION ALL
SELECT s.row_type, s.depth, s.sc, s.nm,
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.fq1, s.fq2, s.fq3, s.fq4,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.np2, s.np3, s.np4,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC
FROM oper_s s
)
SELECT
m.row_type, m.depth, m.sc, m.nm,
m.pq1, m.pq2, m.pq3, m.pq4, m.pyr,
m.d_pq1, m.d_pq2, m.d_pq3, m.d_pq4, m.d_pyr,
COALESCE(m.pyr,0) + COALESCE(m.d_pyr,0) AS total_plan_year,
m.aq1, m.aq2, m.aq3, m.aq4, m.ayr,
m.d_aq1, m.d_aq2, m.d_aq3, m.d_aq4, m.d_ayr,
COALESCE(m.ayr,0) + COALESCE(m.d_ayr,0) AS total_appr_year,
m.fq1, m.fq2, m.fq3, m.fq4, COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0),
m.d_fq1, m.d_fq2, m.d_fq3, m.d_fq4, COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0)
+ COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
m.np2, m.np3, m.np4,
m.d_np2, m.d_np3, m.d_np4
FROM merged m
ORDER BY m.sc;
END;
$function$
;
""")
for t_name in (
"reserve", "contract_detail", "ckk", "allocation", "sequestration", "contract_summary",
"collegial_approval", "rent_detail", "utility_detail", "plan", "budget_line_quarter"
):
op.execute(f"""
delete from v3.{t_name} where line_id in (
select id from v3.budget_line where budget_form_id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui));
""")
op.execute("""
delete from v3.budget_line where budget_form_id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui);
""")
op.execute("""
delete from v3.budget_form where id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui);
""")
op.execute("ALTER TABLE v3.budget_form ADD CONSTRAINT uq_ftype_org_year UNIQUE (form_type_code, org_unit_id, year)")
def downgrade() -> None:
pass

View File

@ -1123,9 +1123,9 @@ END;
$function$ $function$
; ;
-- DROP FUNCTION v3.add_vsp(int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, date); -- DROP FUNCTION v3.add_vsp(int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, varchar, date);
CREATE OR REPLACE FUNCTION v3.add_vsp(p_branch_id integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT true, p_is_deleted boolean DEFAULT false, p_system_code character varying DEFAULT NULL::character varying, p_created_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date) CREATE OR REPLACE FUNCTION v3.add_vsp(p_branch_id integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT true, p_is_deleted boolean DEFAULT false, p_system_code character varying DEFAULT NULL::character varying, p_created_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_location_form character varying DEFAULT NULL::character varying, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
RETURNS integer RETURNS integer
LANGUAGE plpgsql LANGUAGE plpgsql
AS $function$ AS $function$
@ -1153,12 +1153,12 @@ BEGIN
(branch_id, reg_number, address, format, opened_at, (branch_id, reg_number, address, format, opened_at,
placement_type, staff_count, total_area, closed_at, placement_type, staff_count, total_area, closed_at,
is_active, is_deleted, system_code, created_by, is_active, is_deleted, system_code, created_by,
vsp_type, notes, rent_contract_num, rent_end_date) vsp_type, notes, location_form, rent_contract_num, rent_end_date)
VALUES VALUES
(p_branch_id, p_reg_number, p_address, p_format, p_opened_at, (p_branch_id, p_reg_number, p_address, p_format, p_opened_at,
p_placement_type, p_staff_count, p_total_area, p_closed_at, p_placement_type, p_staff_count, p_total_area, p_closed_at,
p_is_active, p_is_deleted, p_system_code, p_created_by, p_is_active, p_is_deleted, p_system_code, p_created_by,
p_vsp_type, p_notes, p_rent_contract_num, p_rent_end_date) p_vsp_type, p_notes, p_location_form, p_rent_contract_num, p_rent_end_date)
RETURNING id INTO v_id; RETURNING id INTO v_id;
PERFORM v3.log_event( PERFORM v3.log_event(
@ -1182,6 +1182,7 @@ BEGIN
'created_by', p_created_by, 'created_by', p_created_by,
'vsp_type', p_vsp_type, 'vsp_type', p_vsp_type,
'notes', p_notes, 'notes', p_notes,
'location_form', p_location_form,
'rent_contract_num', p_rent_contract_num, 'rent_contract_num', p_rent_contract_num,
'rent_end_date', v3._diff_val(p_rent_end_date), 'rent_end_date', v3._diff_val(p_rent_end_date),
'user_email', v_actor_email, 'user_email', v_actor_email,
@ -2746,9 +2747,9 @@ END;
$function$ $function$
; ;
-- DROP FUNCTION v3.upd_vsp(int4, int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, date); -- DROP FUNCTION v3.upd_vsp(int4, int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, varchar, date);
CREATE OR REPLACE FUNCTION v3.upd_vsp(p_vsp_id integer, p_branch_id integer DEFAULT NULL::integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT NULL::boolean, p_is_deleted boolean DEFAULT NULL::boolean, p_system_code character varying DEFAULT NULL::character varying, p_updated_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date) CREATE OR REPLACE FUNCTION v3.upd_vsp(p_vsp_id integer, p_branch_id integer DEFAULT NULL::integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT NULL::boolean, p_is_deleted boolean DEFAULT NULL::boolean, p_system_code character varying DEFAULT NULL::character varying, p_updated_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_location_form character varying DEFAULT NULL::character varying, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
RETURNS void RETURNS void
LANGUAGE plpgsql LANGUAGE plpgsql
AS $function$ AS $function$
@ -2766,7 +2767,7 @@ BEGIN
SELECT v.branch_id, v.reg_number, v.address, v.format, v.opened_at, SELECT v.branch_id, v.reg_number, v.address, v.format, v.opened_at,
v.placement_type, v.staff_count, v.total_area, v.closed_at, v.placement_type, v.staff_count, v.total_area, v.closed_at,
v.is_active, v.is_deleted, v.system_code, v.is_active, v.is_deleted, v.system_code,
v.vsp_type, v.notes, v.rent_contract_num, v.rent_end_date v.vsp_type, v.notes, v.location_form, v.rent_contract_num, v.rent_end_date
INTO v_old INTO v_old
FROM v3.vsp v FROM v3.vsp v
WHERE v.id = p_vsp_id; WHERE v.id = p_vsp_id;
@ -2899,6 +2900,13 @@ BEGIN
'after', v3._diff_val(p_notes))); 'after', v3._diff_val(p_notes)));
END IF; END IF;
IF p_location_form IS NOT NULL AND p_location_form IS DISTINCT FROM v_old.location_form THEN
UPDATE v3.vsp SET location_form = p_location_form WHERE id = p_vsp_id;
v_changes := v_changes || jsonb_build_object('location_form',
jsonb_build_object('before', v3._diff_val(v_old.location_form),
'after', v3._diff_val(p_location_form)));
END IF;
IF p_rent_contract_num IS NOT NULL AND p_rent_contract_num IS DISTINCT FROM v_old.rent_contract_num THEN IF p_rent_contract_num IS NOT NULL AND p_rent_contract_num IS DISTINCT FROM v_old.rent_contract_num THEN
UPDATE v3.vsp SET rent_contract_num = p_rent_contract_num WHERE id = p_vsp_id; UPDATE v3.vsp SET rent_contract_num = p_rent_contract_num WHERE id = p_vsp_id;
v_changes := v_changes || jsonb_build_object('rent_contract_num', v_changes := v_changes || jsonb_build_object('rent_contract_num',
@ -7772,407 +7780,4 @@ BEGIN
END; END;
$function$ $function$
; ;
CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id INT)
RETURNS INTEGER
AS $function$
DECLARE
v_report_type VARCHAR;
v_inserted INTEGER;
BEGIN
SELECT report_type INTO v_report_type
FROM v3.rf_project_report
WHERE id = p_report_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
INSERT INTO v3.form3_phase
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_report_id, pt.phase_code, pt.role,
pt.column_keys, pt.opens_at, pt.closes_at
FROM v3.phase_template pt
WHERE pt.form_type = 'FORM_3'
AND pt.sheet = v_report_type
ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
LANGUAGE plpgsql VOLATILE;
CREATE OR REPLACE FUNCTION v3.user_in_report_org(p_user_id INT, p_report_id INT)
RETURNS BOOLEAN
AS $function$
DECLARE
v_org INT;
BEGIN
SELECT p.org_unit_id
INTO v_org
FROM v3.rf_project_report r
JOIN v3.project p ON p.id = r.project_id
WHERE r.id = p_report_id;
IF v_org IS NULL THEN
RETURN TRUE; -- проект без org_unit — пропускаем фильтр
END IF;
RETURN EXISTS (
SELECT 1
FROM v3.user_org
WHERE user_id = p_user_id
AND org_unit_id = v_org
);
END;
$function$
LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION v3.editable_columns_for3(
p_report_id INT,
p_user_id INT
)
RETURNS TABLE (
column_key TEXT,
closes_at TIMESTAMPTZ
)
AS $function$
DECLARE
v_role VARCHAR;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN; -- неизвестный/неактивный юзер → пустая маска
END IF;
-- ADMIN: UNION всех column_keys всех фаз отчёта.
IF v_role = 'ADMIN' THEN
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form3_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.rf_project_report_id = p_report_id
GROUP BY ck;
RETURN;
END IF;
-- EXECUTOR_RF без назначения → пустая маска (default deny).
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN
RETURN;
END IF;
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form3_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND now() BETWEEN fp.opens_at AND fp.closes_at
GROUP BY ck;
END;
$function$
LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION v3.can_edit3(
p_report_id INT,
p_column_key TEXT,
p_user_id INT
)
RETURNS JSONB
AS $function$
DECLARE
v_role VARCHAR;
v_active_close TIMESTAMPTZ;
v_role_has_col BOOLEAN;
v_next_open TIMESTAMPTZ;
v_last_close TIMESTAMPTZ;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', 'unknown or inactive user'
);
END IF;
-- ADMIN — bypass всех проверок.
IF v_role = 'ADMIN' THEN
RETURN jsonb_build_object('ok', true, 'admin', true);
END IF;
-- EXECUTOR_RF: фильтр по user_org проекта отчёта.
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'org_not_assigned',
'detail', p_column_key
);
END IF;
-- 1. Активная сейчас фаза, в которой эта колонка перечислена для этой роли?
SELECT MAX(fp.closes_at)
INTO v_active_close
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND now() BETWEEN fp.opens_at AND fp.closes_at;
IF v_active_close IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', true,
'closes_at', v_active_close
);
END IF;
-- 2. Колонка В ПРИНЦИПЕ есть хоть в одной фазе этой роли на этом отчёте?
SELECT EXISTS (
SELECT 1
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
) INTO v_role_has_col;
IF NOT v_role_has_col THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', p_column_key
);
END IF;
-- 3. Колонка доступна, но окно не активно. Подсказать ближайшее.
SELECT MIN(fp.opens_at)
INTO v_next_open
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.opens_at > now();
IF v_next_open IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'opens_at', v_next_open
);
END IF;
SELECT MAX(fp.closes_at)
INTO v_last_close
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.closes_at <= now();
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'closes_at', v_last_close
);
END;
$function$
LANGUAGE plpgsql STABLE;
-- ─── 3. Authz-обёртки upd_form3_cell/cells (доп. сигнатуры с p_user_id) ─────
CREATE OR REPLACE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_column TEXT;
v_decision JSONB;
v_msg TEXT;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'role_not_allowed: <missing user_id>';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id AND is_active) THEN
RAISE EXCEPTION 'role_not_allowed: unknown or inactive user %', p_user_id;
END IF;
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_column := v_change->>'column';
IF v_column IS NULL THEN
RAISE EXCEPTION 'change must have column: %', v_change;
END IF;
v_decision := v3.can_edit3(p_report_id, v_column, p_user_id);
IF NOT (v_decision->>'ok')::boolean THEN
IF v_decision->>'code' = 'role_not_allowed' THEN
RAISE EXCEPTION 'role_not_allowed: %', v_column;
ELSIF v_decision->>'code' = 'org_not_assigned' THEN
RAISE EXCEPTION 'org_not_assigned: %', v_column;
ELSIF v_decision->>'code' = 'window_closed' THEN
IF v_decision ? 'opens_at' THEN
v_msg := format('window_closed: %s (opens %s)',
v_column, v_decision->>'opens_at');
ELSIF v_decision->>'closes_at' IS NOT NULL THEN
v_msg := format('window_closed: %s (closed %s)',
v_column, v_decision->>'closes_at');
ELSE
v_msg := format('window_closed: %s (no phase)', v_column);
END IF;
RAISE EXCEPTION '%', v_msg;
ELSE
RAISE EXCEPTION '%: %', v_decision->>'code', v_column;
END IF;
END IF;
END LOOP;
RETURN QUERY
SELECT * FROM v3.upd_form3_cells(p_report_id, p_changes);
END;
$function$
LANGUAGE plpgsql VOLATILE;
CREATE OR REPLACE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
)),
p_user_id
);
$function$
LANGUAGE sql VOLATILE;
-- ─── 4. add_project — раскладка snapshot обоих отчётов ──────────────────────
CREATE OR REPLACE FUNCTION v3.add_project(
p_name VARCHAR,
p_year INT,
p_org_unit_id INT,
p_level VARCHAR DEFAULT 'project',
p_parent_id INT DEFAULT NULL,
p_project_type VARCHAR DEFAULT NULL,
p_vsp_format VARCHAR DEFAULT NULL,
p_placement_type VARCHAR DEFAULT NULL,
p_object_address VARCHAR DEFAULT NULL,
p_staff_count INT DEFAULT NULL,
p_total_area NUMERIC DEFAULT NULL
)
RETURNS TABLE (
project_id INT,
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_pid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF p_org_unit_id IS NULL THEN
RAISE EXCEPTION 'p_org_unit_id обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN
RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id;
END IF;
IF p_parent_id IS NOT NULL AND
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
END IF;
INSERT INTO v3.project (
name, level, parent_id, org_unit_id,
project_type, vsp_format, placement_type, object_address, staff_count, total_area
) VALUES (
p_name, p_level, p_parent_id, p_org_unit_id,
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area
) RETURNING id INTO v_pid;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (v_pid, p_year, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (v_pid, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
-- Snapshot RBAC-фаз (иначе default deny — отчёт read-only).
PERFORM v3.copy_template_to_report(v_lim);
PERFORM v3.copy_template_to_report(v_cur);
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT_CREATE',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'year', p_year,
'level', p_level,
'parent_id', p_parent_id,
'org_unit_id', p_org_unit_id,
'project_type', p_project_type,
'vsp_format', p_vsp_format,
'placement_type', p_placement_type,
'object_address', p_object_address,
'staff_count', p_staff_count,
'total_area', p_total_area,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
))
);
RETURN QUERY SELECT v_pid, v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;

View File

@ -4250,29 +4250,4 @@ INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES
INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES
(798,'FORM_3'); (798,'FORM_3');
REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree; REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree;
-- ─── 5. Seed фаз FORM_3 (идемпотентно, только FORM_3) ──────────────────────
-- DELETE скоупится на FORM_3 → фазы FORM_1/2/4 не затрагиваются.
DELETE FROM v3.phase_template WHERE form_type = 'FORM_3';
INSERT INTO v3.phase_template
(form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT 'FORM_3', sh, 'RF_FILL_2026', 'EXECUTOR_RF',
ARRAY[
'q1.adj_by_items','q1.adj_increase','q1.m1','q1.m2','q1.m3',
'q2.adj_by_items','q2.adj_increase','q2.m1','q2.m2','q2.m3',
'q3.adj_by_items','q3.adj_increase','q3.m1','q3.m2','q3.m3',
'q4.adj_by_items','q4.adj_increase','q4.m1','q4.m2','q4.m3','q4.spod'
],
'2026-01-01 00:00:00+00'::timestamptz,
'2026-12-31 23:59:59+00'::timestamptz
FROM (VALUES ('LIMIT'), ('CURRENT_EXPENSES')) AS s(sh);
-- ─── 6. Backfill v3.form3_phase для существующих отчётов ────────────────────
-- ON CONFLICT DO NOTHING внутри copy_template_to_report → повторный прогон
-- ничего не дублирует.
SELECT id, v3.copy_template_to_report(id) AS phases_inserted
FROM v3.rf_project_report
ORDER BY id;

File diff suppressed because it is too large Load Diff

View File

@ -14,4 +14,3 @@ aiosqlite==0.20.0
asyncpg==0.30.0 asyncpg==0.30.0
pytest==8.3.2 pytest==8.3.2
pytest-asyncio==0.24.0 pytest-asyncio==0.24.0
openpyxl==3.1.5

View File

@ -1,9 +1,6 @@
import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.db.models.app_user import AppUser from src.db.models.app_user import AppUser
from src.domain.schemas import ( from src.domain.schemas import (
BaseListResponse, BaseListResponse,
@ -23,18 +20,6 @@ from src.db.session import get_db
router = APIRouter(prefix="/stages", tags=["stages"]) router = APIRouter(prefix="/stages", tags=["stages"])
def timezone_from_offset(offset: str) -> datetime.timezone | None:
if not offset:
return None
hours = int(offset[1:])
return datetime.timezone(
datetime.timedelta(
hours=hours if offset[0] == '+' else -hours
)
)
@router.get("/form/{form_id}") @router.get("/form/{form_id}")
async def get_form_phases( async def get_form_phases(
form_id: int, form_id: int,
@ -44,32 +29,14 @@ async def get_form_phases(
current_user: AppUser = Depends(get_current_active_user_with_set_db), current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[FormPhaseResponse]: ) -> BaseListResponse[FormPhaseResponse]:
fp_service = FormPhaseService(db) fp_service = FormPhaseService(db)
org_unit_service = OrgUnitService(db)
bf_service = BudgetFormService(db)
phases = await fp_service.get_list( phases = await fp_service.get_list(
budget_form_id=form_id, budget_form_id=form_id,
user=current_user, user=current_user,
sheet=sheet, sheet=sheet,
phase_code=phase_code, phase_code=phase_code,
) )
if phases:
form = await bf_service.get(user=current_user, budget_form_id=form_id)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = [FormPhaseResponse.model_validate(p) for p in phases]
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
for element in result:
if element.opens_at:
element.opens_at = element.opens_at.astimezone(tz)
if element.closes_at:
element.closes_at = element.closes_at.astimezone(tz)
else:
result = []
for element in result:
pass
return BaseListResponse( return BaseListResponse(
result=result, result=[FormPhaseResponse.model_validate(p) for p in phases],
count=len(phases), count=len(phases),
) )
@ -85,9 +52,6 @@ async def create_form_phase(
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True) form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form: if not form:
raise HTTPException(404, "Форма не найдена") raise HTTPException(404, "Форма не найдена")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
fp_service = FormPhaseService(db) fp_service = FormPhaseService(db)
phase = await fp_service.create( phase = await fp_service.create(
@ -95,16 +59,7 @@ async def create_form_phase(
body=body, body=body,
user=current_user, user=current_user,
) )
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.patch("/form/{form_id}/{sheet}/{phase_code}") @router.patch("/form/{form_id}/{sheet}/{phase_code}")
@ -129,21 +84,9 @@ async def update_form_phase(
body=body, body=body,
user=current_user, user=current_user,
) )
if not phase: if not phase:
raise HTTPException(404, "Этап не найден") raise HTTPException(404, "Этап не найден")
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.delete("/form/{form_id}/{sheet}/{phase_code}") @router.delete("/form/{form_id}/{sheet}/{phase_code}")

View File

@ -441,7 +441,7 @@ async def add_form(
) )
raise HTTPException(404, f"ССП {not_found_ids} не найдены") raise HTTPException(404, f"ССП {not_found_ids} не найдены")
new_forms = await bf_service.bulk_create_if_not_exists( new_forms = await bf_service.bulk_create(
form_type_code=form.form_type_code, form_type_code=form.form_type_code,
year=form.year, year=form.year,
org_unit_ids=org_unit_ids, org_unit_ids=org_unit_ids,

View File

@ -75,7 +75,6 @@ async def create_ssp(
org_unit = await org_unit_service.create( org_unit = await org_unit_service.create(
title=org_data.title, title=org_data.title,
is_ssp=org_data.is_ssp, is_ssp=org_data.is_ssp,
utc_offset=org_data.utc_offset,
user=current_user, user=current_user,
) )
return BaseSingleResponse( return BaseSingleResponse(

View File

@ -27,7 +27,7 @@ async def get_vsp(
ssp_id: int | None = None, ssp_id: int | None = None,
open_date_start: date | None = None, open_date_start: date | None = None,
open_date_end: date | None = None, open_date_end: date | None = None,
placement_type: str | None = None, location_form: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -42,7 +42,7 @@ async def get_vsp(
ssp_id=ssp_id, ssp_id=ssp_id,
open_date_start=open_date_start, open_date_start=open_date_start,
open_date_end=open_date_end, open_date_end=open_date_end,
placement_type=placement_type, location_form=location_form,
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
load_org_unit=True, load_org_unit=True,
@ -89,7 +89,7 @@ async def export_info(
ssp_ids=body.ssp_ids, ssp_ids=body.ssp_ids,
open_date_start=body.open_date_start, open_date_start=body.open_date_start,
open_date_end=body.open_date_end, open_date_end=body.open_date_end,
placement_type=body.placement_type, location_form=body.location_form,
staff_count_min=body.staff_count_min, staff_count_min=body.staff_count_min,
staff_count_max=body.staff_count_max, staff_count_max=body.staff_count_max,
) )

View File

@ -14,8 +14,6 @@ from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.services.rf_project_report_service import RfProjectReportService
from src.services.rf_project_report_line_service import RfProjectReportLineService
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
from src.services.budget_line_service import BudgetLineService from src.services.budget_line_service import BudgetLineService
from src.api.v1.deps import get_user_by_token from src.api.v1.deps import get_user_by_token
@ -272,7 +270,7 @@ class ConnectionManager:
for lock_key in lock_keys: for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None) self.cell_locks.pop(lock_key, None)
def is_cell_available_for_edit( def is_locked_by_other(
self, self,
con_key: ConnectionKeyEnum, con_key: ConnectionKeyEnum,
user_id: int, user_id: int,
@ -280,10 +278,10 @@ class ConnectionManager:
form_key, form_key,
) -> bool: ) -> bool:
if not cell_key: if not cell_key:
return True return False
cell_key = frozenset(cell_key.items()) cell_key = frozenset(cell_key.items())
lock_owner_id = self.cell_locks.get((con_key, frozenset(form_key.items()), cell_key)) lock_owner_id = self.cell_locks.get((con_key, frozenset(form_key.items()), cell_key))
return lock_owner_id is not None and lock_owner_id == user_id return lock_owner_id is not None and lock_owner_id != user_id
def is_row_locked_by_other( def is_row_locked_by_other(
self, self,
@ -492,8 +490,7 @@ class FormEventProcess:
class ProjectEventProcess: class ProjectEventProcess:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.project_service: ProjectService = ProjectService(db) self.project_service: ProjectService = ProjectService(db)
self.prl_service: RfProjectReportLineService = RfProjectReportLineService(db) self.bf_service: BudgetFormService = BudgetFormService(db)
self.pr_service: RfProjectReportService = RfProjectReportService(db)
self.user_service: UserService = UserService(db) self.user_service: UserService = UserService(db)
async def process( async def process(
@ -576,16 +573,11 @@ class ProjectEventProcess:
expense_item_id: Optional[int] = None expense_item_id: Optional[int] = None
} }
""" """
project_report = await self.pr_service.get( project = await self.project_service.get(project_id=project_id, user=user)
year=year, if not project:
report_type=report_type,
project_id=project_id,
)
if not project_report:
return None return None
ids = await self.prl_service.get_ids(project_report_id=project_report.id) return await self.project_service.add_form3_line(
result = await self.project_service.add_form3_line(
project_id=project_id, project_id=project_id,
year=year, year=year,
report_type=report_type, report_type=report_type,
@ -593,16 +585,6 @@ class ProjectEventProcess:
user=user, user=user,
) )
final_result = {
"data": result,
"new_line_id": None,
}
for el in result:
if el[3]["line_id"] and el[3]["line_id"] not in ids:
final_result["new_line_id"] = el[3]["line_id"]
return final_result
return final_result
async def __del_row( async def __del_row(
self, self,
event_data: dict, event_data: dict,
@ -613,7 +595,7 @@ class ProjectEventProcess:
) -> list[tuple]: ) -> list[tuple]:
""" """
data = { data = {
"row_id": int, "line_id": int,
} }
""" """
project = await self.project_service.get(project_id=project_id, user=user) project = await self.project_service.get(project_id=project_id, user=user)
@ -624,7 +606,7 @@ class ProjectEventProcess:
project_id=project_id, project_id=project_id,
year=year, year=year,
report_type=report_type, report_type=report_type,
line_id=event_data["row_id"], line_id=event_data["line_id"],
user=user, user=user,
) )
@ -662,7 +644,7 @@ def resolve_cell_key(event_data: dict) -> dict | None:
if not data or "line_id" not in data or "column" not in data: if not data or "line_id" not in data or "column" not in data:
return None return None
return { return {
"line_id": data.get("line_id_code", data["line_id"]), "line_id": data["line_id"],
"column": data["column"], "column": data["column"],
} }
@ -750,7 +732,7 @@ async def process_websocket(
) )
continue continue
case "cell_updated": case "cell_updated":
if not manager.is_cell_available_for_edit( if manager.is_locked_by_other(
con_key=con_key, con_key=con_key,
user_id=user_id, user_id=user_id,
cell_key=cell_key, cell_key=cell_key,
@ -762,18 +744,18 @@ async def process_websocket(
websocket, websocket,
) )
continue continue
# if cell_key and not manager.acquire_cell_lock( if cell_key and not manager.acquire_cell_lock(
# con_key=con_key, con_key=con_key,
# user_id=user_id, user_id=user_id,
# cell_key=cell_key, cell_key=cell_key,
# form_key=kwargs_process, form_key=kwargs_process,
# ): ):
# data["error"] = "Ячейка уже редактируется другим пользователем" data["error"] = "Ячейка уже редактируется другим пользователем"
# await manager.send_back( await manager.send_back(
# data, data,
# websocket, websocket,
# ) )
# continue continue
case "row_deleted": case "row_deleted":
if manager.is_row_locked_by_other( if manager.is_row_locked_by_other(
con_key=con_key, con_key=con_key,
@ -794,7 +776,6 @@ async def process_websocket(
async with get_db_session(user_id=user_id) as db: async with get_db_session(user_id=user_id) as db:
processor = processor_cls(db) processor = processor_cls(db)
t0 = time.perf_counter() t0 = time.perf_counter()
data_old = data.copy()
data["result"] = await processor.process( data["result"] = await processor.process(
event_data=data, event_data=data,
user_id=user_id, user_id=user_id,
@ -808,7 +789,7 @@ async def process_websocket(
"time_ms": f"{db_ms:.2f}", "time_ms": f"{db_ms:.2f}",
"event": data.get("event"), "event": data.get("event"),
} }
extra.update(data_old) extra.update(data)
logger.info( logger.info(
f"db_time: {extra}", f"db_time: {extra}",
extra=extra, extra=extra,

View File

@ -10,7 +10,6 @@ from src.db.models.collegial_approval import CollegialApproval
from src.db.models.contract_detail import ContractDetail from src.db.models.contract_detail import ContractDetail
from src.db.models.contract_summary import ContractSummary from src.db.models.contract_summary import ContractSummary
from src.db.models.expense_item import ExpenseItem from src.db.models.expense_item import ExpenseItem
from src.db.models.form3_phase import Form3Phase
from src.db.models.form_limit import FormLimit from src.db.models.form_limit import FormLimit
from src.db.models.form_phase import FormPhase from src.db.models.form_phase import FormPhase
from src.db.models.form_type import FormType from src.db.models.form_type import FormType

View File

@ -1,27 +0,0 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class Form3Phase(Base):
__tablename__ = "form3_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v3_form3_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v3_form3_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v3_form3_phase_window"),
Index("ix_v3_form3_phase_active", "rf_project_report_id", "role", "opens_at", "closes_at"),
Index("ix_v3_form3_phase_columns_gin", "column_keys", postgresql_using="gin"),
{"schema": "v3"},
)
rf_project_report_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report.id", ondelete="CASCADE"), primary_key=True
)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -21,7 +21,6 @@ class OrgUnit(Base):
title: Mapped[str] = mapped_column(String) title: Mapped[str] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_ssp: Mapped[bool] = mapped_column(Boolean) is_ssp: Mapped[bool] = mapped_column(Boolean)
utc_offset: Mapped[str] = mapped_column(String(3), default="+03")
@property @property
def users_count(self) -> int | None: def users_count(self) -> int | None:

View File

@ -22,8 +22,8 @@ class PhaseTemplate(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True) phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False)) closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
# form_type_rel = relationship("FormType", foreign_keys=[form_type]) # form_type_rel = relationship("FormType", foreign_keys=[form_type])
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates") # role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")

View File

@ -45,6 +45,7 @@ class Vsp(Base):
) )
vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True) vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
location_form: Mapped[str | None] = mapped_column(String, nullable=True)
rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True) rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True)
rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True) rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True)

View File

@ -235,14 +235,12 @@ class OrgUnitBaseSchema(BaseModel):
class OrgUnitCreateSchema(OrgUnitBaseSchema): class OrgUnitCreateSchema(OrgUnitBaseSchema):
title: str = Field(..., min_length=1) title: str = Field(..., min_length=1)
utc_offset: str = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
class OrgUnitUpdateSchema(OrgUnitBaseSchema): class OrgUnitUpdateSchema(OrgUnitBaseSchema):
title: Optional[str] = None title: Optional[str] = None
is_ssp: Optional[bool] = None is_ssp: Optional[bool] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
utc_offset: Optional[str] = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -250,7 +248,6 @@ class OrgUnitUpdateSchema(OrgUnitBaseSchema):
class OrgUnitResponseSchema(OrgUnitBaseSchema): class OrgUnitResponseSchema(OrgUnitBaseSchema):
id: int id: int
is_active: bool is_active: bool
utc_offset: str
users_count: int | None = None users_count: int | None = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -268,7 +265,6 @@ class OrgUnitResponseSchema(OrgUnitBaseSchema):
"is_ssp": data.is_ssp, "is_ssp": data.is_ssp,
"is_ssp": data.is_ssp, "is_ssp": data.is_ssp,
"is_active": data.is_active, "is_active": data.is_active,
"utc_offset": data.utc_offset
} }
try: try:
result["users_count"] = data.users_count result["users_count"] = data.users_count
@ -366,7 +362,7 @@ class AddProjectBody(BaseModel):
max_length=30, max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$", pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
) )
year: int = Field(default_factory=lambda: datetime.now().year) year: int
branch_id: int branch_id: int
level: Literal["project", "program"] = "project" level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None parent_id: Optional[int] = None
@ -378,10 +374,6 @@ class AddProjectBody(BaseModel):
staff_count: Optional[int] = None staff_count: Optional[int] = None
total_area: Optional[float] = None total_area: Optional[float] = None
@field_validator("year", mode="before")
@classmethod
def optional_year(cls, v: Any) -> Any:
return v or datetime.now().year
class AddLineSchema(BaseModel): class AddLineSchema(BaseModel):
expense_item_id: Optional[int] = None expense_item_id: Optional[int] = None
@ -504,6 +496,7 @@ class VSPBase(BaseModel):
notes: Optional[str] = Field(None, description="Примечания") notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения") placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность") staff_count: Optional[int] = Field(None, description="Штатная численность")
location_form: Optional[str] = Field(None, description="Форма расположения")
area: Optional[float] = Field( area: Optional[float] = Field(
None, None,
description="Арендная площадь", description="Арендная площадь",
@ -553,6 +546,7 @@ class VSPEditBase(BaseModel):
notes: Optional[str] = Field(None, description="Примечания") notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения") placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность") staff_count: Optional[int] = Field(None, description="Штатная численность")
location_form: Optional[str] = Field(None, description="Форма расположения")
total_area: Optional[float] = Field( total_area: Optional[float] = Field(
None, None,
description="Арендная площадь", description="Арендная площадь",
@ -623,7 +617,7 @@ class VSPExportRequest(BaseModel):
ssp_ids: Optional[List[int]] = None ssp_ids: Optional[List[int]] = None
open_date_start: Optional[date] = None open_date_start: Optional[date] = None
open_date_end: Optional[date] = None open_date_end: Optional[date] = None
placement_type: Optional[str] = None location_form: Optional[str] = None
staff_count_min: Optional[int] = None staff_count_min: Optional[int] = None
staff_count_max: Optional[int] = None staff_count_max: Optional[int] = None

View File

@ -1,7 +1,6 @@
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
from src.db.models.budget_form import BudgetForm from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -59,34 +58,19 @@ class BudgetFormRepository:
async def get( async def get(
self, self,
budget_form_id: int | None = None, budget_form_id: int,
org_unit: int | list[int] | None = None, org_unit: int | list[int] | None = None,
form_type_code: FormTypeEnum | str | None = None,
year: int | None = None,
load_form_type: bool = False, load_form_type: bool = False,
load_org: bool = False, load_org: bool = False,
) -> BudgetForm | None: ) -> BudgetForm | None:
assert budget_form_id or all((form_type_code, year, org_unit))
query = select(BudgetForm) query = select(BudgetForm)
where = [] where = [BudgetForm.id == budget_form_id]
if budget_form_id is not None:
where.append(BudgetForm.id == budget_form_id)
if org_unit is not None: if org_unit is not None:
if isinstance(org_unit, int): if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit) where.append(BudgetForm.org_unit_id == org_unit)
else: else:
where.append(BudgetForm.org_unit_id.in_(org_unit)) where.append(BudgetForm.org_unit_id.in_(org_unit))
if form_type_code is not None:
if isinstance(form_type_code, FormTypeEnum):
where.append(BudgetForm.form_type_code == form_type_code.value)
else:
where.append(BudgetForm.form_type_code == form_type_code)
if year is not None:
where.append(BudgetForm.year == year)
query = query.where(*where) query = query.where(*where)
if load_org: if load_org:

View File

@ -12,7 +12,7 @@ class BudgetLineRepository:
query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1) query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none() return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, budget_line_ids: list[int]) -> list[BudgetLine]: async def get_list(self, budget_line_ids: list[int]) -> BudgetLine | None:
query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids)) query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids))
return (await self.db.execute(query)).scalars().all() return (await self.db.execute(query)).scalars().all()

View File

@ -55,8 +55,8 @@ class FormPhaseRepository:
:phase_code, :phase_code,
:role, :role,
:column_keys, :column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), :opens_at,
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) :closes_at
)).budget_form_id )).budget_form_id
""" """
), ),
@ -87,8 +87,8 @@ class FormPhaseRepository:
:phase_code, :phase_code,
:role, :role,
:column_keys, :column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), :opens_at,
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) :closes_at
)).budget_form_id )).budget_form_id
""" """
), ),

View File

@ -1,6 +1,6 @@
from sqlalchemy import func, select, text, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit from src.db.models.org_unit import OrgUnit
@ -84,10 +84,9 @@ class OrgUnitRepository:
self, self,
title: str, title: str,
is_ssp: bool, is_ssp: bool,
utc_offset: str,
) -> OrgUnit: ) -> OrgUnit:
"""Создание SSP.""" """Создание SSP."""
ssp = OrgUnit(title=title, is_ssp=is_ssp, utc_offset=utc_offset) ssp = OrgUnit(title=title, is_ssp=is_ssp)
self.db.add(ssp) self.db.add(ssp)
await self.db.flush() await self.db.flush()
# await self.db.refresh(ssp) # await self.db.refresh(ssp)
@ -99,29 +98,10 @@ class OrgUnitRepository:
if not org_unit: if not org_unit:
return None return None
old_offset = org_unit.utc_offset
for field, value in kwargs.items(): for field, value in kwargs.items():
setattr(org_unit, field, value) setattr(org_unit, field, value)
await self.db.flush() await self.db.flush()
if org_unit.utc_offset != old_offset:
await self.db.execute(
text(
"""
SELECT v3.upd_phases_timezones(
:org_unit_id,
:old_tz,
:new_tz
)
"""
),
{
"org_unit_id": org_unit.id,
"old_tz": old_offset,
"new_tz": org_unit.utc_offset,
},
)
# await self.db.refresh(ssp) # await self.db.refresh(ssp)
return org_unit return org_unit

View File

@ -14,7 +14,7 @@ class ProjectRepository:
self.db = db self.db = db
@staticmethod @staticmethod
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int, years: list[InterruptedError] | None = None) -> dict: def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict:
return { return {
"id": project.id, "id": project.id,
"name": project.name, "name": project.name,
@ -29,7 +29,6 @@ class ProjectRepository:
"org_unit_id": project.org_unit_id, "org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name, "org_unit_name": org_unit_name,
"report_count": int(report_count), "report_count": int(report_count),
"years": years,
} }
@staticmethod @staticmethod
@ -67,19 +66,11 @@ class ProjectRepository:
.scalar_subquery() .scalar_subquery()
) )
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = ( query = (
select( select(
Project, Project,
OrgUnit.title.label("org_unit_name"), OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"), report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
) )
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.order_by(Project.id) .order_by(Project.id)
@ -91,7 +82,7 @@ class ProjectRepository:
query = query.limit(limit) query = query.limit(limit)
rows = (await self.db.execute(query)).all() rows = (await self.db.execute(query)).all()
payload = [self._serialize_project(row[0], row[1], row[2], row[3]) for row in rows] payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows]
if not with_count: if not with_count:
return payload return payload
@ -110,18 +101,11 @@ class ProjectRepository:
.where(RfProjectReport.project_id == Project.id) .where(RfProjectReport.project_id == Project.id)
.scalar_subquery() .scalar_subquery()
) )
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = ( query = (
select( select(
Project, Project,
OrgUnit.title.label("org_unit_name"), OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"), report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
) )
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.where(Project.id == project_id) .where(Project.id == project_id)
@ -133,7 +117,7 @@ class ProjectRepository:
row = (await self.db.execute(query)).first() row = (await self.db.execute(query)).first()
if not row: if not row:
return None return None
return self._serialize_project(row[0], row[1], row[2], row[3]) return self._serialize_project(row[0], row[1], row[2])
async def get_reports(self, project_id: int) -> list[dict]: async def get_reports(self, project_id: int) -> list[dict]:
line_count_subq = ( line_count_subq = (

View File

@ -1,39 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report_line import RfProjectReportLine
class RfProjectReportLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, line_id: int) -> RfProjectReportLine | None:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id == line_id
).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, line_ids: list[int]) -> list[RfProjectReportLine]:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id.in_(line_ids)
)
return (await self.db.execute(query)).scalars().all()
async def get_ids(
self,
project_report_id: int,
) -> list[int]:
query = select(
RfProjectReportLine.id
).where(
RfProjectReportLine.rf_project_report_id == project_report_id
)
return (await self.db.execute(query)).scalars().all()

View File

@ -1,33 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report import RfProjectReport
class RfProjectReportRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
) -> RfProjectReport | None:
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
query = select(
RfProjectReport
)
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
else:
query = query.where(
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
RfProjectReport.project_id == project_id
)
query = query.limit(1)
return (await self.db.execute(query)).scalar_one_or_none()

View File

@ -36,7 +36,7 @@ class VSPRepository:
ssp_ids: list[int] | None = None, ssp_ids: list[int] | None = None,
open_date_start: date | None = None, open_date_start: date | None = None,
open_date_end: date | None = None, open_date_end: date | None = None,
placement_type: str | None = None, location_form: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
is_active: bool | None = True, is_active: bool | None = True,
@ -53,7 +53,7 @@ class VSPRepository:
filters = [ filters = [
("registration_number", registration_number), ("registration_number", registration_number),
("address", address), ("address", address),
("placement_type", placement_type), ("location_form", location_form),
("open_date_start", open_date_start), ("open_date_start", open_date_start),
("open_date_end", open_date_end), ("open_date_end", open_date_end),
("staff_count_min", staff_count_min), ("staff_count_min", staff_count_min),
@ -67,8 +67,8 @@ class VSPRepository:
queries.append(Vsp.reg_number.ilike(f"%{value}%")) queries.append(Vsp.reg_number.ilike(f"%{value}%"))
case ("address", str(value)) if value: case ("address", str(value)) if value:
queries.append(Vsp.address.ilike(f"%{value}%")) queries.append(Vsp.address.ilike(f"%{value}%"))
case ("placement_type", str(value)) if value: case ("location_form", str(value)) if value:
queries.append(Vsp.placement_type.ilike(f"%{value}%")) queries.append(Vsp.location_form.ilike(f"%{value}%"))
case ("open_date_start", value) if value is not None: case ("open_date_start", value) if value is not None:
queries.append(Vsp.opened_at >= value) queries.append(Vsp.opened_at >= value)
case ("open_date_end", value) if value is not None: case ("open_date_end", value) if value is not None:
@ -125,7 +125,7 @@ class VSPRepository:
:p_opened_at, :p_placement_type, :p_staff_count, :p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted, :p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_created_by, :p_vsp_type, :p_notes, :p_system_code, :p_created_by, :p_vsp_type, :p_notes,
:p_rent_contract_num, :p_rent_end_date :p_location_form, :p_rent_contract_num, :p_rent_end_date
) )
"""), """),
{ {
@ -144,6 +144,7 @@ class VSPRepository:
"p_created_by": created_by, "p_created_by": created_by,
"p_vsp_type": data.get("vsp_type"), "p_vsp_type": data.get("vsp_type"),
"p_notes": data.get("notes"), "p_notes": data.get("notes"),
"p_location_form": data.get("location_form"),
"p_rent_contract_num": data.get("rent_contract_num"), "p_rent_contract_num": data.get("rent_contract_num"),
"p_rent_end_date": data.get("rent_end_date"), "p_rent_end_date": data.get("rent_end_date"),
} }
@ -177,6 +178,7 @@ class VSPRepository:
"placement_type": "p_placement_type", "placement_type": "p_placement_type",
"staff_count": "p_staff_count", "staff_count": "p_staff_count",
"notes": "p_notes", "notes": "p_notes",
"location_form": "p_location_form",
"total_area": "p_total_area", "total_area": "p_total_area",
"rent_contract_num": "p_rent_contract_num", "rent_contract_num": "p_rent_contract_num",
"rent_end_date": "p_rent_end_date", "rent_end_date": "p_rent_end_date",
@ -196,7 +198,7 @@ class VSPRepository:
:p_format, :p_opened_at, :p_placement_type, :p_staff_count, :p_format, :p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted, :p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_updated_by, :p_vsp_type, :p_notes, :p_system_code, :p_updated_by, :p_vsp_type, :p_notes,
:p_rent_contract_num, :p_rent_end_date :p_location_form, :p_rent_contract_num, :p_rent_end_date
) )
"""), """),
params, params,

View File

@ -79,25 +79,6 @@ class BudgetFormService:
year=year, year=year,
org_unit_id=org_unit_id, org_unit_id=org_unit_id,
) )
async def create_if_not_exists(
self,
form_type_code: str,
year: int,
org_unit_id: int,
) -> BudgetForm | None:
existing_bf = await self.bf_repo.get(
form_type_code=form_type_code,
year=year,
org_unit=org_unit_id,
)
if existing_bf:
return existing_bf
return await self.bf_repo.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
async def bulk_create( async def bulk_create(
self, self,
@ -115,20 +96,3 @@ class BudgetFormService:
for org_unit_id in org_unit_ids for org_unit_id in org_unit_ids
] ]
) )
async def bulk_create_if_not_exists(
self,
form_type_code: str,
year: int,
org_unit_ids: list[int],
) -> BudgetForm | None:
return await asyncio.gather(
*[
self.create_if_not_exists(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
for org_unit_id in org_unit_ids
]
)

View File

@ -54,7 +54,6 @@ class OrgUnitService:
self, self,
title: str, title: str,
is_ssp: bool, is_ssp: bool,
utc_offset: str,
user: AppUser, user: AppUser,
) -> OrgUnit: ) -> OrgUnit:
"""Создание нового ССП/РФ.""" """Создание нового ССП/РФ."""
@ -64,7 +63,6 @@ class OrgUnitService:
return await self.org_repo.create( return await self.org_repo.create(
title=title, title=title,
is_ssp=is_ssp, is_ssp=is_ssp,
utc_offset=utc_offset,
) )
async def update( async def update(

View File

@ -1,34 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository
from src.db.models.app_user import AppUser
class RfProjectReportLineService:
def __init__(self, db: AsyncSession):
self.db = db
self.prl_repo = RfProjectReportLineRepository(db)
async def get(
self,
user: AppUser,
line_id: int,
) -> RfProjectReportLine | None:
return await self.prl_repo.get(line_id=line_id)
async def get_list(
self,
user: AppUser,
line_ids: list[int],
) -> list[RfProjectReportLine]:
return await self.prl_repo.get_list(line_ids=line_ids)
async def get_ids(
self,
project_report_id: int,
) -> list[int]:
return await self.prl_repo.get_ids(project_report_id=project_report_id)

View File

@ -1,31 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report import RfProjectReport
from src.repository.rf_project_report_repository import RfProjectReportRepository
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository
from src.db.models.app_user import AppUser
class RfProjectReportService:
def __init__(self, db: AsyncSession):
self.db = db
self.pr_repo = RfProjectReportRepository(db)
async def get(
self,
user: AppUser| None = None,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
) -> RfProjectReport | None:
return await self.pr_repo.get(
report_id=report_id,
year=year,
report_type=report_type,
project_id=project_id,
)

View File

@ -11,7 +11,6 @@ from src.repository.budget_form_repository import BudgetFormRepository
from src.core.errors import AccessDeniedException, ValidationException from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate from src.domain.schemas import VSPCreate, VSPUpdate
from src.repository.org_unit_repository import OrgUnitRepository
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
from src.repository.vsp_repository import VSPRepository from src.repository.vsp_repository import VSPRepository
@ -21,7 +20,7 @@ _info_close_sync_lock = asyncio.Lock()
class VSPService: class VSPService:
EXECUTOR_EDITABLE_FIELDS = { EXECUTOR_EDITABLE_FIELDS = {
"placement_type", "location_form",
"staff_count", "staff_count",
"total_area", "total_area",
"rent_contract_num", "rent_contract_num",
@ -32,7 +31,6 @@ class VSPService:
self.db = db self.db = db
self.vsp_repo = VSPRepository(db) self.vsp_repo = VSPRepository(db)
self.form_repo = BudgetFormRepository(db) self.form_repo = BudgetFormRepository(db)
self.org_unit_repo = OrgUnitRepository(db)
self.user_repo = UserRepository(db) self.user_repo = UserRepository(db)
async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]: async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]:
@ -56,7 +54,7 @@ class VSPService:
ssp_ids: list[int] | None = None, ssp_ids: list[int] | None = None,
open_date_start: date | None = None, open_date_start: date | None = None,
open_date_end: date | None = None, open_date_end: date | None = None,
placement_type: str | None = None, location_form: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
is_active: bool | None = None, is_active: bool | None = None,
@ -86,7 +84,7 @@ class VSPService:
ssp_ids=effective_ssp_ids, ssp_ids=effective_ssp_ids,
open_date_start=open_date_start, open_date_start=open_date_start,
open_date_end=open_date_end, open_date_end=open_date_end,
placement_type=placement_type, location_form=location_form,
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
is_active=is_active, is_active=is_active,
@ -120,17 +118,16 @@ class VSPService:
else: else:
ssp_ids = list(checking_ssps & set(ssp_ids)) ssp_ids = list(checking_ssps & set(ssp_ids))
await self._sync_closed_records()
return await self.vsp_repo.get_list( return await self.vsp_repo.get_list(
ssp_ids=ssp_ids, ssp_ids=ssp_ids,
is_active=True, is_active=True,
close_date_is_null=True,
with_count=with_count, with_count=with_count,
) )
async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp: async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp:
if not self._can_modify(user): if not self._can_modify(user):
raise AccessDeniedException() raise AccessDeniedException()
await self._ensure_active_branch(payload.branch_id)
try: try:
created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit) created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit)
if created.closed_at is not None and created.closed_at <= date.today(): if created.closed_at is not None and created.closed_at <= date.today():
@ -157,7 +154,7 @@ class VSPService:
ssp_ids: list[int] | None = None, ssp_ids: list[int] | None = None,
open_date_start: date | None = None, open_date_start: date | None = None,
open_date_end: date | None = None, open_date_end: date | None = None,
placement_type: str | None = None, location_form: str | None = None,
staff_count_min: int | None = None, staff_count_min: int | None = None,
staff_count_max: int | None = None, staff_count_max: int | None = None,
) -> tuple[io.BytesIO, str]: ) -> tuple[io.BytesIO, str]:
@ -168,7 +165,7 @@ class VSPService:
ssp_ids=ssp_ids, ssp_ids=ssp_ids,
open_date_start=open_date_start, open_date_start=open_date_start,
open_date_end=open_date_end, open_date_end=open_date_end,
placement_type=placement_type, location_form=location_form,
staff_count_min=staff_count_min, staff_count_min=staff_count_min,
staff_count_max=staff_count_max, staff_count_max=staff_count_max,
is_active=None, is_active=None,
@ -191,7 +188,7 @@ class VSPService:
"open_date", "open_date",
"close_date", "close_date",
"notes", "notes",
"placement_type", "location_form",
"staff_count", "staff_count",
"area", "area",
"rent_contract_num", "rent_contract_num",
@ -218,7 +215,7 @@ class VSPService:
item.opened_at.isoformat() if item.opened_at else None, item.opened_at.isoformat() if item.opened_at else None,
item.closed_at.isoformat() if item.closed_at else None, item.closed_at.isoformat() if item.closed_at else None,
item.notes or None, item.notes or None,
item.placement_type or None, item.location_form or None,
item.staff_count if item.staff_count is not None else None, item.staff_count if item.staff_count is not None else None,
float(item.total_area) if item.total_area is not None else None, float(item.total_area) if item.total_area is not None else None,
item.rent_contract_num or None, item.rent_contract_num or None,
@ -248,9 +245,6 @@ class VSPService:
if not data: if not data:
return vsp return vsp
if "branch_id" in data:
await self._ensure_active_branch(data["branch_id"])
if user.role_id != UserRoleEnum.ADMIN: if user.role_id != UserRoleEnum.ADMIN:
ssp_ids = await self._get_scoped_ssp_ids(user=user) ssp_ids = await self._get_scoped_ssp_ids(user=user)
if not ssp_ids or vsp.branch_id not in set(ssp_ids): if not ssp_ids or vsp.branch_id not in set(ssp_ids):
@ -297,10 +291,3 @@ class VSPService:
return return
await self.vsp_repo.deactivate_by_close_date(as_of=today) await self.vsp_repo.deactivate_by_close_date(as_of=today)
_last_info_close_sync_run = today _last_info_close_sync_run = today
async def _ensure_active_branch(self, branch_id: int | None) -> None:
if branch_id is None:
return
org_unit = await self.org_unit_repo.get(org_unit_id=branch_id)
if org_unit is None or not org_unit.is_active:
raise ValidationException(description="Указан неактивный или отсутствующий ССП/РФ")

View File

@ -84,16 +84,16 @@ def test_form_phases_delete_forbidden(client, isp_tokens):
def test_form_phases_create(client, admin_tokens): def test_form_phases_create(client, admin_tokens):
body = { body = {
"budget_form_id": 4, "budget_form_id": 1,
"sheet": "AHR", "sheet": "AHR",
"phase_code": "new_phase", "phase_code": "new_phase",
"role": "DFIP", "role": "DFIP",
"column_keys": ["plan.q1"], "column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00", "opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00", "closes_at": "2026-12-31T00:00:00Z",
} }
response = client.post( response = client.post(
"/api/v1/stages/form/4", "/api/v1/stages/form/1",
json=body, json=body,
headers=_auth_headers(admin_tokens), headers=_auth_headers(admin_tokens),
) )
@ -102,31 +102,22 @@ def test_form_phases_create(client, admin_tokens):
assert "success" in payload assert "success" in payload
assert payload["success"] assert payload["success"]
assert "result" in payload assert "result" in payload
result = payload["result"] assert isinstance(payload["result"], dict)
assert isinstance(result, dict)
opens_at = result.pop("opens_at", None)
assert "+05" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+05" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
assert result == body
assert payload["result"] == body assert payload["result"] == body
def test_form_phases_update(client, admin_tokens): def test_form_phases_update(client, admin_tokens):
body = { body = {
"budget_form_id": 4, "budget_form_id": 1,
"sheet": "AHR", "sheet": "AHR",
"phase_code": "test", "phase_code": "test",
"role": "EXECUTOR_RF", "role": "EXECUTOR_RF",
"column_keys": ["plan.q1"], "column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00", "opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00", "closes_at": "2026-12-31T00:00:00Z",
} }
response = client.patch( response = client.patch(
"/api/v1/stages/form/4/AHR/test", "/api/v1/stages/form/1/AHR/test",
json=body, json=body,
headers=_auth_headers(admin_tokens), headers=_auth_headers(admin_tokens),
) )
@ -136,15 +127,8 @@ def test_form_phases_update(client, admin_tokens):
assert "success" in payload assert "success" in payload
assert payload["success"] assert payload["success"]
assert "result" in payload assert "result" in payload
result = payload["result"] assert isinstance(payload["result"], dict)
assert isinstance(result, dict) assert payload["result"] == body
opens_at = result.pop("opens_at", None)
assert "+05" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+05" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
assert result == body
def test_form_phases_delete(client, admin_tokens): def test_form_phases_delete(client, admin_tokens):

View File

@ -161,25 +161,6 @@ def test_forms_add_form_success(client, admin_tokens, auth_headers):
assert response.status_code == 200 assert response.status_code == 200
def test_forms_add_form_existing_unit(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
json={"year": 2026, "form_type_code": "FORM_2", "org_unit_ids": [1, 2]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
assert len(payload["result"]) == 2
assert "year" in payload["result"][0]
assert payload["result"][0]["year"] == 2026
assert "id" in payload["result"][1]
assert payload["result"][1]["id"] == 4
def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers): def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers):
response = client.post( response = client.post(
"/api/v1/form/", "/api/v1/form/",

View File

@ -78,47 +78,6 @@ def test_org_units_get_not_found(client, admin_tokens, auth_headers):
# assert payload["result"]["id"] == 1 # assert payload["result"]["id"] == 1
def test_org_units_update_offset_smoke(client, admin_tokens, auth_headers):
old_offset = "+05"
new_offset = "+04"
response = client.get(
"/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_old = phases[0]
response = client.patch(
"/api/v1/org-unit/2",
json={"utc_offset": new_offset},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["id"] == 2
assert payload["result"]["utc_offset"] == new_offset
response = client.get(
"/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_new = phases[0]
assert new_offset in phase_new["opens_at"]
assert new_offset in phase_new["closes_at"]
assert phase_old["opens_at"].split(old_offset)[0] == phase_new["opens_at"].split(new_offset)[0]
assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0]
def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers): def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/org-unit/99999", "/api/v1/org-unit/99999",

View File

@ -48,31 +48,6 @@ def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД") pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_create_project_smoke(client, admin_tokens, auth_headers):
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": None, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "project_id" in payload
assert (project_id := payload["project_id"]) is not None
response = client.get(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "id" in payload["result"]
assert payload["result"]["id"] == project_id
assert "years" in payload["result"]
assert isinstance(payload["result"]["years"], list)
assert len(payload["result"]["years"]) == 1
def test_projects_write_smoke(client, admin_tokens, auth_headers): def test_projects_write_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens, auth_headers) project_id, name = _create_project(client, admin_tokens, auth_headers)

View File

@ -40,10 +40,10 @@ def test_vsp_dropdown_smoke(client, admin_tokens, auth_headers):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"params, count", "params, count",
[ [
([("ssp_id", 2)], 2), ([("ssp_id", 2)], 1),
([("form_id", 1)], 0), ([("form_id", 1)], 0),
([("form_id", 2)], 2), ([("form_id", 2)], 1),
([("ssp_id", 2), ("form_id", 1)], 2), ([("ssp_id", 2), ("form_id", 1)], 1),
] ]
) )
def test_vsp_dropdown_with_filter(client, admin_tokens, auth_headers, params, count): def test_vsp_dropdown_with_filter(client, admin_tokens, auth_headers, params, count):
@ -110,7 +110,7 @@ def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
"close_date": "2025-12-31", "close_date": "2025-12-31",
"approved_format": "Стандарт", "approved_format": "Стандарт",
"notes": "Тестовое примечание", "notes": "Тестовое примечание",
"placement_type": "Аренда", "location_form": "Встроенное",
"staff_count": 10, "staff_count": 10,
"area": 150.5, "area": 150.5,
"rent_contract_num": "Д-123/24", "rent_contract_num": "Д-123/24",
@ -127,7 +127,7 @@ def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
assert payload["result"]["open_date"] == "2024-01-15" assert payload["result"]["open_date"] == "2024-01-15"
assert payload["result"]["close_date"] == "2025-12-31" assert payload["result"]["close_date"] == "2025-12-31"
assert payload["result"]["approved_format"] == "Стандарт" assert payload["result"]["approved_format"] == "Стандарт"
assert payload["result"]["placement_type"] == "Аренда" assert payload["result"]["location_form"] == "Встроенное"
assert payload["result"]["staff_count"] == 10 assert payload["result"]["staff_count"] == 10
assert payload["result"]["area"] == 150.5 assert payload["result"]["area"] == 150.5
assert payload["result"]["rent_contract_num"] == "Д-123/24" assert payload["result"]["rent_contract_num"] == "Д-123/24"
@ -150,36 +150,6 @@ def test_vsp_create_forbidden_for_executor(client, isp_tokens, auth_headers):
assert "message" in payload assert "message" in payload
def test_vsp_create_with_inactive_ssp_fails(client, admin_tokens, auth_headers):
created_org = client.post(
"/api/v1/org-unit/",
json={"title": f"Inactive SSP {uuid.uuid4().hex[:6]}", "is_ssp": True},
headers=auth_headers(admin_tokens),
)
assert created_org.status_code == 200
org_unit_id = created_org.json()["result"]["id"]
deactivate = client.patch(
f"/api/v1/org-unit/{org_unit_id}",
json={"is_active": False},
headers=auth_headers(admin_tokens),
)
assert deactivate.status_code == 200
response = client.post(
"/api/v1/dict/info",
json={
"registration_number": _unique_reg_number(),
"address": "Should Fail",
"ssp_id": org_unit_id,
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert "message" in payload
def test_vsp_update_smoke(client, admin_tokens, auth_headers): def test_vsp_update_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number() reg_number = _unique_reg_number()
created = client.post( created = client.post(
@ -225,33 +195,7 @@ def test_vsp_delete_smoke(client, admin_tokens, auth_headers):
json={ json={
"registration_number": reg_number, "registration_number": reg_number,
"address": "To Be Deleted", "address": "To Be Deleted",
"ssp_id": 2, "ssp_id": 1,
},
headers=auth_headers(admin_tokens),
)
assert created.status_code == 201
vsp_id = created.json()["result"]["id"]
response = client.delete(
f"/api/v1/dict/info/{vsp_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Запись INFO удалена"
def test_vsp_delete_with_close_date_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
created = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "To Be Deleted with close_date",
"ssp_id": 2,
"open_date": "2026-01-01",
"close_date": "2030-01-01",
}, },
headers=auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )

View File

@ -3,9 +3,9 @@ INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_
(2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'); (2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
SELECT setval('v3.app_user_id_seq', 2); SELECT setval('v3.app_user_id_seq', 2);
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES INSERT INTO v3.org_unit (id,title,is_active,is_ssp) VALUES
(1,'Test_SSP',true,true,'+03'), (1,'Test_SSP',true,true),
(2,'Test РФ',true,false,'+05'); (2,'Test РФ',true,false);
SELECT setval('v3.org_unit_id_seq', 2); SELECT setval('v3.org_unit_id_seq', 2);
INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
@ -24,11 +24,9 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or
SELECT setval('v3.budget_line_id_seq', 1); SELECT setval('v3.budget_line_id_seq', 1);
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'), (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03');
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05');
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,location_form,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','',NULL), (1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение','',NULL),
(2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','12-45','2026-10-02'), (2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','аренда','12-45','2026-10-02');
(3,2,'3434','победы 12','формат','2026-06-01','субаренда', NULL,34,NULL,true,'2026-07-01 13:32:35.327097',false,'2026-06-29 15:53:50.236062',91,'3434',NULL,'2','примечание','567','2026-07-01'); SELECT setval('v3.vsp_id_seq', 2);
SELECT setval('v3.vsp_id_seq', 3);

View File

@ -21,7 +21,7 @@ export const DictVspApi = {
responseType: "blob", responseType: "blob",
}); });
}, },
getDropdownVsp(params) { getDropdownVsp(params){
return api.get(`/dict/info/dropdown`, { params }).then((r) => r.data); return api.get(`/dict/info/dropdown`, params).then((r) => r.data);
}, }
}; };

View File

@ -1,34 +0,0 @@
import api from "./client";
export const ProjectsApi = {
list: (params) => {
return api.get(`/projects`, { params }).then((r) => r.data);
},
get: (id) => {
return api.get(`/projects/${id}`).then((r) => r.data);
},
create: (data) => {
return api.post("/projects", data).then((r) => r.data);
},
update: (id, data) => {
return api.patch(`/projects/${id}`, data).then((r) => r.data);
},
delete: (id) => {
return api.delete(`/projects/${id}`).then((r) => r.data);
},
getTableData: (id, sheetName, year) => {
return api
.get(`/projects/${id}/report/${year}/${sheetName}`)
.then((r) => r.data);
},
export: (id, year, sheetName) => {
return api.get(`/export/project/${id}/report/${year}/${sheetName}`, {
responseType: "blob",
});
},
};

View File

@ -17,7 +17,6 @@ import { CircularProgress } from '@mui/material';
import NewTablePage from '../pages/NewTablePage.jsx'; import NewTablePage from '../pages/NewTablePage.jsx';
import TablesTest from '../pages/TablesTest.jsx'; import TablesTest from '../pages/TablesTest.jsx';
import NewFormTablePage from '../pages/NewFormTablePage.jsx' import NewFormTablePage from '../pages/NewFormTablePage.jsx'
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx';
export const PrivateRoute = () => { export const PrivateRoute = () => {
const { isAuthenticated, loading } = useAuth(); const { isAuthenticated, loading } = useAuth();
@ -38,10 +37,8 @@ export const AppRoutes = () => {
<Route element={<PrivateRoute />}> <Route element={<PrivateRoute />}>
<Route path="/" element={<TasksPage />} /> <Route path="/" element={<TasksPage />} />
<Route path="/tasks" element={<TasksPage />} /> <Route path="/tasks" element={<TasksPage />} />
<Route path="/projects" element={<ProjectsPage />}/>
<Route path="/forms" element={<FormsPage />} /> <Route path="/forms" element={<FormsPage />} />
<Route path="/task/:taskId" element={<TaskPage />} /> <Route path="/task/:taskId" element={<TaskPage />} />
<Route path="/project/:projectId" element={<TaskPage />} />
<Route path="/table/:tableId" element={<TablePage />} /> <Route path="/table/:tableId" element={<TablePage />} />
<Route path="/table-temp/:tableId" element={<TableTempPage />} /> <Route path="/table-temp/:tableId" element={<TableTempPage />} />
<Route path="/admin_panel/users" element={<UsersPage />} /> <Route path="/admin_panel/users" element={<UsersPage />} />
@ -52,7 +49,7 @@ export const AppRoutes = () => {
<Route path="/new-table" element={<NewTablePage />} /> <Route path="/new-table" element={<NewTablePage />} />
<Route path="/tables-new" element={<TablesTest />} /> <Route path="/tables-new" element={<TablesTest />} />
<Route path="/tables/:form/:sheetName" element={<NewFormTablePage />} /> <Route path="/tables/:form/:sheetName" element={<NewFormTablePage />} />
<Route path="/table/form/:formId/form-type/:formType/:sheetName/:direction/:year" element={<NewFormTablePage />} /> <Route path="/table/form/:formId/form-type/:formType/:sheetName/:direction" element={<NewFormTablePage />} />
</Route> </Route>
</Route> </Route>
</Routes> </Routes>

View File

@ -4,7 +4,7 @@ import { NavLink } from 'react-router-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { TasksSvg, AdminPanelSvg, DictSvg } from '../common/icons/icons'; import { TasksSvg, AdminPanelSvg, DictSvg } from '../common/icons/icons';
import { useAuth } from '../../app/context/AuthProvider'; import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants'; import { ROLES_NAME_ID } from '../../constants';
const Switch = styled.div` const Switch = styled.div`
margin-left: 1rem; margin-left: 1rem;

View File

@ -31,13 +31,12 @@ const LockBadge = styled.div`
`; `;
// Функция для подсветки текста // Функция для подсветки текста
const highlightText = (text, searchQueries) => { const highlightText = (text, searchQuery) => {
if (!text) return text; if (!searchQuery || !text) return text;
const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== ''));
if (cleanSearchQueries.length == 0) return text; const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${ escapedQueries.join('|')})`, 'gi');
const parts = text.split(regex); const parts = text.split(regex);
return parts.map((part, index) => return parts.map((part, index) =>
@ -90,7 +89,7 @@ const formatNumber = (value, asInteger = false) => {
}); });
}; };
const Cell = React.memo(({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, color, isEditable }) => { const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color, isEditable }) => {
const { lockedCells } = useRealtime(); const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`; const cellKey = `${row.id}_${column.id}`;
@ -106,10 +105,9 @@ const Cell = React.memo(({ cell, row, column, onClick, globalFilter, columnFilte
} }
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
return highlightText(textValue, [globalFilter, columnFilter]); return highlightText(textValue, globalFilter);
}, [textValue, globalFilter]); }, [textValue, globalFilter]);
// Базовые стили для заблокированной ячейки // Базовые стили для заблокированной ячейки
const lockedStyles = isLocked ? { const lockedStyles = isLocked ? {
border: '2px solid #e0e0e0', border: '2px solid #e0e0e0',

View File

@ -2,18 +2,15 @@
import { useEffect, useRef, useLayoutEffect, useState } from 'react'; import { useEffect, useRef, useLayoutEffect, useState } from 'react';
import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils'; import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils';
import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils'; import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils';
import { useRealtime } from '../../contexts/RealtimeContext';
const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const { endEditing: contextEndEditing } = useRealtime();
const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId }) => {
const [curValue, setCurValue] = useState(value); const [curValue, setCurValue] = useState(value);
const [displayValue, setDisplayValue] = useState(value); const [displayValue, setDisplayValue] = useState(value);
const [isFormulaMode, setIsFormulaMode] = useState(false); const [isFormulaMode, setIsFormulaMode] = useState(false);
// Ключ для хранения формул в localStorage // Ключ для хранения формул в localStorage
const storageKey = `formula_${tableId}_${cellId}`; const storageKey = `formula_${tableId}_${cellId}`;
const trFrag = refCell.current.offsetParent.offsetParent; const trFrag = refCell.current.offsetParent.offsetParent;
const tdFrag = refCell.current.offsetParent; const tdFrag = refCell.current.offsetParent;
const left = tdFrag.offsetLeft; const left = tdFrag.offsetLeft;
@ -72,6 +69,7 @@ const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }
if (isFormula(curValue)) { if (isFormula(curValue)) {
// Сохраняем формулу в localStorage // Сохраняем формулу в localStorage
saveToStorage(storageKey, curValue); saveToStorage(storageKey, curValue);
// Вычисляем и сохраняем результат // Вычисляем и сохраняем результат
try { try {
const formula = extractFormula(curValue); const formula = extractFormula(curValue);
@ -110,32 +108,6 @@ const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }
return displayValue; return displayValue;
}; };
const finishEditing = () => {
if (!disabled) {
handleSave();
}
const editingCell = table.getState().editingCell || null;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
};
useEffect(() => {
const handleClickOutside = (event) => {
// Проверяем, был ли клик внутри элемента редактирования
const editElement = ref.current;
if (editElement && !editElement.contains(event.target)) {
finishEditing();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, finishEditing]);
return ( return (
<tr> <tr>
<td> <td>
@ -178,10 +150,16 @@ const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }
}} }}
onChange={handleChangeValue} onChange={handleChangeValue}
value={curValue} value={curValue}
onBlur={handleSave}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
finishEditing(); handleSave();
// Закрываем редактирование
if (onChange) {
// Симулируем завершение редактирования
document.activeElement?.blur();
}
} }
}} }}
placeholder={isFormulaMode ? 'Введите формулу (начинается с =)' : ''} placeholder={isFormulaMode ? 'Введите формулу (начинается с =)' : ''}

View File

@ -29,8 +29,8 @@ import { additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectVspModal } from './Modals/SelectVspModal';
import { getRowId } from './utils/rowUtils'; import { getRowId } from './utils/rowUtils';
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction);
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false); const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumnId, setSelectedColumnId] = useState(); const [selectedColumnId, setSelectedColumnId] = useState();
@ -63,6 +63,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
addRow: contextAddRow, addRow: contextAddRow,
deleteRow: contextDeleteRow, deleteRow: contextDeleteRow,
startEditing: contextStartEditing, startEditing: contextStartEditing,
endEditing: contextEndEditing,
} = useRealtime(); } = useRealtime();
const { const {
@ -179,6 +180,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
if (cell) { if (cell) {
if (editingCell) return; if (editingCell) return;
contextStartEditing?.(cell.row, cell.column); contextStartEditing?.(cell.row, cell.column);
} else {
contextEndEditing?.(editingCell.row, editingCell.column);
} }
}, },
columnVirtualizerOptions: ({ table }) => ({ columnVirtualizerOptions: ({ table }) => ({
@ -226,12 +229,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
minHeight: '1px', minHeight: '1px',
maxHeight: '1px', maxHeight: '1px',
visibility: 'hidden', visibility: 'hidden',
'& svg': {
height: '1px',
minHeight: '1px',
maxHeight: '1px',
visibility: 'hidden',
}
}, },
}, },
muiTablePaperProps: getTablePaperStyles(), muiTablePaperProps: getTablePaperStyles(),
@ -363,8 +360,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
formId={formId} formId={formId}
sheetName={sheetName} sheetName={sheetName}
direction={direction} direction={direction}
year={year}
isProject={formType == 'PROJECT'}
/> />
<div style={tableWrapperStyle}> <div style={tableWrapperStyle}>
@ -425,6 +420,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onClose={() => setIsOpenModalSelectVsp(false)} onClose={() => setIsOpenModalSelectVsp(false)}
formId={formId} formId={formId}
onSelect={handleAddVspRow} onSelect={handleAddVspRow}
// vspOptions={}
// selectedVSP={}
title="Выбор ВСП" title="Выбор ВСП"
/> />
</> </>

View File

@ -18,7 +18,7 @@ import CollapsibleTree from '../../common/CollapsibleTree';
import ZoomSlider from './ZoomSlider'; import ZoomSlider from './ZoomSlider';
import SearchComponent from '../../common/SearchComponent'; import SearchComponent from '../../common/SearchComponent';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; import { exportSheet } from '../../../utils/exportForm';
const SettingPanel = ({ const SettingPanel = ({
selectedColumnId, selectedColumnId,
@ -39,8 +39,6 @@ const SettingPanel = ({
formId, formId,
sheetName, sheetName,
direction, direction,
year,
isProject,
}) => { }) => {
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@ -78,16 +76,7 @@ const SettingPanel = ({
const handleClickDeleteFormula = () => { }; const handleClickDeleteFormula = () => { };
const handleExport = async () => { const handleExport = async () => {
if (isExporting) return; if (!formId || !sheetName || isExporting) return;
if (isProject) {
if (!formId || !sheetName || !year) return;
setIsExporting(true);
await exportSheetProject(formId, sheetName, year);
setIsExporting(false);
return;
}
if (!formId || !sheetName) return;
setIsExporting(true); setIsExporting(true);
await exportSheet(formId, sheetName, direction); await exportSheet(formId, sheetName, direction);
setIsExporting(false); setIsExporting(false);
@ -126,26 +115,40 @@ const SettingPanel = ({
</Tooltip> </Tooltip>
</GroupByObject> </GroupByObject>
{!isProject && <Divider orientation="vertical" flexItem />
<>
<Divider orientation="vertical" flexItem />
<GroupByObject title="Строки">
<Tooltip title="Добавить строку">
<IconButton onClick={onAddRow} variant="outlined">
<Plus />
<AddLine />
</IconButton>
</Tooltip>
<Tooltip title="Удалить строку"> <GroupByObject title="Строки">
<IconButton onClick={onDeleteRow} variant="outlined"> <Tooltip title="Добавить строку">
<Minus /> <IconButton onClick={onAddRow} variant="outlined">
<RemoveLine /> <Plus />
</IconButton> <AddLine />
</Tooltip> </IconButton>
</GroupByObject> </Tooltip>
</>
} <Tooltip title="Удалить строку">
<IconButton onClick={onDeleteRow} variant="outlined">
<Minus />
<RemoveLine />
</IconButton>
</Tooltip>
</GroupByObject>
{/* <Divider orientation="vertical" flexItem />
<GroupByObject title="Формулы">
<Tooltip title="Добавить формулу">
<IconButton onClick={handleClickAddFormula} variant="outlined">
<FormulaSettingPanel />
</IconButton>
</Tooltip>
<Tooltip title="Удалить формулу">
<IconButton onClick={handleClickDeleteFormula} variant="outlined">
<Minus />
<FormulaSettingPanel />
</IconButton>
</Tooltip>
</GroupByObject> */}
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
<GroupByObject title="Столбцы"> <GroupByObject title="Столбцы">
{columns ? ( {columns ? (

View File

@ -313,10 +313,10 @@ const FilterCell = ({ column, table }) => {
onChange={(e) => handleFilterChange(e.target.value)} onChange={(e) => handleFilterChange(e.target.value)}
style={{ style={{
width: '100%', width: '100%',
padding: '0.25rem 0.375rem', padding: '4px 6px',
fontSize: '0.6875rem', fontSize: '11px',
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '0.25rem', borderRadius: '4px',
backgroundColor: 'white', backgroundColor: 'white',
}} }}
> >
@ -330,30 +330,23 @@ const FilterCell = ({ column, table }) => {
); );
case 'range': { case 'range': {
// Для диапазона можно реализовать два поля
const [min, max] = column.getFilterValue() || ['', '']; const [min, max] = column.getFilterValue() || ['', ''];
return ( return (
<div style={{ display: 'flex', gap: '0.25rem' }}> {/* 4px */} <div style={{ display: 'flex', gap: '4px' }}>
<input <input
type="number" type="number"
placeholder="От" placeholder="От"
value={min} value={min}
onChange={(e) => column.setFilterValue([e.target.value, max])} onChange={(e) => column.setFilterValue([e.target.value, max])}
style={{ style={{ width: '50%', padding: '4px', fontSize: '11px' }}
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem'
}}
/> />
<input <input
type="number" type="number"
placeholder="До" placeholder="До"
value={max} value={max}
onChange={(e) => column.setFilterValue([min, e.target.value])} onChange={(e) => column.setFilterValue([min, e.target.value])}
style={{ style={{ width: '50%', padding: '4px', fontSize: '11px' }}
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem'
}}
/> />
</div> </div>
); );
@ -368,10 +361,10 @@ const FilterCell = ({ column, table }) => {
placeholder="Поиск..." placeholder="Поиск..."
style={{ style={{
width: '100%', width: '100%',
padding: '0.25rem 0.375rem', padding: '4px 6px',
fontSize: '0.6875rem', fontSize: '11px',
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '0.25rem', borderRadius: '4px',
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
/> />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,555 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_limita_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_limita_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_itogo":{
"color_type":whiteColumn,
"accessorKey":"itogo_itogo"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка лимита",
"accessorKey":"korrektirovka_limita",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_limita_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого",
"accessorKey":"itogo",
"columns":[
{
"header":"Итого",
"accessorKey":"itogo_itogo",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

View File

@ -0,0 +1,577 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_plana_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_plana_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_korrektirovka_itogo_korrektirovka":{
"color_type":whiteColumn,
"accessorKey":"itogo_korrektirovka_itogo_korrektirovka"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal"
},
"itogo_skorrek_smeta_itogo_skorrek_smeta":{
"color_type":whiteColumn,
"accessorKey":"itogo_skorrek_smeta_itogo_skorrek_smeta"
},
"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal"
},
"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка плана",
"accessorKey":"korrektirovka_plana",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_plana_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого корректировка",
"accessorKey":"itogo_korrektirovka",
"columns":[
{
"header":"Итого корректировка",
"accessorKey":"itogo_korrektirovka_itogo_korrektirovka",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на II квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на II квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого скоррек. смета",
"accessorKey":"itogo_skorrek_smeta",
"columns":[
{
"header":"Итого скоррек. смета",
"accessorKey":"itogo_skorrek_smeta_itogo_skorrek_smeta",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на III квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на III квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на IV квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на IV квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

View File

@ -0,0 +1,555 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_limita_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_limita_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_itogo":{
"color_type":whiteColumn,
"accessorKey":"itogo_itogo"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка лимита",
"accessorKey":"korrektirovka_limita",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_limita_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого",
"accessorKey":"itogo",
"columns":[
{
"header":"Итого",
"accessorKey":"itogo_itogo",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -25,29 +25,17 @@ export const RealtimeProvider = ({
formId, formId,
sheetName, sheetName,
direction, direction,
year,
userId, userId,
isProject = false,
}) => { }) => {
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH) // Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl = const wsUrl = `${(process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || "ws://localhost:8000").replace(/^http/, "ws")}/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`;
`${(
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_ROOT_PATH ||
"ws://localhost:8000"
).replace(/^http/, "ws")}` +
(!isProject
? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`
: `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`);
//для ячеек которые редактируются другими пользователями //для ячеек которые редактируются другими пользователями
const [lockedCells, setLockedCells] = useState([]); const [lockedCells, setLockedCells] = useState([]);
const handleMessage = useCallback((data) => { const handleMessage = useCallback((data) => {
// Обработка ошибок // Обработка ошибок
if (data.error) { if (data.error) {
console.log(data.error.message); console.log(data.error.message)
toast.error("Server error:" + data.error.message || ""); toast.error("Server error:" + data.error.message || '');
onErrorRef.current?.(data.error); onErrorRef.current?.(data.error);
return; return;
} }
@ -93,7 +81,7 @@ export const RealtimeProvider = ({
} }
}, []); }, []);
const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = useWebSocket( const { isConnected, sendMessage, disconnect, lastError } = useWebSocket(
wsUrl, wsUrl,
handleMessage, handleMessage,
); );
@ -157,26 +145,13 @@ export const RealtimeProvider = ({
} }
}, [isConnected]); }, [isConnected]);
const addCommonField = (message) => {
if (isProject) {
message.report_type = sheetName;
message.project_id = formId;
message.year = year;
} else {
message.sheet = sheetName;
message.form_id = formId;
message.direction = direction;
}
return message;
};
// Функция для начала редактирования ячейки // Функция для начала редактирования ячейки
const startEditing = useCallback( const startEditing = useCallback(
async (row, column) => { async (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const rowId = row.id;
if (!isConnectedRef) { if (!isConnected) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.("WebSocket не подключен");
return false; return false;
} }
@ -208,7 +183,7 @@ export const RealtimeProvider = ({
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const rowId = row.id;
if (!isConnectedRef) { if (!isConnected) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.("WebSocket не подключен");
return false; return false;
} }
@ -238,7 +213,7 @@ export const RealtimeProvider = ({
async (row, column, value) => { async (row, column, value) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const rowId = row.id;
if (!isConnectedRef) { if (!isConnected) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.("WebSocket не подключен");
return false; return false;
} }
@ -249,7 +224,6 @@ export const RealtimeProvider = ({
event: "cell_updated", event: "cell_updated",
data: { data: {
line_id: lineId, line_id: lineId,
line_id_code: row.id,
column: colId, column: colId,
value: value, value: value,
}, },
@ -267,7 +241,7 @@ export const RealtimeProvider = ({
const addRow = useCallback( const addRow = useCallback(
async (rowData) => { async (rowData) => {
if (!isConnectedRef) { if (!isConnected) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.("Нет подключения или авторизации");
return false; return false;
} }
@ -275,9 +249,10 @@ export const RealtimeProvider = ({
const message = { const message = {
event: "row_added", event: "row_added",
data: rowData, data: rowData,
sheet: sheetName,
form_id: formId,
direction: direction,
}; };
addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
@ -285,7 +260,7 @@ export const RealtimeProvider = ({
const deleteRow = useCallback( const deleteRow = useCallback(
async (rowId) => { async (rowId) => {
if (!isConnectedRef) { if (!isConnected) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.("Нет подключения или авторизации");
return false; return false;
} }
@ -293,8 +268,10 @@ export const RealtimeProvider = ({
const message = { const message = {
event: "row_deleted", event: "row_deleted",
data: { row_id: rowId }, data: { row_id: rowId },
sheet: sheetName,
form_id: formId,
direction: direction,
}; };
addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },

View File

@ -4,9 +4,8 @@ import { useRealtime } from "../contexts/RealtimeContext";
import { deleteRow, insertCell, updateCells } from "../utils/cellUtils"; import { deleteRow, insertCell, updateCells } from "../utils/cellUtils";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { isVspNewRow, isVspRow } from "../utils/rowUtils"; import { isVspNewRow, isVspRow } from "../utils/rowUtils";
import { ProjectsApi } from "../../../api/projects";
const useRealtimeData = (formId, sheetName, direction, formType, year) => { const useRealtimeData = (formId, sheetName, direction) => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -78,10 +77,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
try { try {
setIsLoading(true); setIsLoading(true);
let res; let res;
if (direction !== null) {
if(formType == 'PROJECT'){
res = await ProjectsApi.getTableData(formId, sheetName, year)
} else if (direction !== null) {
res = await FormsSheetApi.get(formId, sheetName, { res = await FormsSheetApi.get(formId, sheetName, {
params: { direction: direction }, params: { direction: direction },
}); });

View File

@ -107,16 +107,30 @@ export const useWebSocket = (url, onMessage) => {
return false; return false;
}, []); }, []);
const login = useCallback(
(token) => {
if (!token) {
console.warn("No token provided for login");
return false;
}
return sendMessage({
event: "user_login",
data: { token },
});
},
[sendMessage],
);
useEffect(() => { useEffect(() => {
connect(); connect();
return () => disconnect(); return () => disconnect();
}, [connect, disconnect]); }, [connect, disconnect]);
return { return {
isConnectedRef, isConnected: isConnectedRef,
isConnected,
error, error,
sendMessage, sendMessage,
login,
disconnect, disconnect,
}; };
}; };

View File

@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
import { columns as mockColumns } from '../../mocks/mockTable'; import { columns as mockColumns } from '../../mocks/mockTable';
import { config } from './constants/FORM_2/AHR'; import { config } from './constants/FORM_2/AHR';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { useRealtime } from './contexts/RealtimeContext';
function EditCellPortal({ function EditCellPortal({
cell, cell,
@ -21,8 +20,6 @@ function EditCellPortal({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const isDisabled = false; const isDisabled = false;
const { endEditing: contextEndEditing } = useRealtime();
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
@ -71,7 +68,6 @@ function EditCellPortal({
disabled={!isEditable} disabled={!isEditable}
onChange={handleChange} onChange={handleChange}
tableId={tableKey} tableId={tableKey}
table={table}
cellId={cell.row.id + '_' + cell.column.id} cellId={cell.row.id + '_' + cell.column.id}
/>, />,
refTbody, refTbody,
@ -116,7 +112,6 @@ export const getTableColumns = ({
globalFilter={ globalFilter={
table.getState().globalFilter table.getState().globalFilter
} }
columnFilter={table.getState().columnFilters?.find(f => f.id === column.id)?.value}
isEditable={row.original?.row_type === 'INPUT' || false} isEditable={row.original?.row_type === 'INPUT' || false}
backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]} backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
color={columnColors[column.id]?.color_type?.['COLOR']} color={columnColors[column.id]?.color_type?.['COLOR']}

View File

@ -25,7 +25,7 @@ import { StageStatusChip } from './StageStatusChip';
import { formatDateForApi } from './utils'; import { formatDateForApi } from './utils';
import CollapsibleTree from '../common/CollapsibleTree'; import CollapsibleTree from '../common/CollapsibleTree';
import { collectLeafColumns } from '../RealtimeTable/utils/columnUtils'; import { collectLeafColumns } from '../RealtimeTable/utils/columnUtils';
import { STAGE_ROLE_RUSSIAN_NAME } from '../../constants/constants'; import { STAGE_ROLE_RUSSIAN_NAME } from '../../constants';
export const AddStagesModal = ({ export const AddStagesModal = ({
isOpen, isOpen,

View File

@ -1,6 +1,6 @@
import { Box, Divider, Stack } from '@mui/material'; import { Box, Divider, Stack } from '@mui/material';
import { UnLockedSvg } from '../common/icons/icons'; import { UnLockedSvg } from '../common/icons/icons';
import { ROLES_ID_RUSSIAN_NAME, EDIT_ACCESS_RUSSIAN } from '../../constants/constants'; import { ROLES_ID_RUSSIAN_NAME, EDIT_ACCESS_RUSSIAN } from '../../constants';
export const StageInfoForTooltip = ({ stageData = [] }) => { export const StageInfoForTooltip = ({ stageData = [] }) => {
const accessData = stageData[0] || []; const accessData = stageData[0] || [];

View File

@ -20,7 +20,7 @@ import {
import { getStageStatus, getStatusColors } from './utils'; import { getStageStatus, getStatusColors } from './utils';
import { formatDate } from '../../../utils/formatDate'; import { formatDate } from '../../../utils/formatDate';
import { StageStatusChip } from '../StageStatusChip'; import { StageStatusChip } from '../StageStatusChip';
import { ROLES_NAME_ID, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../constants/constants'; import { ROLES_NAME_ID, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../constants';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
export const StagesTable = ({ stages, onEdit, onDelete }) => { export const StagesTable = ({ stages, onEdit, onDelete }) => {

View File

@ -1,33 +0,0 @@
// components/NavigationToggle.jsx
import React from 'react';
import { Box, Typography } from '@mui/material';
import { PrimaryOutlinedButton } from './Buttons/Buttons'; // Ваш компонент кнопки
const NavigationToggle = ({ currentView, onViewChange }) => {
const handleNavigateToTasks = () => {
onViewChange('/tasks');
};
const handleNavigateToProjects = () => {
onViewChange('/projects');
};
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{currentView === 'tasks' ? (
<PrimaryOutlinedButton onClick={handleNavigateToProjects}>
К проектам
</PrimaryOutlinedButton>
) : (
<PrimaryOutlinedButton onClick={handleNavigateToTasks}>
К задачам
</PrimaryOutlinedButton>
)}
<Typography variant="h6" color="primary" sx={{ fontWeight: 200, fontSize: '1rem' }}>
{currentView === 'tasks' ? 'Управление задачами' : 'Управление проектами'}
</Typography>
</Box>
);
};
export default NavigationToggle;

View File

@ -1,257 +0,0 @@
// src/components/common/PaginatedList/PaginatedList.jsx
import { useState, useEffect } from 'react';
import {
Box,
Pagination,
PaginationItem,
Select,
MenuItem,
Typography,
Stack,
} from '@mui/material';
import { CardsSkeleton } from './Skeleton';
import {
ExportExitButton,
ExportWithTextButton,
} from './Buttons/ButtonsActions';
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile';
/**
* Универсальный компонент для отображения списка с пагинацией и экспортом
*
* @param {Object} props
* @param {Array} props.items - Массив элементов для отображения
* @param {boolean} props.loading - Состояние загрузки
* @param {number} props.totalCount - Общее количество элементов
* @param {number} props.page - Текущая страница
* @param {number} props.limit - Количество элементов на странице
* @param {Function} props.onPageChange - Обработчик изменения страницы
* @param {Function} props.onLimitChange - Обработчик изменения лимита
* @param {Function} props.renderItem - Функция рендеринга элемента
* @param {Function} props.getItemId - Функция получения ID элемента
* @param {Function} props.getItemTitle - Функция получения заголовка элемента
* @param {string} props.entityName - Название сущности (для текстов)
* @param {string} props.emptyMessage - Сообщение при пустом списке
* @param {number} props.skeletonCount - Количество силуэтов таблиц при загрузки
* @param {Object} props.extraActions - Дополнительные действия справа
* @param {boolean} props.enableExport - Включить экспорт
* @param {Array} props.limitOptions - Опции для выбора лимита
* @param {number} props.maxHeight - Максимальная высота контейнера со скроллом
* @param {boolean} props.enableScroll - Включить скролл для списка
* @param {boolean} props.filterActions - Элементы фильтрации
*/
export function PaginatedList({
items = [],
loading = false,
totalCount = 0,
page = 1,
limit = 20,
onPageChange,
onLimitChange,
renderItem,
getItemId,
getItemTitle = (item) => `Элемент ${getItemId(item)}`,
entityName = 'элементов',
emptyMessage = 'Элементы не найдены',
skeletonCount = 20,
extraActions = null,
enableExport = true,
limitOptions = [10, 20, 50, 100, 200],
maxHeight = 'calc(100vh - 300px)',
enableScroll = true,
filterActions = null,
}) {
const [exportMode, setExportMode] = useState(false);
const [exportList, setExportList] = useState([]);
// Сброс списка экспорта при переключении режима
useEffect(() => {
setExportList([]);
}, [exportMode]);
const handleAddToExport = (itemId, checked) => {
if (checked) {
setExportList((prev) => [...prev, itemId]);
} else {
setExportList((prev) => prev.filter((id) => id !== itemId));
}
};
const handleBulkExport = async () => {
await exportBulkForms(exportList);
setExportMode(false);
};
const handleSingleExport = (itemId) => {
const item = items.find((item) => getItemId(item) === itemId);
const title = item ? getItemTitle(item) : `Элемент ${itemId}`;
exportSingleForm(itemId, title);
};
// Если включен экспорт, передаем его пропсы в дочерние элементы
const renderItemWithExport = (item) => {
if (enableExport) {
return renderItem(item, {
exportMode,
onAddForExport: (checked) => {
handleAddToExport(getItemId(item), checked);
},
onExportClick: () => {
handleSingleExport(getItemId(item));
},
isSelected: exportList.includes(getItemId(item)),
});
}
return renderItem(item);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
{/* Верхняя панель с экспортом и дополнительными действиями */}
{enableExport && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
{filterActions}
</div>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: '1.5rem' }}>
{exportMode && (
<Typography variant="body2" color="textSecondary">
Выделено {entityName}: {exportList.length}
</Typography>
)}
<Box sx={{ flex: 1 }} />
<Stack direction="row" spacing="0.5rem">
{!exportMode ? (
<>
<ExportWithTextButton
text="Пакетный экспорт"
onClick={() => setExportMode(true)}
height='2.5rem'
/>
{extraActions}
</>
) : (
<>
<ExportWithTextButton
text="Экспортировать"
onClick={handleBulkExport}
disabled={exportList.length === 0}
height='2.5rem'
/>
<ExportExitButton onClick={() => setExportMode(false)} />
</>
)}
</Stack>
</Box>
</Box>
)}
{/* Контейнер со скроллом для списка */}
<Box
sx={{
flex: 1,
overflow: enableScroll ? 'auto' : 'visible',
maxHeight: enableScroll ? maxHeight : 'none',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: '#888',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#555',
},
}}
>
{/* Список элементов */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
{loading ? (
<CardsSkeleton count={skeletonCount} />
) : items.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4, width: '100%' }}>
<Typography variant="body1" color="textSecondary">
{emptyMessage}
</Typography>
</Box>
) : (
items.map((item) => renderItemWithExport(item))
)}
</Box>
</Box>
{/* Пагинация */}
{!loading && items.length > 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pt: 2,
pb: 2,
borderTop: '1px solid',
borderColor: 'divider',
flexShrink: 0,
mt: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="textSecondary">
Показать на странице:
</Typography>
<Select
value={limit}
onChange={onLimitChange}
size="small"
sx={{ minWidth: 70 }}
disabled={loading}
>
{limitOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</Box>
<Typography variant="body2" color="textSecondary">
Всего: {totalCount}
</Typography>
</Box>
<Pagination
count={Math.ceil(totalCount / limit)}
page={page}
onChange={onPageChange}
color="primary"
size="large"
showFirstButton
showLastButton
disabled={loading}
renderItem={(item) => (
<PaginationItem
{...item}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'white',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
/>
)}
/>
<Box sx={{ width: 120 }} />
</Box>
)}
</Box>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { EDIT_ACCESS_RUSSIAN, ROLES_ID_RUSSIAN_NAME } from '../../../constants/constants'; import { EDIT_ACCESS_RUSSIAN, ROLES_ID_RUSSIAN_NAME } from '../../../constants';
import { import {
SelectWrapper, SelectWrapper,
SelectContainer, SelectContainer,

View File

@ -1,5 +1,5 @@
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
import { Switch, Buttons, StyledNavLink } from './SwitchFormTask.style'; import { Switch, Buttons, StyledNavLink } from './SwitchFormTask.style';
export function SwitchFormTask() { export function SwitchFormTask() {

View File

@ -1,4 +1,4 @@
import { DIRECTION_TRANSLATE, FORM_TYPE_OPTIONS } from '../../../constants/constants'; import { DIRECTION_TRANSLATE, FORM_TYPE_OPTIONS } from '../../../constants';
import { import {
Section, Section,
SectionStartPart, SectionStartPart,

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React from 'react';
import { Box, Stack, Typography } from '@mui/material'; import { Box, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import TableCard from '../TableCard/TableCard'; import TableCard from '../TableCard/TableCard';
@ -6,10 +6,10 @@ import { toast } from 'react-toastify';
const TablesList = ({ const TablesList = ({
filteredTables, filteredTables,
projects,
query, query,
basePath = '/table', basePath = '/table',
formInfo, formInfo
isProject = false,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -22,7 +22,7 @@ const TablesList = ({
if (!formInfo){ if (!formInfo){
return; return;
} }
const path = `${basePath}/form/${formInfo.id}/form-type/${isProject ? 'PROJECT' : formInfo.form_type_code}/${table.sheet}/${table?.direction}/${table?.year}`; const path = `${basePath}/form/${formInfo.id}/form-type/${formInfo.form_type_code}/${table.sheet}/${table?.direction}`;
return ( return (
<TableCard <TableCard

View File

@ -26,7 +26,7 @@ import { formatDate } from '../../utils/formatDate';
import { IconWithContent } from './IconWithContent'; import { IconWithContent } from './IconWithContent';
import { Chip } from '../styles/StyledChip'; import { Chip } from '../styles/StyledChip';
import { ExportButton } from '../common/Buttons/ButtonsActions'; import { ExportButton } from '../common/Buttons/ButtonsActions';
import { FORM_TYPE_TRANSLATE } from '../../constants/constants'; import { FORM_TYPE_TRANSLATE } from '../../constants';
const TaskCard = styled.div` const TaskCard = styled.div`
width: 26.8rem; width: 26.8rem;

View File

@ -1,90 +0,0 @@
export const DEFAULT_PROJECT_CONFIG = {
'ССП/РФ': {
type: 'multiselect',
options: [],
placeholder: 'Выберите ССП/РФ',
defaultValue: null,
required_field: true,
},
'Год': {
type: 'number',
placeholder: 'Введите год',
defaultValue: null,
min: 0,
step: 1,
required_field: false,
},
'Тип проекта развития': {
type: 'multiselect',
options: [
'Открытие ВСП',
'Закрытие ВСП',
'Переезд ВСП',
'Реновация РФ',
'Открытие УРМ',
],
placeholder: 'Выберите тип проекта',
defaultValue: null,
required_field: false,
},
'Название проекта': {
type: 'text',
placeholder: 'Введите название проекта',
defaultValue: null,
required_field: true,
},
'Формат ВСП': {
type: 'multiselect',
options: [
'Фланговый',
'Типовой',
'Розничный',
'МСБ',
'Лёгкий',
'Мини',
'Розничный-киоск',
'МБО',
'Офис самообслуживания',
'Другое',
],
placeholder: 'Выберите формат ВСП',
defaultValue: null,
required_field: false,
},
'Адрес объекта': {
type: 'text',
placeholder: 'Введите адрес объекта',
defaultValue: '',
required_field: false,
},
'Целевая численность, чел.': {
type: 'number',
placeholder: 'Введите число',
defaultValue: 0,
min: 0,
step: 1,
required_field: false,
},
'Тип размещения': {
type: 'multiselect',
options: ['Собственность', 'Аренда', 'Субаренда'],
placeholder: 'Выберите тип размещения',
defaultValue: null,
required_field: false,
},
'Площадь размещения, м2': {
type: 'number',
placeholder: 'Введите число',
defaultValue: 0,
min: 0,
step: 1,
required_field: false,
},
};

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { PageContainer } from '../../StyledForPage'; import { PageContainer } from '../../StyledForPage';
import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch.jsx'; import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch.jsx';
import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp'; import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Box, Typography, Link, Chip } from '@mui/material'; import { Box, Typography, Link, Chip } from '@mui/material';
import { ROLES_ID_RUSSIAN_NAME } from '../../../../constants/constants'; import { ROLES_ID_RUSSIAN_NAME } from '../../../../constants';
import { import {
ENTITY_RUSSIAN_NAMES, ENTITY_RUSSIAN_NAMES,
getRussianNamesForType, getRussianNamesForType,

View File

@ -12,8 +12,8 @@ export const EVENT_CONFIGS = {
format: 'Утвержденный формат', format: 'Утвержденный формат',
opened_at: 'Дата открытия ВСП', opened_at: 'Дата открытия ВСП',
notes: 'Примечания', notes: 'Примечания',
placement_type: 'Форма расположения ВСП', location_form: 'Форма расположения ВСП',
staff_count: 'Штатная численность ВСП', numbers: 'Штатная численность ВСП',
total_area: 'Площадь помещений ВСП', total_area: 'Площадь помещений ВСП',
rent_contract_num: 'Номер договора аренды', rent_contract_num: 'Номер договора аренды',
rent_end_date: 'Срок окончания договора аренды', rent_end_date: 'Срок окончания договора аренды',
@ -21,6 +21,8 @@ export const EVENT_CONFIGS = {
branch_name: 'Региональный филиал ', branch_name: 'Региональный филиал ',
closed_at: 'Дата закрытия ВСП', closed_at: 'Дата закрытия ВСП',
user_email: 'Почта пользователя', user_email: 'Почта пользователя',
staff_count: 'Кол-во работников',
placement_type: 'Тип размещение',
is_active: 'Активный', is_active: 'Активный',
}, },
VSP_UPDATE: { VSP_UPDATE: {
@ -30,8 +32,8 @@ export const EVENT_CONFIGS = {
format: 'Утвержденный формат', format: 'Утвержденный формат',
opened_at: 'Дата открытия ВСП', opened_at: 'Дата открытия ВСП',
notes: 'Примечания', notes: 'Примечания',
placement_type: 'Форма расположения ВСП', location_form: 'Форма расположения ВСП',
staff_count: 'Штатная численность ВСП', numbers: 'Штатная численность ВСП',
total_area: 'Площадь помещений ВСП', total_area: 'Площадь помещений ВСП',
rent_contract_num: 'Номер договора аренды', rent_contract_num: 'Номер договора аренды',
rent_end_date: 'Срок окончания договора аренды', rent_end_date: 'Срок окончания договора аренды',
@ -39,6 +41,8 @@ export const EVENT_CONFIGS = {
'changes.branch_name': 'Региональный филиал ', 'changes.branch_name': 'Региональный филиал ',
closed_at: 'Дата закрытия ВСП', closed_at: 'Дата закрытия ВСП',
user_email: 'Почта пользователя', user_email: 'Почта пользователя',
staff_count: 'Кол-во работников',
placement_type: 'Тип размещение',
is_active: 'Активный', is_active: 'Активный',
}, },
USER_ACCESS_GRANTED: { USER_ACCESS_GRANTED: {
@ -78,15 +82,15 @@ export const EVENT_CONFIGS = {
}, },
ACCESS_EXTEND: { ACCESS_EXTEND: {
form_id: 'Информация о задаче', form_id: 'Информация о задаче',
closes_at_after: 'Дата закрытия (новая)', closes_at_after: 'Дата закрытия',
closes_at_before: 'Дата закрытия (старая)', closes_at_before: 'Дата открытия',
phase_name: 'Названия этапа', phase_name: 'Названия этапа',
sheet: "Лист", sheet: "Лист",
}, },
ACCESS_WINDOW_CHANGE: { ACCESS_WINDOW_CHANGE: {
form_id: 'Информация о задаче', form_id: 'Информация о задаче',
closes_at: 'Дата закрытия', closes_at: 'Дата закрытия',
opens_at: 'Дата открытия', open_at: 'Дата открытия',
phase_name: 'Названия этапа', phase_name: 'Названия этапа',
role: 'Для роли', role: 'Для роли',
column_keys: 'Колонки в этапе', column_keys: 'Колонки в этапе',

View File

@ -4,7 +4,7 @@ import { FormsApi } from '../../../../api/forms';
import { SspApi } from '../../../../api/ssp'; import { SspApi } from '../../../../api/ssp';
import { TasksApi } from '../../../../api/tasks'; import { TasksApi } from '../../../../api/tasks';
import { UsersApi } from '../../../../api/users'; import { UsersApi } from '../../../../api/users';
import { DIRECTION_TRANSLATE, FORM_TYPE_TRANSLATE, ROLES_ID_RUSSIAN_NAME, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../../constants/constants'; import { DIRECTION_TRANSLATE, FORM_TYPE_TRANSLATE, ROLES_ID_RUSSIAN_NAME, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../../constants';
export const VALUE_TRANSFORMERS = { export const VALUE_TRANSFORMERS = {
Роль: transformRole, Роль: transformRole,

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { PageContainer } from '../../StyledForPage'; import { PageContainer } from '../../StyledForPage';
import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch'; import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch';
import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp'; import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { import {
Box, Box,

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import Modal from '../../../components/common/Modal/Modal'; import Modal from '../../../components/common/Modal/Modal';
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import SelectWithOptions from '../components/SelectWithOptions'; import SelectWithOptions from '../components/SelectWithOptions';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
import { AutocompleteForChangeOptions } from '../components/AutocompleteForChangeOptions'; import { AutocompleteForChangeOptions } from '../components/AutocompleteForChangeOptions';
export const ModalEditUserSsp = ({ export const ModalEditUserSsp = ({

View File

@ -15,7 +15,7 @@ import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { import {
ROLES_ID_RUSSIAN_NAME_ARRAY as ROLES, ROLES_ID_RUSSIAN_NAME_ARRAY as ROLES,
ROLES_ID_RUSSIAN_NAME, ROLES_ID_RUSSIAN_NAME,
} from '../../../../constants/constants'; } from '../../../../constants';
import SelectWithOptions from '../../components/SelectWithOptions'; import SelectWithOptions from '../../components/SelectWithOptions';
import useAbbreviation from './hooks/useAbbreviation'; import useAbbreviation from './hooks/useAbbreviation';
import { import {

View File

@ -5,12 +5,12 @@ import {
CheckedIcon, CheckedIcon,
IndeterminateIcon, IndeterminateIcon,
} from '../../IconsForTable/icons'; } from '../../IconsForTable/icons';
import { ROLES_NAME_ID } from '../../../../constants/constants'; import { ROLES_NAME_ID } from '../../../../constants';
import { createFilterData } from '../../utils/filters'; import { createFilterData } from '../../utils/filters';
import { import {
ROLES_ID_RUSSIAN_NAME_ARRAY as ROLES, ROLES_ID_RUSSIAN_NAME_ARRAY as ROLES,
ROLES_ID_NAME, ROLES_ID_NAME,
} from '../../../../constants/constants'; } from '../../../../constants';
import SelectWithOptions from '../../components/SelectWithOptions'; import SelectWithOptions from '../../components/SelectWithOptions';
import RowActionsMenu from './RowActionMenu'; import RowActionsMenu from './RowActionMenu';
import DepartmentCell from './DepartmentCell'; import DepartmentCell from './DepartmentCell';

View File

@ -12,7 +12,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { CheckIcon, CheckedIcon } from '../IconsForTable/icons'; import { CheckIcon, CheckedIcon } from '../IconsForTable/icons';
import { TrashSvg } from '../../../components/common/icons/icons'; import { TrashSvg } from '../../../components/common/icons/icons';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
const StyleForChecked = { const StyleForChecked = {
display: 'flex', display: 'flex',

View File

@ -6,7 +6,7 @@ import { Button, Stack } from '@mui/material';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { ROLES_ID_RUSSIAN_NAME } from '../../../constants/constants'; import { ROLES_ID_RUSSIAN_NAME } from '../../../constants';
import { UserIcon, TrashSvg } from '../../../components/common/icons/icons'; import { UserIcon, TrashSvg } from '../../../components/common/icons/icons';
import { import {
DangerOutlinedButton, DangerOutlinedButton,

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { PageContainer } from '../../StyledForPage'; import { PageContainer } from '../../StyledForPage';
import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch'; import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch';
import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp'; import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp';
import { ROLES_ID_RUSSIAN_NAME_ARRAY, ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_ID_RUSSIAN_NAME_ARRAY, ROLES_NAME_ID } from '../../../constants';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { Box, TextField, Autocomplete, FormLabel } from '@mui/material'; import { Box, TextField, Autocomplete, FormLabel } from '@mui/material';
import SearchComponent from '../../../components/common/SearchComponent'; import SearchComponent from '../../../components/common/SearchComponent';
@ -10,7 +10,7 @@ import { toast } from 'react-toastify';
import { SspApi } from '../../../api/ssp'; import { SspApi } from '../../../api/ssp';
import { UsersApi } from '../../../api/users'; import { UsersApi } from '../../../api/users';
import UserTable from './UserTable/UserTable'; import UserTable from './UserTable/UserTable';
import { ROLES_ID_RUSSIAN_NAME } from '../../../constants/constants'; import { ROLES_ID_RUSSIAN_NAME } from '../../../constants';
import { ModalEditUserSsp } from './ModalEditUserSsp'; import { ModalEditUserSsp } from './ModalEditUserSsp';
import { ModalAddUsers } from './ModalAddUsers'; import { ModalAddUsers } from './ModalAddUsers';
import UsersActionBar from './UsersActionBar'; import UsersActionBar from './UsersActionBar';

View File

@ -13,7 +13,7 @@ import TableDictVsp from './components/TableDictVsp';
import { PageContainer } from '../StyledForPage'; import { PageContainer } from '../StyledForPage';
import { SspApi } from '../../api/ssp'; import { SspApi } from '../../api/ssp';
import { useAuth } from '../../app/context/AuthProvider'; import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants'; import { ROLES_NAME_ID } from '../../constants';
import DictVspFilters from './components/DictVspFilters'; import DictVspFilters from './components/DictVspFilters';
const DictVspInfo = () => { const DictVspInfo = () => {

View File

@ -14,7 +14,7 @@ import {
PrimaryButton, PrimaryButton,
} from '../../../../components/common/Buttons/Buttons'; } from '../../../../components/common/Buttons/Buttons';
import { useAuth } from '../../../../app/context/AuthProvider'; import { useAuth } from '../../../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../../../constants/constants'; import { ROLES_NAME_ID } from '../../../../constants';
import { import {
ModalContainer, ModalContainer,
FieldContainer, FieldContainer,
@ -43,8 +43,8 @@ const ModalEditAddVsp = ({
open_date: null, open_date: null,
close_date: null, close_date: null,
notes: '', notes: '',
placement_type: '', location_form: '',
staff_count: '', numbers: '',
area: null, area: null,
rent_contract_num: '', rent_contract_num: '',
rent_end_date: null, rent_end_date: null,
@ -94,8 +94,8 @@ const ModalEditAddVsp = ({
? new Date(initialData.close_date) ? new Date(initialData.close_date)
: null, : null,
notes: initialData.notes || '', notes: initialData.notes || '',
placement_type: initialData.placement_type || '', location_form: initialData.location_form || '',
staff_count: initialData.staff_count?.toString() || '', numbers: initialData.staff_count?.toString() || '',
area: initialData.area || null, area: initialData.area || null,
rent_contract_num: initialData.rent_contract_num || '', rent_contract_num: initialData.rent_contract_num || '',
rent_end_date: initialData.rent_end_date rent_end_date: initialData.rent_end_date
@ -114,8 +114,8 @@ const ModalEditAddVsp = ({
open_date: null, open_date: null,
close_date: null, close_date: null,
notes: '', notes: '',
placement_type: '', location_form: '',
staff_count: '', numbers: '',
area: null, area: null,
rent_contract_num: '', rent_contract_num: '',
rent_end_date: null, rent_end_date: null,
@ -215,7 +215,7 @@ const ModalEditAddVsp = ({
const submitData = { const submitData = {
...formData, ...formData,
staff_count: formData.staff_count ? parseInt(formData.staff_count, 10) : null, numbers: formData.staff_count ? parseInt(formData.staff_count, 10) : null,
area: formData.area ? parseFloat(formData.area) : null, area: formData.area ? parseFloat(formData.area) : null,
open_date: formData.open_date open_date: formData.open_date
? formData.open_date.toLocaleDateString('en-CA') ? formData.open_date.toLocaleDateString('en-CA')
@ -255,8 +255,8 @@ const ModalEditAddVsp = ({
open_date: null, open_date: null,
close_date: null, close_date: null,
notes: '', notes: '',
placement_type: '', location_form: '',
staff_count: '', numbers: '',
area: '', area: '',
rent_contract_num: '', rent_contract_num: '',
rent_end_date: null, rent_end_date: null,
@ -461,11 +461,11 @@ const ModalEditAddVsp = ({
<FieldLabel>Форма расположения ВСП</FieldLabel> <FieldLabel>Форма расположения ВСП</FieldLabel>
<StyledTextField <StyledTextField
select select
value={formData.placement_type} value={formData.location_form}
onChange={handleChange('placement_type')} onChange={handleChange('location_form')}
placeholder="Выберите форму расположения" placeholder="Выберите форму расположения"
error={!!errors.placement_type} error={!!errors.location_form}
helperText={errors.placement_type} helperText={errors.location_form}
disabled={loading} disabled={loading}
slotProps={{ slotProps={{
select: { select: {
@ -496,7 +496,7 @@ const ModalEditAddVsp = ({
<StyledTextField <StyledTextField
type="text" type="text"
value={formData.staff_count} value={formData.staff_count}
onChange={handleNumberChange('staff_count')} onChange={handleNumberChange('numbers')}
placeholder="Введите численность" placeholder="Введите численность"
error={!!errors.staff_count} error={!!errors.staff_count}
helperText={errors.staff_count} helperText={errors.staff_count}

View File

@ -11,7 +11,7 @@ import {
PrimaryOutlinedButton, PrimaryOutlinedButton,
} from '../../../components/common/Buttons/Buttons'; } from '../../../components/common/Buttons/Buttons';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants';
import ModalExport from './Modals/ModalExport'; import ModalExport from './Modals/ModalExport';
const TableDictVsp = ({ const TableDictVsp = ({
@ -192,7 +192,7 @@ const TableDictVsp = ({
enableSorting: false, enableSorting: false,
}, },
{ {
accessorKey: 'placement_type', accessorKey: 'location_form',
header: 'Форма расположения ВСП', header: 'Форма расположения ВСП',
size: 170, size: 170,
minSize: 140, minSize: 140,

View File

@ -8,13 +8,12 @@ import { useParams } from 'react-router-dom';
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext'; import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
import { useAuth } from '../app/context/AuthProvider'; import { useAuth } from '../app/context/AuthProvider';
import { BackButton } from '../components/common/Buttons/BackButton'; import { BackButton } from '../components/common/Buttons/BackButton';
import { SHEET_NAME } from '../constants/constants'; import { SHEET_NAME } from '../constants';
const TableInfo = ({ formId, sheetName, isProject = false }) => { const TableInfo = ({ formId, sheetName }) => {
const path = (isProject ? `/project/` : '/task/') + formId;
const portalContent = ( const portalContent = (
<TaskInfoContainer> <TaskInfoContainer>
<BackButton to={path} /> <BackButton to={`/task/${formId}`} />
{sheetName && ( {sheetName && (
<NameTask>{SHEET_NAME[sheetName] || sheetName}</NameTask> <NameTask>{SHEET_NAME[sheetName] || sheetName}</NameTask>
)} )}
@ -29,29 +28,20 @@ const TableInfo = ({ formId, sheetName, isProject = false }) => {
}; };
export default function NewTablePage() { export default function NewTablePage() {
const { formId, formType, sheetName, direction, year } = useParams(); const { formId, formType, sheetName, direction } = useParams();
const { user } = useAuth(); const { user } = useAuth();
const isProject = formType == 'PROJECT';
return ( return (
<> <>
{formId && <TableInfo formId={formId} sheetName={sheetName} isProject={isProject} />} {formId && <TableInfo formId={formId} sheetName={sheetName} />}
{user && ( {user && (
<RealtimeProvider <RealtimeProvider
formId={formId} formId={formId}
sheetName={sheetName} sheetName={sheetName}
userId={user.id} userId={user.id}
direction={direction === 'null' ? null : direction} direction={direction === 'null' ? null : direction}
isProject={isProject}
year={year}
> >
<RealtimeTable <RealtimeTable formType={formType} formId={formId} sheetName={sheetName} direction={direction === 'null' ? null : direction} />
formType={formType}
formId={formId}
sheetName={sheetName}
direction={direction === 'null' ? null : direction}
year={year}
/>
</RealtimeProvider> </RealtimeProvider>
)} )}

View File

@ -1,349 +0,0 @@
// src/components/common/ProjectCardComponent/ProjectCardComponent.jsx
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import {
Button,
TextField,
FormControl,
FormLabel,
Box,
Stack,
Chip,
} from '@mui/material';
import { ProjectsApi } from '../../api/projects';
import { toast } from 'react-toastify';
import {
CheckBoxCheckSvg,
CheckBoxUncheckSvg,
DateIcon,
Edit,
ExportSvg,
UserIcon,
BuildingIcon,
LocationIcon,
} from '../../components/common/icons/icons';
import { IconWithContent } from '../../components/common/IconWithContent';
import { ExportButton } from '../../components/common/Buttons/ButtonsActions';
import { ModalCreateProject } from './ModalCreateProject';
const ProjectCard = styled.div`
width: 26.8rem;
height: 15rem;
display: flex;
padding: 1.25rem;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
box-sizing: border-box;
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
border-radius: 1rem;
box-shadow:
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
background: var(--White, rgba(255, 255, 255, 1));
overflow: hidden;
cursor: pointer;
position: relative;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
`;
const CardHeader = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
`;
const ProjectId = styled.div`
font-size: 0.75rem;
font-weight: 400;
line-height: 1rem;
letter-spacing: 0rem;
text-align: left;
color: rgba(0, 0, 0, 0.6);
`;
const CardBody = styled.div`
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.8125rem;
justify-content: flex-end;
height: 100%;
`;
const ProjectTitle = styled.div`
font-size: 1rem;
font-weight: 500;
color: var(--Text-Primary, #333);
line-height: 1.63;
margin: 0;
padding: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
`;
const StatusChip = styled(Chip)`
font-size: 0.7rem;
height: 1.5rem;
background-color: ${({ status }) => {
switch (status) {
case 'active': return '#4caf50';
case 'completed': return '#2196f3';
case 'paused': return '#ff9800';
case 'archived': return '#9e9e9e';
default: return '#9e9e9e';
}
}};
color: white;
`;
const LevelChip = styled(Chip)`
font-size: 0.6rem;
height: 1.25rem;
background-color: rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.7);
`;
const ProjectInfoGrid = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
`;
const InfoChip = styled(Chip)`
font-size: 0.6rem;
height: 1.25rem;
background-color: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.7);
`;
const getStatusLabel = (status) => {
const statusMap = {
active: 'Активный',
completed: 'Завершен',
paused: 'Приостановлен',
archived: 'Архивирован',
};
return statusMap[status] || status || 'Не указан';
};
export const ProjectCardComponent = ({
project,
projectDataInit,
exportMode = false,
isLoadingFile = false,
onAddForExport,
onExportClick,
}) => {
const navigate = useNavigate();
const {
id,
name,
org_unit_name,
years,
status,
level,
project_type,
vsp_format,
placement_type,
object_address,
staff_count,
total_area,
report_count,
parent_id,
} = project;
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [currentName, setCurrentName] = useState(name || '');
const [isChecked, setIsChecked] = useState(false);
const [editFormData, setEditFormData] = useState({});
const [projectData, setProjectData] = useState(projectDataInit);
useEffect(() => {
setIsChecked(false);
}, [exportMode]);
// Функция для подготовки данных проекта к редактированию
const prepareProjectDataForEdit = () => {
return {
'ССП/РФ': project.org_unit_id || null,
'Год': years || null,
'Тип проекта развития': project_type || null,
'Название проекта': currentName,
'Формат ВСП': vsp_format || null,
'Адрес объекта': object_address || '',
'Целевая численность, чел.': staff_count || 0,
'Тип размещения': placement_type || null,
'Площадь размещения, м2': total_area || 0,
};
};
const handleOpenEdit = (event) => {
event.stopPropagation();
setEditFormData(prepareProjectDataForEdit());
setEditModalOpen(true);
};
const handleCloseEdit = () => {
setEditModalOpen(false);
setEditFormData({});
};
const handleCheckboxClick = (event) => {
event.stopPropagation();
if (!isLoadingFile) {
setIsChecked(!isChecked);
onAddForExport(!isChecked);
} else {
toast.warning('Файл формируется!');
}
};
const handleExportProject = (event) => {
event.stopPropagation();
onExportClick?.(id, name);
};
const handleNavigate = () => {
if (!exportMode) {
navigate(`/project/${id}`);
}
};
// Обновленная функция обновления проекта
const updateProject = async (formData) => {
try {
const projectData = {
name: formData['Название проекта'] || currentName,
project_type: formData['Тип проекта развития'] || null,
vsp_format: formData['Формат ВСП'] || null,
object_address: formData['Адрес объекта'] || '',
staff_count: Number(formData['Целевая численность, чел.']) || 0,
placement_type: formData['Тип размещения'] || null,
total_area: Number(formData['Площадь размещения, м2']) || 0,
year: formData['Год'] ? Number(formData['Год']) : null,
branch_id: formData['ССП/РФ'] || null,
};
await ProjectsApi.update(id, projectData);
// Обновляем локальные данные
setCurrentName(projectData.name);
setEditModalOpen(false);
toast.success('Данные проекта успешно обновлены');
// Можно добавить рефреш данных или перезагрузку страницы
window.location.reload(); // или используйте callback для обновления списка
} catch (error) {
toast.error(
error?.response?.data?.detail || 'Не удалось обновить данные проекта',
);
}
};
const handleSaveProject = async (formData) => {
await updateProject(formData);
};
const getInitials = (name) => {
if (!name) return '?';
const words = name.split(' ');
if (words.length >= 2) {
return `${words[0][0]}${words[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
return (
<>
<ProjectCard onClick={handleNavigate} key={id}>
<Box sx={{ width: '100%' }}>
<CardHeader>
<ProjectId>ID: {id}</ProjectId>
<Stack direction={'row'} spacing={'.5rem'} sx={{
}} >
{!exportMode && (
<>
{status && (
<StatusChip
status={status}
label={getStatusLabel(status)}
size="small"
/>
)}
{/* {Пока нет редактирование + экспорт} */}
{/* <ExportButton onClick={handleExportProject} />
<Edit onClick={handleOpenEdit} /> */}
</>
)}
</Stack>
</CardHeader>
<ProjectTitle>{currentName}</ProjectTitle>
<ProjectInfoGrid>
{project_type && (
<InfoChip label={`Тип: ${project_type}`} size="small" />
)}
{report_count !== undefined && report_count !== null && (
<InfoChip label={`Отчетов: ${report_count}`} size="small" />
)}
</ProjectInfoGrid>
</Box>
<Stack
sx={{
direction: 'row',
justifyContent: 'space-between',
width: '100%',
alignItems: 'end',
}}
>
<CardBody>
<IconWithContent icon={<DateIcon />}>
{years.join(', ') || 'Год не указан'}
</IconWithContent>
<IconWithContent icon={<UserIcon />}>
{org_unit_name || 'Организация не указана'}
</IconWithContent>
</CardBody>
{exportMode && (
<div>
{isChecked ? (
<CheckBoxCheckSvg size="1.625rem" />
) : (
<CheckBoxUncheckSvg size="1.625rem" />
)}
</div>
)}
</Stack>
</ProjectCard>
<ModalCreateProject
open={isEditModalOpen}
onClose={handleCloseEdit}
onCreate={handleSaveProject}
config={projectData}
initialData={editFormData}
isEdit={true}
projectId={id}
/>
</>
);
};

View File

@ -1,298 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { ProjectsApi } from '../../api/projects';
import { CardsSkeleton } from '../../components/common/Skeleton';
import SearchComponent from '../../components/common/SearchComponent';
import { ProjectCardComponent } from './ProjectCard';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { PageContainer, TasksContainer } from '../StyledForPage';
import {
Box,
FormControl,
Button,
Stack,
Pagination,
PaginationItem,
Select,
MenuItem,
Typography,
IconButton,
Tooltip,
TextField,
Autocomplete,
} from '@mui/material';
import { toast } from 'react-toastify';
import { ModalCreateProject } from './ModalCreateProject';
import {
ROLES_NAME_ID,
} from '../../constants/constants';
import { useAuth } from '../../app/context/AuthProvider';
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import {
ExportExitButton,
ExportWithTextButton,
} from '../../components/common/Buttons/ButtonsActions';
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile';
import { WhitePlus } from '../../components/common/icons/icons';
import { useNavigate } from 'react-router-dom';
import { PaginatedList } from '../../components/common/PaginatedList';
import { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
import { SspApi } from '../../api/ssp';
import NavigationToggle from '../../components/common/NavigationToggle';
export { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
export default function ProjectsPage() {
const navigate = useNavigate();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
// Новые фильтры
const [yearFilter, setYearFilter] = useState('');
const [branchFilter, setBranchFilter] = useState('');
// Pagination state
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [totalCount, setTotalCount] = useState(0);
const { user } = useAuth();
const [exportMode, setExportMode] = useState(false);
const [exportList, setExportList] = useState([]);
const [projectData, setProjectData] = useState(DEFAULT_PROJECT_CONFIG);
// Получаем список годов для фильтра (можно динамически или задать диапазон)
const yearOptions = useMemo(() => {
const currentYear = new Date().getFullYear();
const years = [];
for (let year = currentYear - 5; year <= currentYear + 2; year++) {
years.push(year);
}
return years;
}, []);
useEffect(() => {
if (!user) return;
if (user.role_id == ROLES_NAME_ID.admin) {
SspApi.getAll().then(data => {
if (data.success) {
setProjectData(prev => ({
...prev, 'ССП/РФ': {
...prev['ССП/РФ'],
options: data.result
}
}))
return;
}
toast.error('Ошибка при получении ССП/РФ');
});
return;
}
setProjectData(prev => ({
...prev, 'ССП/РФ': {
...prev['ССП/РФ'],
options: user.org_units,
}
}))
}, [user])
const openModal = () => {
setModalOpen(true);
};
const mapFormDataToApi = (formData) => {
const apiData = {
name: formData['Название проекта'] || '',
project_type: formData['Тип проекта развития'] || null,
vsp_format: formData['Формат ВСП'] || null,
object_address: formData['Адрес объекта'] || '',
staff_count: formData['Целевая численность, чел.'] || 0,
placement_type: formData['Тип размещения'] || null,
total_area: formData['Площадь размещения, м2'] || 0,
year: formData['Год'] || null,
branch_id: formData['ССП/РФ'] || null,
};
return apiData;
};
const handleCreateProject = async (formData) => {
try {
const apiData = mapFormDataToApi(formData);
await ProjectsApi.create(apiData);
setPage(1);
loadProjects(1, limit);
setModalOpen(false);
toast.success('Проект успешно создан');
} catch (error) {
toast.error(
error.response?.data?.detail ||
error.response?.data?.message ||
'Ошибка при создании проекта',
);
}
};
const loadProjects = async (currentPage = page, currentLimit = limit) => {
setLoading(true);
try {
const skip = (currentPage - 1) * currentLimit;
const params = {
offset: skip,
limit: currentLimit,
// Добавляем фильтры в параметры запроса
...(yearFilter && { year: parseInt(yearFilter) }),
...(branchFilter && { branch_id: parseInt(branchFilter.id) }),
};
const res = await ProjectsApi.list(params);
const data = res.result;
setItems(data);
setTotalCount(res.count);
} catch (error) {
toast.error('Ошибка загрузки проектов');
} finally {
setLoading(false);
}
};
// Сбрасываем страницу при изменении любых фильтров
useEffect(() => {
setPage(1);
loadProjects(1, limit);
}, [yearFilter, branchFilter]);
const handlePageChange = (event, value) => {
setPage(value);
loadProjects(value, limit);
};
const handleLimitChange = (event) => {
const newLimit = event.target.value;
setLimit(newLimit);
setPage(1);
loadProjects(1, newLimit);
};
// Сброс всех фильтров
const handleClearFilters = () => {
setYearFilter('');
setBranchFilter('');
setPage(1);
};
useEffect(() => {
loadProjects(page, limit);
}, []);
useEffect(() => {
setExportList([]);
}, [exportMode]);
const handleChangeToExportList = (projectId, checked) => {
if (checked) {
setExportList((prev) => [...prev, projectId]);
} else {
const newExportList = exportList.filter((t) => t != projectId);
setExportList(newExportList);
}
};
const handleBulkExport = async () => {
await exportBulkForms(exportList);
setExportMode(false);
};
const handleSingleExport = (projectId, projectTitle) => {
exportSingleForm(projectId, projectTitle);
};
const handleRefresh = () => {
loadProjects(page, limit);
toast.info('Список проектов обновлен');
};
if (!user) return null;
return (
<>
<PageContainer>
<HeaderSwitch />
<NavigationToggle currentView={'projects'} onViewChange={(path) => navigate(path)} />
<PaginatedList
items={items}
loading={loading}
totalCount={totalCount}
page={page}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
entityName="проектов"
emptyMessage="Проекты не найдены"
renderItem={(project, exportProps) => (
<ProjectCardComponent
key={project.id}
project={project}
projectDataInit={projectData}
{...exportProps}
/>
)}
getItemId={(project) => project.id}
getItemTitle={(project) => project.name || 'Проект'}
extraActions={
user.role_id === ROLES_NAME_ID.admin && (
<PrimaryButton onClick={openModal} startIcon={<WhitePlus />} height='2.5rem' >
Создать проект
</PrimaryButton>
)
}
filterActions={
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center', height: '2.5rem' }}>
<Select
size="small"
displayEmpty
value={yearFilter}
onChange={(e) => setYearFilter(e.target.value)}
sx={{ width: 130 }}
>
<MenuItem value="">Все годы</MenuItem>
{yearOptions.map((year) => (
<MenuItem key={year} value={year}>{year}</MenuItem>
))}
</Select>
<Autocomplete
size="small"
options={projectData['ССП/РФ']?.options ?? []}
noOptionsText="Не найдено"
getOptionLabel={(option) => option.title || option.name || '-'}
value={branchFilter}
onChange={(event, newValue) => setBranchFilter(newValue)}
sx={{ width: 200 }}
renderInput={(params) => (
<TextField {...params} placeholder="Все" size="small" />
)}
/>
<PrimaryOutlinedButton onClick={handleClearFilters} height="100%"> Сбросить</PrimaryOutlinedButton>
</Box>
}
/>
</PageContainer>
<ModalCreateProject
open={modalOpen}
onClose={() => setModalOpen(false)}
onCreate={handleCreateProject}
config={projectData}
/>
</>
);
}

View File

@ -1,8 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
const PageContainer = styled.div` const PageContainer = styled.div`
padding: 2rem; padding: 2.5rem;
padding-top: 1rem;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -57,7 +57,7 @@ export default function TablePage() {
const connection = connectToTable(tableId, user?.id, { const connection = connectToTable(tableId, user?.id, {
onError: () => { onError: () => {
toast.error('Ошибка подключения к серверу'); toast.error('Ошибка подключения к серверу реального времени');
}, },
onClose: (event) => { onClose: (event) => {
if (isUnmountingRef.current) { if (isUnmountingRef.current) {

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Box, Button, Divider, TextField, Typography } from '@mui/material'; import { Box, Button, Divider, TextField, Typography } from '@mui/material';
import SelectWithOptions from '../AdminPanel/components/SelectWithOptions'; import SelectWithOptions from '../AdminPanel/components/SelectWithOptions';
import Modal from '../../components/common/Modal/Modal'; import Modal from '../../components/common/Modal/Modal';
@ -9,98 +9,25 @@ export const ModalCreateProject = ({
onClose, onClose,
onCreate, onCreate,
config = {}, config = {},
initialData = null,
title = 'Создание проекта',
buttonText = 'Создать',
}) => { }) => {
const [projectData, setProjectData] = useState(() => { const [projectData, setProjectData] = useState(() => {
if (initialData) { const initialData = {};
return { ...initialData };
}
// Иначе создаем из конфига
const data = {};
Object.keys(config).forEach((key) => { Object.keys(config).forEach((key) => {
data[key] = config[key].defaultValue; initialData[key] = config[key].defaultValue;
}); });
return data; return initialData;
}); });
const [errors, setErrors] = useState({});
useEffect(() => {
if (initialData) {
setProjectData({ ...initialData });
} else {
const data = {};
Object.keys(config).forEach((key) => {
data[key] = config[key].defaultValue;
});
setProjectData(data);
}
setErrors({});
}, [initialData, config, open]);
const handleChange = (field, value) => { const handleChange = (field, value) => {
setProjectData((prev) => ({ setProjectData((prev) => ({
...prev, ...prev,
[field]: value, [field]: value,
})); }));
if (errors[field]) {
setErrors((prev) => ({
...prev,
[field]: undefined,
}));
}
};
const validateField = (fieldName, config) => {
if (config.required_field) {
const value = projectData[fieldName];
if (config.type === 'multiselect') {
if (!value || (Array.isArray(value) && value.length === 0) || value === '') {
return `Поле "${fieldName}" обязательно для заполнения`;
}
} else if (config.type === 'text') {
if (!value || value.trim() === '') {
return `Поле "${fieldName}" обязательно для заполнения`;
}
} else if (config.type === 'number') {
if (value === null || value === undefined || value === '') {
return `Поле "${fieldName}" обязательно для заполнения`;
}
if (config.min !== undefined && Number(value) < config.min) {
return `Значение должно быть не меньше ${config.min}`;
}
if (config.max !== undefined && Number(value) > config.max) {
return `Значение должно быть не больше ${config.max}`;
}
}
}
return null;
};
const validateAllFields = () => {
const newErrors = {};
let hasErrors = false;
Object.entries(config).forEach(([fieldName, fieldConfig]) => {
const error = validateField(fieldName, fieldConfig);
if (error) {
newErrors[fieldName] = error;
hasErrors = true;
}
});
setErrors(newErrors);
return !hasErrors;
}; };
const handleClickCreate = () => { const handleClickCreate = () => {
if (validateAllFields()) { onCreate(projectData);
onCreate(projectData); onClose();
onClose();
}
}; };
const handleClose = () => { const handleClose = () => {
@ -109,14 +36,10 @@ export const ModalCreateProject = ({
resetData[key] = config[key].defaultValue; resetData[key] = config[key].defaultValue;
}); });
setProjectData(resetData); setProjectData(resetData);
setErrors({});
onClose(); onClose();
}; };
const renderField = (fieldName, config) => { const renderField = (fieldName, config) => {
const hasError = !!errors[fieldName];
const errorText = errors[fieldName];
switch (config.type) { switch (config.type) {
case 'multiselect': case 'multiselect':
return ( return (
@ -127,13 +50,9 @@ export const ModalCreateProject = ({
fontWeight: '400', fontWeight: '400',
fontSize: '0.75rem', fontSize: '0.75rem',
paddingBottom: '.25rem', paddingBottom: '.25rem',
color: hasError ? 'var(--error-color, #d32f2f)' : 'var(--grey-dark)',
}} }}
> >
{fieldName} {fieldName}
{config.required_field && (
<span style={{ color: 'var(--error-color, #d32f2f)', marginLeft: '4px' }}>*</span>
)}
</Typography> </Typography>
<SelectWithOptions <SelectWithOptions
className="sameSize" className="sameSize"
@ -142,22 +61,7 @@ export const ModalCreateProject = ({
placeholder={config.placeholder} placeholder={config.placeholder}
onChange={(value) => handleChange(fieldName, value)} onChange={(value) => handleChange(fieldName, value)}
width="28.25rem" width="28.25rem"
style={{
borderColor: hasError ? 'var(--error-color, #d32f2f)' : undefined,
borderWidth: hasError ? '2px' : undefined,
}}
/> />
{hasError && (
<Typography
sx={{
color: 'var(--error-color, #d32f2f)',
fontSize: '0.75rem',
marginTop: '4px',
}}
>
{errorText}
</Typography>
)}
</Box> </Box>
); );
@ -169,14 +73,11 @@ export const ModalCreateProject = ({
sx={{ sx={{
fontWeight: '400', fontWeight: '400',
fontSize: '0.75rem', fontSize: '0.75rem',
color: hasError ? 'var(--error-color, #d32f2f)' : 'var(--grey-dark)', color: 'var(--grey-dark)',
paddingBottom: '.25rem', paddingBottom: '.25rem',
}} }}
> >
{fieldName} {fieldName}
{config.required_field && (
<span style={{ color: 'var(--error-color, #d32f2f)', marginLeft: '4px' }}>*</span>
)}
</Typography> </Typography>
<TextField <TextField
className="sameSize" className="sameSize"
@ -184,14 +85,6 @@ export const ModalCreateProject = ({
onChange={(e) => handleChange(fieldName, e.target.value)} onChange={(e) => handleChange(fieldName, e.target.value)}
placeholder={config.placeholder} placeholder={config.placeholder}
size="small" size="small"
error={hasError}
helperText={hasError ? errorText : ''}
FormHelperTextProps={{
sx: {
marginLeft: 0,
fontSize: '0.75rem',
},
}}
/> />
</Box> </Box>
); );
@ -204,14 +97,11 @@ export const ModalCreateProject = ({
sx={{ sx={{
fontWeight: '400', fontWeight: '400',
fontSize: '0.75rem', fontSize: '0.75rem',
color: hasError ? 'var(--error-color, #d32f2f)' : 'var(--grey-dark)', color: 'var(--grey-dark)',
paddingBottom: '.25rem', paddingBottom: '.25rem',
}} }}
> >
{fieldName} {fieldName}
{config.required_field && (
<span style={{ color: 'var(--error-color, #d32f2f)', marginLeft: '4px' }}>*</span>
)}
</Typography> </Typography>
<TextField <TextField
className="sameSize" className="sameSize"
@ -219,6 +109,7 @@ export const ModalCreateProject = ({
value={projectData[fieldName] || ''} value={projectData[fieldName] || ''}
onChange={(e) => { onChange={(e) => {
const inputValue = e.target.value; const inputValue = e.target.value;
//только числа
const isValidInput = const isValidInput =
/^\d*\.?\d*$/.test(inputValue) || inputValue === ''; /^\d*\.?\d*$/.test(inputValue) || inputValue === '';
if (isValidInput) { if (isValidInput) {
@ -227,8 +118,6 @@ export const ModalCreateProject = ({
}} }}
placeholder={config.placeholder} placeholder={config.placeholder}
size="small" size="small"
error={hasError}
helperText={hasError ? errorText : ''}
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
min: config.min, min: config.min,
@ -236,12 +125,6 @@ export const ModalCreateProject = ({
step: config.step || 1, step: config.step || 1,
}, },
}} }}
FormHelperTextProps={{
sx: {
marginLeft: 0,
fontSize: '0.75rem',
},
}}
/> />
</Box> </Box>
); );
@ -253,7 +136,7 @@ export const ModalCreateProject = ({
return ( return (
<Modal <Modal
title={title} title={'Создание проекта'}
open={open} open={open}
onClose={handleClose} onClose={handleClose}
customSize={'43.75'} customSize={'43.75'}
@ -271,7 +154,7 @@ export const ModalCreateProject = ({
onClick={handleClickCreate} onClick={handleClickCreate}
style={{ height: '2.5rem' }} style={{ height: '2.5rem' }}
> >
{buttonText} Создать
</PrimaryButton> </PrimaryButton>
</> </>
} }
@ -297,4 +180,4 @@ export const ModalCreateProject = ({
<Divider /> <Divider />
</Modal> </Modal>
); );
}; };

View File

@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { TasksApi } from '../../api/tasks'; import { TasksApi } from '../../api/tasks';
import { TablesApi } from '../../api/tables'; import { TablesApi } from '../../api/tables';
import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов
import { import {
TaskInfoContainer, TaskInfoContainer,
NameTask, NameTask,
@ -19,16 +18,17 @@ import { toast } from 'react-toastify';
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal'; import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
import { PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons'; import { PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import { FormsApi } from '../../api/forms'; import { FormsApi } from '../../api/forms';
import { ModalCreateProject } from './ModalCreateProject';
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions'; import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
import { exportSingleForm } from '../../utils/exportFile'; import { exportSingleForm } from '../../utils/exportForm';
import TablesList from '../../components/common/TableList/TableList'; import TablesList from '../../components/common/TableList/TableList';
import { DIRECTION_TRANSLATE, FORM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants/constants'; import { DIRECTION_TRANSLATE, FORM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants';
const TaskInfo = ({ task, isProject }) => { const TaskInfo = ({ task }) => {
const portalContent = ( const portalContent = (
<TaskInfoContainer> <TaskInfoContainer>
<BackButton to={isProject ? "/projects" : "/tasks"} /> <BackButton to="/tasks" />
<NameTask>{isProject ? 'Проект' : FORM_TYPE_TRANSLATE[task.form_type_code] || ''}</NameTask> <NameTask>{FORM_TYPE_TRANSLATE[task.form_type_code] || ''}</NameTask>
</TaskInfoContainer> </TaskInfoContainer>
); );
@ -40,13 +40,9 @@ const TaskInfo = ({ task, isProject }) => {
}; };
export default function TaskPage() { export default function TaskPage() {
const { taskId, projectId } = useParams(); const { taskId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const id = Number(taskId);
// Определяем, что у нас за ID и какой тип
const isProject = !!projectId;
const id = isProject ? Number(projectId) : Number(taskId);
const [task, setTask] = useState(null); const [task, setTask] = useState(null);
const [tables, setTables] = useState([]); const [tables, setTables] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -55,42 +51,23 @@ export default function TaskPage() {
const [modalProjectOpen, setModalProjectOpen] = useState(false); const [modalProjectOpen, setModalProjectOpen] = useState(false);
const [tempProjects, setTempProjects] = useState([]); const [tempProjects, setTempProjects] = useState([]);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [isProjectData, setIsProjectData] = useState(false);
useEffect(() => { useEffect(() => {
const getData = async () => { const getSheets = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
if (isProject) { const data = await TasksApi.getSheets(id);
// Получаем информацию о проекте // Преобразуем данные с учетом direction
const projectData = await ProjectsApi.get(id); const mappedTables = data.result.all_sheets.map(sheet => ({
setTask(projectData.result); name: SHEET_NAME[sheet.sheet_name] || sheet.sheet_name,
setIsProjectData(true); sheet: sheet.sheet_name,
direction: sheet.direction,
const mappedTables = projectData.result.reports.map(sheet => ({ }));
name: SHEET_NAME[sheet.report_type] || sheet.report_type, //пока скрыт
sheet: sheet.report_type, setTables(mappedTables.filter(t => t.sheet !== "OTCH9F",));
year: sheet.year,
}));
setTables(mappedTables);
} else {
// Получаем информацию о задаче
const taskData = await TasksApi.getById(id);
setTask(taskData.result);
setIsProjectData(false);
// Получаем таблицы задачи
const tablesData = await TasksApi.getSheets(id);
const mappedTables = tablesData.result.all_sheets.map(sheet => ({
name: SHEET_NAME[sheet.sheet_name] || sheet.sheet_name,
sheet: sheet.sheet_name,
direction: sheet.direction,
}));
setTables(mappedTables.filter(t => t.sheet !== "OTCH9F"));
}
} catch (error) { } catch (error) {
toast.error( toast.error(
error?.response?.data?.detail || `Ошибка получения данных ${isProject ? 'проекта' : 'задачи'}`, error?.response?.data?.detail || 'Ошибка получения данных задачи',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -98,11 +75,27 @@ export default function TaskPage() {
}; };
if (Number.isFinite(id)) { if (Number.isFinite(id)) {
getData(); getSheets();
} }
}, [id, isProject]); }, [id]);
// Объединили два useEffect в один useEffect(() => {
const getFormInfo = async () => {
setIsLoading(true);
try {
const data = await TasksApi.getById(id);
setTask(data.result);
} catch (error) {
// обработка ошибки
} finally {
setIsLoading(false);
}
};
if (Number.isFinite(id)) {
getFormInfo();
}
}, [id]);
const handleCreateProject = (projectData) => { const handleCreateProject = (projectData) => {
// логика создания проекта // логика создания проекта
@ -114,14 +107,6 @@ export default function TaskPage() {
return searchString.includes(query.toLowerCase()); return searchString.includes(query.toLowerCase());
}); });
// Функция для экспорта
const handleExport = () => {
const fileName = isProject
? `project_${task?.title || id}`
: `form_${task?.title || taskId}`;
exportSingleForm(id, fileName);
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center pt-12"> <div className="flex justify-center pt-12">
@ -132,7 +117,7 @@ export default function TaskPage() {
return ( return (
<> <>
{task && <TaskInfo task={task} isProject={isProject} />} {task && <TaskInfo task={task} />}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@ -158,7 +143,9 @@ export default function TaskPage() {
onChange={setQuery} onChange={setQuery}
/> />
<Stack sx={{ flexDirection: 'row', spacing: '.5rem', justifyContent: 'end', gap: '.5rem' }}> <Stack sx={{ flexDirection: 'row', spacing: '.5rem', justifyContent: 'end', gap: '.5rem' }}>
<ExportWithTextButton onClick={handleExport} /> <ExportWithTextButton
onClick={() => exportSingleForm(taskId, task?.title ?? 'form')}
/>
<PrimaryOutlinedButton <PrimaryOutlinedButton
variant="outlined" variant="outlined"
onClick={() => setModalStageOpen(true)} onClick={() => setModalStageOpen(true)}
@ -169,10 +156,9 @@ export default function TaskPage() {
<SettingStageModal <SettingStageModal
isOpen={modalStageOpen} isOpen={modalStageOpen}
onClose={() => setModalStageOpen(false)} onClose={() => setModalStageOpen(false)}
taskId={isProject ? undefined : taskId} taskId={taskId}
projectId={isProject ? projectId : undefined} mode="task"
mode={isProject ? "project" : "task"} formTypeCode={task?.form_type_code || ''}
formTypeCode={ task?.form_type_code || ''}
sheets={tables} sheets={tables}
/> />
@ -183,9 +169,8 @@ export default function TaskPage() {
query={query} query={query}
projects={projects} projects={projects}
filteredTables={filteredTables} filteredTables={filteredTables}
basePath={"/table"} basePath="/table"
formInfo={task} formInfo={task}
isProject={isProject}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -1,6 +1,6 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Box, Paper, Typography } from '@mui/material'; import { Box, Paper, Typography } from '@mui/material';
import { CustomCheckbox } from '../StyledCheckbox'; import { CustomCheckbox } from '../../components/common/StyledCheckbox';
export const OrgUnitsAutocompletePaper = forwardRef( export const OrgUnitsAutocompletePaper = forwardRef(
function OrgUnitsAutocompletePaper( function OrgUnitsAutocompletePaper(

View File

@ -27,7 +27,7 @@ import {
ORG_UNIT_TYPE_OPTIONS, ORG_UNIT_TYPE_OPTIONS,
ROLES_NAME_ID, ROLES_NAME_ID,
FORM_TYPE_TRANSLATE FORM_TYPE_TRANSLATE
} from '../../constants/constants'; } from '../../constants';
import { useAuth } from '../../app/context/AuthProvider'; import { useAuth } from '../../app/context/AuthProvider';
import { SspApi } from '../../api/ssp'; import { SspApi } from '../../api/ssp';
import { PrimaryButton } from '../../components/common/Buttons/Buttons'; import { PrimaryButton } from '../../components/common/Buttons/Buttons';
@ -35,13 +35,10 @@ import {
ExportExitButton, ExportExitButton,
ExportWithTextButton, ExportWithTextButton,
} from '../../components/common/Buttons/ButtonsActions'; } from '../../components/common/Buttons/ButtonsActions';
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile'; import { exportBulkForms, exportSingleForm } from '../../utils/exportForm';
import { WhitePlus } from '../../components/common/icons/icons'; import { WhitePlus } from '../../components/common/icons/icons';
import { OrgUnitsAutocompletePaper } from '../../components/common/OrgUnitsAutocompletePaper/OrgUnitsAutocompletePaper'; import { OrgUnitsAutocompletePaper } from './OrgUnitsAutocompletePaper';
import { renderOrgUnitValue } from '../../components/common/OrgUnitsAutocompletePaper/renderOrgUnitValue'; import { renderOrgUnitValue } from './renderOrgUnitValue';
import { PaginatedList } from '../../components/common/PaginatedList';
import NavigationToggle from '../../components/common/NavigationToggle';
import { useNavigate } from 'react-router';
const modalSelectMenuProps = { const modalSelectMenuProps = {
sx: { zIndex: 1301 }, sx: { zIndex: 1301 },
@ -55,8 +52,6 @@ const modalSelectSx = {
}; };
export default function TasksPage() { export default function TasksPage() {
const navigate = useNavigate();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingUpdate, setLoadingUpdate] = useState(false); const [loadingUpdate, setLoadingUpdate] = useState(false);
@ -260,34 +255,128 @@ export default function TasksPage() {
<> <>
<PageContainer> <PageContainer>
<HeaderSwitch /> <HeaderSwitch />
<NavigationToggle currentView={'tasks'} onViewChange={(path) => navigate(path)} /> <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<PaginatedList {exportMode && <span>Выделено задач: {exportList.length}</span>}
items={items} </Box>
loading={loading} <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
totalCount={totalCount} <div style={{ width: '20rem' }}>
page={page} {/* <SearchComponent
limit={limit} placeholder="Поиск по названию задачи"
onPageChange={handlePageChange} value={query}
onLimitChange={handleLimitChange} onChange={setQuery}
entityName="задач" /> */}
emptyMessage="Задачи не найдены" </div>
renderItem={(task, exportProps) => ( <Stack direction={'row'} spacing={'.5rem'}>
<TaskCardComponent {!exportMode ? (
key={task.id} <>
taskForm={task} <ExportWithTextButton
{...exportProps} text={'Пакетный экспорт'}
/> onClick={() => {
setExportMode(true);
}}
></ExportWithTextButton>
{user.role_id == ROLES_NAME_ID.admin && (
<PrimaryButton onClick={openModal} startIcon={<WhitePlus />}>
Создать задачу
</PrimaryButton>
)}
</>
) : (
<>
<ExportWithTextButton
text={'Экспортировать'}
onClick={handleBulkExport}
disabled={exportList.length === 0}
/>
<ExportExitButton onClick={() => setExportMode(false)} />
</>
)}
</Stack>
</Box>
{/* TODO: вернуть, когда будет реализовано на бэке */}
{/* <FilterComponent statusFilter={statusFilter} setStatusFilter={setStatusFilter} /> */}
<TasksContainer>
{loading ? (
<CardsSkeleton count={limit} />
) : (
<>
{items.map((t) => (
<TaskCardComponent
exportMode={exportMode}
taskForm={t}
key={t.id + '-task-id'}
onAddForExport={(checked) => {
handleChangeToExportList(t.id, checked);
}}
onExportClick={handleSingleExport}
/>
))}
</>
)} )}
getItemId={(task) => task.id} </TasksContainer>
getItemTitle={(task) => task.title || 'Задача'}
extraActions={ {/* Pagination Footer */}
user.role_id === ROLES_NAME_ID.admin && ( {!loading && items.length > 0 && (
<PrimaryButton onClick={openModal} startIcon={<WhitePlus />} height={'2.5rem'}> <Box
Создать задачу sx={{
</PrimaryButton> display: 'flex',
) justifyContent: 'space-between',
} alignItems: 'center',
/> pt: 2,
pb: 2,
borderTop: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="textSecondary">
Показать на странице:
</Typography>
<Select
value={limit}
onChange={handleLimitChange}
size="small"
sx={{ minWidth: 70 }}
disabled={loading}
>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={20}>20</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={100}>100</MenuItem>
<MenuItem value={200}>200</MenuItem>
</Select>
</Box>
</Box>
<Pagination
count={Math.ceil(totalCount / limit)}
page={page}
onChange={handlePageChange}
color="primary"
size="large"
showFirstButton
showLastButton
disabled={loading}
renderItem={(item) => (
<PaginationItem
{...item}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'white',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
/>
)}
/>
<Box sx={{ width: 120 }} /> {/* Spacer for symmetry */}
</Box>
)}
</PageContainer> </PageContainer>
<Modal <Modal
open={modalOpen} open={modalOpen}

Some files were not shown because too many files have changed in this diff Show More